<?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>The Schema Three Chapters Were Already Relying On (Chapter 14)</title>
      <dc:creator>Kamen</dc:creator>
      <pubDate>Mon, 02 Nov 2026 09:28:02 +0000</pubDate>
      <link>https://dev.to/kamenivanov/the-schema-three-chapters-were-already-relying-on-chapter-14-21j</link>
      <guid>https://dev.to/kamenivanov/the-schema-three-chapters-were-already-relying-on-chapter-14-21j</guid>
      <description>&lt;p&gt;Chapter 12 spent several pages arguing that the only guard against assigning the same product to the same category twice is a unique constraint on &lt;code&gt;(category_id, product_id)&lt;/code&gt;. It rejected a pessimistic read lock on the grounds that a lock delays a conflicting change rather than preventing it, settled on reading current state to classify a batch, and said plainly that the database is the only real arbiter of the race that remains.&lt;/p&gt;

&lt;p&gt;That constraint did not exist. Neither did the foreign key with &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; that the same chapter called a backstop, nor the &lt;code&gt;BINARY(16)&lt;/code&gt; column that the version 7 UUID decision quietly depends on. Three chapters of reasoning had been written against a schema that was still a sentence in a paragraph, and the previous chapter configured Flyway to apply migrations without there being a migration to apply.&lt;/p&gt;

&lt;p&gt;Writing it is where several earlier decisions stop being arguments and become something a database will enforce or refuse. A few of them also turn out to have been wrong, or at least incomplete, in ways only visible once the columns have types.&lt;/p&gt;




&lt;h2&gt;
  
  
  One file, or one per table
&lt;/h2&gt;

&lt;p&gt;Before any of that configuration does anything, there has to be something in &lt;code&gt;classpath:db/migrations&lt;/code&gt;, and there are two questions before the SQL: where the file lives and how many files there are.&lt;/p&gt;

&lt;p&gt;It lives in &lt;code&gt;dao-impl&lt;/code&gt;, with the entities, rather than in the application module that configured Flyway. The application module owns the wiring and says where to look, the module that owns persistence owns the shape of it. A schema in &lt;code&gt;application&lt;/code&gt; would mean that the module which knows what a &lt;code&gt;ProductEntity&lt;/code&gt; is and the module which knows what the &lt;code&gt;products&lt;/code&gt; table is are two different modules, and every column change would be a change in both. &lt;code&gt;classpath:&lt;/code&gt; is what makes this work without anyone declaring it: Flyway looks on the classpath and &lt;code&gt;dao-impl&lt;/code&gt; is on it, so the application never names the module that holds the migration.&lt;/p&gt;

&lt;p&gt;The second question is how many files it should be. One per table is the tempting answer, because it mirrors the code, where each entity has its own class. I went with one, and the reason is that a migration is not a unit of structure, it is a unit of change. These four tables are not four changes. &lt;code&gt;products&lt;/code&gt; has a foreign key to &lt;code&gt;product_specifications&lt;/code&gt;, &lt;code&gt;category_assignments&lt;/code&gt; has foreign keys to two of the others, and that fixes the only order they can be created in. Split into four files, two of them cannot be applied on their own at all and a third can only follow them. Flyway would run the four in sequence and arrive at the same schema, so nothing would break, but the history would now record four events where one happened, and a developer reading a &lt;code&gt;create_products&lt;/code&gt; migration in isolation would be looking at a fragment of a decision rather than a decision.&lt;/p&gt;

&lt;p&gt;The rule that follows is short enough to state once and then follow for the rest of the project. One file per change, named after the change. The baseline is a single file because the baseline is one change: the schema this application starts life with, which is why it is called &lt;code&gt;V01.00.00__init.sql&lt;/code&gt; rather than named after a table. The three-part version is a release number rather than a counter, so a migration's name says which version of the product introduced it, and ordering by filename still gives Flyway the sequence it needs. Everything after it gets its own file, whether it touches one table or four.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;`categories`&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;`id`&lt;/span&gt;            &lt;span class="nb"&gt;BINARY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;`created_at`&lt;/span&gt;    &lt;span class="nb"&gt;DATETIME&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                               &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nv"&gt;`updated_at`&lt;/span&gt;    &lt;span class="nb"&gt;DATETIME&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                               &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nv"&gt;`created_by_id`&lt;/span&gt; &lt;span class="nb"&gt;BINARY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;`updated_by_id`&lt;/span&gt; &lt;span class="nb"&gt;BINARY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;`version`&lt;/span&gt;       &lt;span class="nb"&gt;INTEGER&lt;/span&gt;                                   &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;`name`&lt;/span&gt;          &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                              &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;`description`&lt;/span&gt;   &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                      &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;`status`&lt;/span&gt;        &lt;span class="nb"&gt;ENUM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'INACTIVE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ACTIVE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ARCHIVED'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;`id`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;`uk_categories_name`&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;`name`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;InnoDB&lt;/span&gt;
  &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;CHARSET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;utf8mb4&lt;/span&gt;
  &lt;span class="k"&gt;COLLATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;utf8mb4_0900_ai_ci&lt;/span&gt;
  &lt;span class="n"&gt;ROW_FORMAT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;DYNAMIC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is &lt;code&gt;categories&lt;/code&gt;, and its first six columns are the audit base classes rendered into SQL. They repeat verbatim in &lt;code&gt;products&lt;/code&gt;, which adds a name, a SKU, a price, a status and a nullable &lt;code&gt;specification_id&lt;/code&gt;, and in &lt;code&gt;product_specifications&lt;/code&gt;, which adds a dimension and a weight. &lt;code&gt;category_assignments&lt;/code&gt; is the exception and carries only three of them, having dropped &lt;code&gt;updated_at&lt;/code&gt;, &lt;code&gt;updated_by_id&lt;/code&gt; and &lt;code&gt;version&lt;/code&gt; along with the base class that declares them, and added the two columns it exists for. The whole file is in the repository at this chapter's tag, and the part of it worth reading inline is the bottom of that last table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;    &lt;span class="no"&gt;PRIMARY&lt;/span&gt; &lt;span class="nf"&gt;KEY&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="no"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="n"&gt;uk_assignments_category_product&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt; &lt;span class="no"&gt;UNIQUE&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="n"&gt;category_id&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="no"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="n"&gt;fk_assignments_category&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt; &lt;span class="no"&gt;FOREIGN&lt;/span&gt; &lt;span class="nf"&gt;KEY&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="n"&gt;category_id&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="no"&gt;REFERENCES&lt;/span&gt; &lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="no"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="n"&gt;fk_assignments_product&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt; &lt;span class="no"&gt;FOREIGN&lt;/span&gt; &lt;span class="nf"&gt;KEY&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="no"&gt;REFERENCES&lt;/span&gt; &lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="no"&gt;ON&lt;/span&gt; &lt;span class="no"&gt;DELETE&lt;/span&gt; &lt;span class="no"&gt;CASCADE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Sixteen bytes, in the right order
&lt;/h2&gt;

&lt;p&gt;The identifier column is &lt;code&gt;BINARY(16)&lt;/code&gt;, which is sixteen bytes rather than the thirty-six a &lt;code&gt;CHAR(36)&lt;/code&gt; would take, and this is where the &lt;code&gt;@UuidGenerator(style = VERSION_7)&lt;/code&gt; decision from Chapter 12 collects the rest of its payment. In InnoDB the primary key is the clustered index, so rows are physically stored in primary key order and every insert goes wherever its key sorts. A version 4 UUID is random, which means inserts land uniformly across the whole index, and a table larger than the buffer pool starts reading pages from disk in order to write to them. A version 7 UUID carries a millisecond timestamp in its leading bits, so in the byte ordering that &lt;code&gt;BINARY(16)&lt;/code&gt; preserves, successive inserts sort next to each other and the writes concentrate on the right-hand edge of the index. Chapter 12 wanted version 7 because in-memory id assignment is what lets Hibernate batch inserts at all. This is the second half of the same decision, and it only holds if the column preserves the ordering, which the hyphenated text form in a &lt;code&gt;CHAR(36)&lt;/code&gt; does not.&lt;/p&gt;




&lt;h2&gt;
  
  
  A specification with a life of its own
&lt;/h2&gt;

&lt;p&gt;The specification lives in its own table rather than as two more columns on &lt;code&gt;products&lt;/code&gt;, and that is a modelling decision rather than a normalisation reflex. A specification is a thing with an identity and a life of its own: it is created, it is amended, and it is audited, which is why it carries the same audit columns as everything else here. Flattening it into &lt;code&gt;products&lt;/code&gt; would mean a product row that cannot say who last changed its dimensions as distinct from who last changed its price. The cost is a join on every read that needs it and a nullable foreign key, since a product can exist before anyone has measured it.&lt;/p&gt;

&lt;p&gt;The uniqueness on &lt;code&gt;specification_id&lt;/code&gt; is what makes the relationship actually one to one. A foreign key on its own permits two products to point at the same specification row, and the day that happens, editing one product's weight silently edits another's. The unique constraint forbids it at the only level that can be trusted, and it conveniently supplies the index InnoDB requires on the referencing side of the foreign key, so the constraint earns its keep twice. It also does not get in the way of the nullable column, because MySQL treats each &lt;code&gt;NULL&lt;/code&gt; as distinct for uniqueness purposes, so any number of products can be waiting to be measured while no two can share a measurement.&lt;/p&gt;

&lt;p&gt;What the schema cannot express is the other half of that relationship. Deleting a product leaves its specification behind as an orphan, because the foreign key points from product to specification and a cascade in that direction would be backwards. There is no &lt;code&gt;ON DELETE&lt;/code&gt; clause that fixes this, which is why the association on &lt;code&gt;ProductEntity&lt;/code&gt; carries &lt;code&gt;orphanRemoval = true&lt;/code&gt; and &lt;code&gt;CascadeType.ALL&lt;/code&gt;, leaving Hibernate to issue the second delete. Worth being explicit that this is now a rule living in the mapping rather than in the database, which means a row deleted by hand in a SQL client will leave the orphan the application would have cleaned up. That is an acceptable asymmetry as long as it is a known one.&lt;/p&gt;




&lt;h2&gt;
  
  
  The column that changes behaviour
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;version&lt;/code&gt; column is the one addition here that changes behaviour rather than describing it. Paired with a &lt;code&gt;@Version private Integer version&lt;/code&gt; on &lt;code&gt;AbstractUpdatableEntity&lt;/code&gt;, it gives Hibernate an optimistic lock: every update carries the version it read in its &lt;code&gt;WHERE&lt;/code&gt; clause and increments it, so an update that matches no rows means someone else got there first. This is the problem Chapter 12 looked at and declined to solve with a pessimistic read lock, on the grounds that holding a lock only delays a conflicting change rather than preventing it. Optimistic locking makes a narrower and more useful promise. It does not order concurrent operations, it detects a lost update: two requests both read a product at version 3, both compute a new state from it, and without this column the second write silently discards the first. With it, the second write fails and the caller finds out.&lt;/p&gt;

&lt;p&gt;That failure needs somewhere to go, and as the exception handler stands at the end of Chapter 11, it does not have one. Spring translates the Hibernate failure into &lt;code&gt;ObjectOptimisticLockingFailureException&lt;/code&gt;, which descends from &lt;code&gt;TransientDataAccessException&lt;/code&gt; rather than from &lt;code&gt;DataIntegrityViolationException&lt;/code&gt;, so the integrity-violation handler that catches the unique-constraint race does not catch this one. It falls through to the &lt;code&gt;Throwable&lt;/code&gt; mapping and becomes a 500, which is wrong in the way that matters: the request was not malformed and the server is not broken, the state moved underneath it, and retrying is a reasonable thing for the caller to do. It belongs with the other conflicts, as a 409, and adding it is a two-line change to a class that already has the shape for it. Which base class gets the field is worth deciding on purpose rather than by instinct, and the instinct is to put it next to the identifier so everything in the system has one. A version number is a guard on update, so the concept arrives with the class that introduces updating, exactly as Chapter 10 argued for &lt;code&gt;changeStatus()&lt;/code&gt;. One level higher and every creation-only table carries a column that can never hold anything but zero. &lt;code&gt;category_assignments&lt;/code&gt; is such a table, which is why it is the only one here without the column, and that absence is the schema agreeing with the code rather than an omission.&lt;/p&gt;

&lt;p&gt;There is a consequence of that choice sitting directly on top of Chapter 12's batching. Spring Data decides between &lt;code&gt;persist&lt;/code&gt; and &lt;code&gt;merge&lt;/code&gt; by looking at the version property first when that property is of a non-primitive type, treating &lt;code&gt;null&lt;/code&gt; as new, and falling back to inspecting the identifier when there is no such property. So products and categories are classified by their version, while &lt;code&gt;CategoryAssignmentEntity&lt;/code&gt;, having none, is classified by its id. Both land on &lt;code&gt;persist&lt;/code&gt;, and that is precisely what keeps &lt;code&gt;saveAllAndFlush&lt;/code&gt; batching: a &lt;code&gt;merge&lt;/code&gt; would issue a select before every insert, turning a two-hundred-item assignment from four round trips into two hundred selects and two hundred inserts. Nothing would break, no test would fail, and the performance decision from the previous chapter would have quietly gone. Giving the assignment entity a version it does not need, or assigning its id in the transformer before persistence, are both ways to cause that. The field has to be a wrapper rather than a primitive for the same reason. &lt;code&gt;int&lt;/code&gt; cannot be null, so Spring Data stops asking the version and falls back to the identifier, which here still happens to answer correctly because Hibernate assigns the id during persistence. The behaviour would be right by accident rather than by design, and the next change to how ids are assigned would quietly take it away.&lt;/p&gt;

&lt;p&gt;It is also the only field in these classes with no getter and no setter, and that is intentional rather than an oversight. The annotations in this project sit on fields, so Hibernate reads and writes through reflection and needs neither. What a setter would add is the ability for something in the application to assign a version, and assigning a version is the only way to defeat the lock from the inside: a transformer that mechanically copies every property would eventually copy a number read minutes earlier and turn the check into a comparison with itself. A version is established by persistence, not set by callers, which is the same argument Chapter 10 made for &lt;code&gt;changeStatus()&lt;/code&gt; over a status setter. A short comment saying the field exists for Hibernate is worth adding, because a field with no accessors looks unused to every static analyser and to the next person tidying up.&lt;/p&gt;

&lt;p&gt;Two things changed behaviour the moment the column was mapped, and neither is in the paragraph above. The first is deletion. The DAO deletes by loading the row and handing the entity to the repository, and once there is a version Hibernate puts it in the &lt;code&gt;DELETE&lt;/code&gt; statement's &lt;code&gt;WHERE&lt;/code&gt; clause, so a row that changed between the load and the delete now refuses to be deleted. That is the correct answer - you are deleting something that is no longer what you read, but it's a second path to the same unhandled exception, which makes the missing handler slightly more than a convenience.&lt;/p&gt;

&lt;p&gt;The second was a bug the column exposed rather than caused. Updating a product used to replace its specification with a freshly constructed entity built from the domain object. With &lt;code&gt;orphanRemoval&lt;/code&gt; that already meant a delete and an insert where an update was intended, which was wasteful and worked. With a version it stops working: the new entity carries an identifier that already exists and a version that is null, so it is classified as new, and &lt;code&gt;persist&lt;/code&gt; with an occupied identifier is not a quiet failure. A persistence test caught it within minutes of the field being added, which is the first time in this chapter that something failed loudly rather than being found by reading two files side by side. The test was &lt;code&gt;AbstractCrudTestCase.testUpdate&lt;/code&gt;, the shared base case from Chapter 7 that every entity inherits, so nothing had been written to look for this.&lt;/p&gt;

&lt;p&gt;What it reported was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ObjectOptimisticLockingFailureException: Row was already updated or deleted by another
transaction for entity [ProductSpecificationEntity with id '01a19b12-...']
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nobody had updated or deleted anything. There was one thread and one transaction. The replacement entity carried an identifier that already existed and a version of null, so the statement Hibernate issued could not match any row, and zero rows updated is indistinguishable, at the layer that counts them, from somebody else having got there first. The message is not wrong so much as it is reporting the only conclusion available to it, which is worth remembering the next time one of these appears in a log: it says a row moved, and it means a statement matched nothing.&lt;/p&gt;

&lt;p&gt;The fix is to copy into the specification that was loaded rather than to replace it. The reason it is worth telling is that the schema change did not break the code - it revealed that the code had been describing the wrong operation all along, and that a test had been passing because nothing was checking. What this version protects is narrower than it first appears, the lock travels with the entity inside a single unit of work, so it covers the DAO's own load-then-write and not the wider window between the service reading a product and the service writing it back. Closing that one means carrying the version out through the domain type and into the API, which is a conversation about request headers and belongs to the chapter that builds the REST layer. The column is a prerequisite for it rather than a substitute.&lt;/p&gt;




&lt;h2&gt;
  
  
  Enums and timestamps, stored on purpose
&lt;/h2&gt;

&lt;p&gt;Both status columns are a MySQL &lt;code&gt;ENUM&lt;/code&gt; holding the names from the Java enum, and neither is a &lt;code&gt;VARCHAR&lt;/code&gt; nor an ordinal. The ordinal is the easy one to reject, storing &lt;code&gt;ProductStatus.ACTIVE&lt;/code&gt; as &lt;code&gt;2&lt;/code&gt; means the database's meaning of &lt;code&gt;2&lt;/code&gt; is defined by the declaration order of a Java enum, and inserting a new constant in the middle of that enum silently reinterprets every existing row. Nothing in the database changed and every row now means something else.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;VARCHAR(32)&lt;/code&gt; is where I started, and it is the defensible default: it costs a few bytes a row, it is readable in a query result, and it leaves the set of legal values entirely to the application. What it does not do is refuse anything. A column that accepts any thirty-two characters will accept &lt;code&gt;'ACTIVEE'&lt;/code&gt;, and the row that results is one no code path can produce and no code path can handle, which makes it the kind of data that is discovered months later by a &lt;code&gt;switch&lt;/code&gt; that throws.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;ENUM&lt;/code&gt; refuses it. In strict mode the insert is an error rather than a row, and that is worth more here than it would be in most places, because these two enums are not open sets. &lt;code&gt;ProductStatus&lt;/code&gt; and &lt;code&gt;CategoryStatus&lt;/code&gt; declare transitions in code - &lt;code&gt;canTransitionTo&lt;/code&gt; on each constant, which means the legal values are not a list somebody appends to as the business grows but a lifecycle with rules attached. A new status is never only a new string: it is a new set of transitions, a decision about what may become it and what it may become, and work in the service layer. Something that can never be added casually is exactly the kind of thing that can afford to be schema.&lt;/p&gt;

&lt;p&gt;The usual objection is the second source of truth, and it holds: the set now exists in the Java enum and in the column definition, and the migration that adds a value has to be deployed before the code that writes it. For a list that genuinely grows that is a tax with nothing to show for it. For a lifecycle it is closer to a feature, because the deployment order it forces is the correct one anyway - the database should know about a state before a request can put a row into it.&lt;/p&gt;

&lt;p&gt;The cost is elsewhere, in the comparison semantics. An &lt;code&gt;ENUM&lt;/code&gt; column is an index internally, so &lt;code&gt;WHERE status = 2&lt;/code&gt; quietly matches the second member of the list and &lt;code&gt;ORDER BY status&lt;/code&gt; sorts by declaration order rather than alphabetically. That is the ordinal hazard reappearing in queries after being rejected in storage, and the only mitigation is the one taken here: declare the values in the same order as the Java enum, so the index order is the lifecycle order and a sort by status at least means something. Anyone writing SQL against these columns by hand should compare against the string.&lt;/p&gt;

&lt;p&gt;One asymmetry to know about rather than fix. The persistence tests from Chapter 7 run on H2 with the schema generated from the entities, so the column there is whatever &lt;code&gt;@Enumerated(STRING)&lt;/code&gt; produces and not an &lt;code&gt;ENUM&lt;/code&gt; at all. The guard this section is about exists in production and nowhere else, and no test touches it. That is acceptable because the application is not the thing the guard protects against - it already writes only valid names, but it does mean the protection is the one decision in this file that the test suite cannot confirm.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DATETIME(3)&lt;/code&gt; rather than &lt;code&gt;DATETIME&lt;/code&gt; is not cosmetic. MySQL's default temporal precision is whole seconds, so a plain &lt;code&gt;DATETIME&lt;/code&gt; column truncates everything below the second without complaint, and &lt;code&gt;Instant&lt;/code&gt; carries far more than that. The failure that produces is one of the quieter ones: two rows written a few milliseconds apart come back with identical &lt;code&gt;created_at&lt;/code&gt; values, an ordering by creation time becomes non-deterministic, and a test that sorts by timestamp passes or fails depending on how fast the machine is. Three digits is a deliberate stopping point rather than the maximum, because milliseconds are the finest granularity anything in this system actually reasons about, and it is worth knowing that the H2 database behind the persistence tests from Chapter 7 does not truncate the same way, so a timestamp compared across a round trip behaves slightly differently in a test than in production.&lt;/p&gt;

&lt;p&gt;The database-side defaults on those columns deserve a note precisely because they almost never fire. Hibernate includes every mapped column in both its inserts and its updates, so the timestamps always arrive with a value, and an explicitly supplied value is also the condition under which MySQL skips &lt;code&gt;ON UPDATE CURRENT_TIMESTAMP(3)&lt;/code&gt;. They are not redundancy for its own sake: they are what makes a row written outside the application, by a later migration or by someone at a SQL prompt, come out with sane audit timestamps instead of zeroes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Constraints as the only real arbiter
&lt;/h2&gt;

&lt;p&gt;The unique constraint on &lt;code&gt;(category_id, product_id)&lt;/code&gt; is the one Chapter 12 leaned on. That chapter walked through several ways of preventing the same product from being assigned to the same category twice, rejected a pessimistic read lock, and settled on reading current state to classify the batch while letting the database be the only real arbiter of the race that remains. This line is that arbiter, two concurrent requests assigning the same product both read an empty assignment table, both decide the insert is needed, and one of them receives a &lt;code&gt;DataIntegrityViolationException&lt;/code&gt; that the handler turns into a 409.&lt;/p&gt;

&lt;p&gt;The two foreign keys out of &lt;code&gt;category_assignments&lt;/code&gt; are asymmetric. Deleting a product cascades to its assignments, because an assignment to a product that no longer exists is not data, it is a dangling row, and Chapter 12 described this as a backstop rather than a mechanism: the service deletes assignments explicitly, and the cascade covers the paths the service does not own. The category side has no cascade, which means deleting a category with products still assigned to it fails. That is intentional, categories are platform-owned taxonomy and archiving is the operation that retires one, so a delete that quietly took assignments with it would be silent data loss dressed up as convenience. There is one index declared outright, on &lt;code&gt;products.created_at&lt;/code&gt;, and it is there because creation time is how a product list is ordered and paged, which is a read pattern rather than a constraint and so has no other line in the file to arrive on.&lt;/p&gt;

&lt;p&gt;There is also one index nobody declared and the system depends on anyway. The unassign path queries assignments by &lt;code&gt;product_id&lt;/code&gt; alone, which the unique constraint on &lt;code&gt;(category_id, product_id)&lt;/code&gt; cannot serve, since a composite index is only usable from its leftmost column. InnoDB requires an index on the referencing columns of every foreign key and creates one silently when the schema does not provide it, so &lt;code&gt;fk_assignments_product&lt;/code&gt; leaves a usable &lt;code&gt;product_id&lt;/code&gt; index behind as a side effect. This is a MySQL behaviour rather than a general one: PostgreSQL does not index foreign key columns, and the same schema ported there would quietly turn that query into a sequential scan. Depending on it is fine as long as the dependency is known rather than accidental, which is why it is written down here.&lt;/p&gt;




&lt;h2&gt;
  
  
  A migration that cannot fail is a suggestion
&lt;/h2&gt;

&lt;p&gt;One thing the file does not say is &lt;code&gt;IF NOT EXISTS&lt;/code&gt;. It reads as harmless defensiveness, and in a hand-run script it would be. Inside a Flyway migration it undoes work the rest of this chapter paid for. Flyway already guarantees that each migration runs exactly once by recording it in &lt;code&gt;flyway_schema_history&lt;/code&gt;, so the only situation in which &lt;code&gt;IF NOT EXISTS&lt;/code&gt; has anything to do is one where the table is already there and Flyway does not know about it, which is exactly the situation that should stop the application rather than be smoothed over. The migration would report success, the table would keep whatever shape it already had, and &lt;code&gt;setValidateOnMigrate(true)&lt;/code&gt; would have been satisfied by a history entry describing a schema change that never happened. A migration that cannot fail is not a migration, it is a suggestion.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the schema enforces
&lt;/h2&gt;

&lt;p&gt;Reading the file back, the migration turns out to be where four earlier chapters get audited. The UUID version from Chapter 12 only pays off because the column preserves byte ordering. The batching from the same chapter only survives because one table intentionally lacks a column. The &lt;code&gt;DataIntegrityViolationException&lt;/code&gt; handler from Chapter 11 only has something to catch because of one &lt;code&gt;UNIQUE&lt;/code&gt; line, and it turns out to be one handler short, because the optimistic lock this migration introduces produces a different exception family that currently becomes a 500.&lt;/p&gt;

&lt;p&gt;That last one is the pattern worth taking away. Most of these are not bugs in the sense of something failing: the application starts and a reviewer reading either the Java or the SQL on its own would find nothing wrong. The specification update is the exception, and it is instructive for being one - it had been wrong for several chapters and only became detectable once a column existed that could notice. They are disagreements between two files that are only visible when you put them side by side and ask what each one assumes about the other. A schema is where those assumptions become checkable, which is a good argument for writing it deliberately rather than letting a tool generate it from the entities.&lt;/p&gt;




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

&lt;p&gt;The application starts, the schema exists, and fourteen chapters of code still cannot answer a single request. There is no controller, no mapping, no request body, and the handler from Chapter 11 has nothing to hand a response back to.&lt;/p&gt;

&lt;p&gt;Chapter 15 builds the REST layer, and it inherits two unfinished things from this one. The optimistic lock introduced here produces an exception family the handler does not catch, which is a two-line change and a 409. And the version column only protects a single unit of work, so making it protect the window between a client reading a product and writing it back means carrying the version out through the domain type and into the API — which is a conversation about request headers, conditional requests and what a client is expected to send back.&lt;/p&gt;

&lt;p&gt;The module boundary gets tested for the first time too. The REST module holds the interfaces and the DTOs and depends on Jackson and the Swagger annotations rather than on &lt;code&gt;spring-boot-starter-web&lt;/code&gt;, which is a claim the previous chapter made and this one has no way to check. The first &lt;code&gt;@WebMvcTest&lt;/code&gt; is also what finally settles whether declaring &lt;code&gt;@ComponentScan&lt;/code&gt; by hand costs the filters that slice tests rely on, a question Part 1 of Chapter 13 reasoned about and left open.&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 14, use the following link:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Repository (Tag: &lt;code&gt;chapter-14-schema&lt;/code&gt;):&lt;/strong&gt; &lt;a href="https://github.com/KamenIvanov/advanced-spring-multimodule/tree/chapter-14-schema" 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/bootstrapping-the-system-part-3-making-it-run-chapter-13-eig"&gt;Read Chapter 13 Part 3: Bootstrapping the System, Making It Run&lt;/a&gt;&lt;br&gt;
▶️ Read Chapter 15: The REST Layer (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>database</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Our Redis cache was serving prices from 3 Hours ago. The invalidation strategy that looked correct on paper</title>
      <dc:creator>Kamen</dc:creator>
      <pubDate>Thu, 29 Oct 2026 11:25:51 +0000</pubDate>
      <link>https://dev.to/kamenivanov/our-redis-cache-was-serving-prices-from-3-hours-ago-the-invalidation-strategy-that-looked-correct-8ak</link>
      <guid>https://dev.to/kamenivanov/our-redis-cache-was-serving-prices-from-3-hours-ago-the-invalidation-strategy-that-looked-correct-8ak</guid>
      <description>&lt;p&gt;A support ticket arrived with a screenshot of a product page showing one price and a checkout page showing another. The price had been changed that morning, and the cache had been serving the old value for nearly three hours.&lt;/p&gt;

&lt;p&gt;On paper, our invalidation was textbook, prices were read through a cache-aside pattern: check Redis, fall back to the database on a miss, store the result with a three-hour TTL. When a price changed, the service updated the database and then evicted the key. Update, then evict, it is the sequence most articles recommend, and the code reviewers had approved it for exactly that reason.&lt;/p&gt;

&lt;p&gt;The flaw was where the eviction ran. It sat inside the same transaction as the update, which meant it executed before the commit. For a short window, Redis was empty while the database still held the old price. If a reader arrived in that window, it missed the cache, read the old price from the database, and wrote it back to Redis with a fresh three-hour TTL. Then the writer committed. The database now held the new price, the cache held the old one, and nothing was scheduled to correct it until the TTL expired.&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%2Ftznj1v62vxsv5fjeja6j.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%2Ftznj1v62vxsv5fjeja6j.png" alt="Eviction flow" width="800" height="501"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The window was only a few milliseconds wide, which is why it took a busy morning and a bulk price change on popular products to expose it. A product page that receives dozens of requests a second will put a reader into a few-millisecond window surprisingly often, and we ship price changes in bulk.&lt;/p&gt;

&lt;p&gt;Two changes closed it. The eviction now runs after the transaction commits, so a reader that misses the cache can only ever load a value that is already final. And the TTL dropped from three hours to five minutes as a backstop. The after-commit eviction handles the normal case, and the TTL bounds the damage when something bypasses it, such as a direct database update during a data fix, or an eviction that fails because Redis was briefly unreachable.&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%2Fi19vpdlpvxnz0ni3dni3.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%2Fi19vpdlpvxnz0ni3dni3.png" alt="Cache TTL" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The order "update, then evict" is only half of a correct strategy. The other half is when the eviction becomes visible relative to the commit, and a long TTL turns any mistake in that timing into a long-lived one. Treat the TTL as the safety net for your invalidation logic, not as a freshness policy, and size it by how long you can tolerate being wrong.&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>softwareengineering</category>
      <category>debugging</category>
      <category>backend</category>
      <category>database</category>
    </item>
    <item>
      <title>Bootstrapping the System, Part 3: Making It Run (Chapter 13)</title>
      <dc:creator>Kamen</dc:creator>
      <pubDate>Mon, 26 Oct 2026 08:13:18 +0000</pubDate>
      <link>https://dev.to/kamenivanov/bootstrapping-the-system-part-3-making-it-run-chapter-13-eig</link>
      <guid>https://dev.to/kamenivanov/bootstrapping-the-system-part-3-making-it-run-chapter-13-eig</guid>
      <description>&lt;p&gt;Parts 1 and 2 left the application with every bean it needs and a correct home for every setting it reads. What neither of them touched is the process.&lt;/p&gt;

&lt;p&gt;A process has properties that no properties file decides. How many requests it is allowed to hold at once, which is a concurrency model rather than a number. How a container's environment becomes something a JVM will accept, given that nothing hands a properties file to a container. What a log line looks like and where it lands, which no amount of &lt;code&gt;logging.level&lt;/code&gt; configuration answers. And what the thing can tell you about itself at three in the morning, when the only question that matters is which version is running.&lt;/p&gt;

&lt;p&gt;Those four are this part, and it ends with an image and a compose file, so that from here on the whole project starts with one command. The first of them is a single line that reaches further than anything else in the file.&lt;/p&gt;




&lt;h2&gt;
  
  
  Virtual threads change what the pool means
&lt;/h2&gt;

&lt;p&gt;It is this one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spring.threads.virtual.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Boot responds by running the servlet container's request handling on virtual threads, so every inbound request gets its own, and the asynchronous machinery inside Spring follows. The change is small to write and large in what it removes, because the thread pool that used to sit in front of everything was doing a job nobody had asked it to do.&lt;/p&gt;

&lt;p&gt;A classic Tomcat serves requests from a fixed pool, two hundred threads by default. A request that blocks on a database call holds one of those two hundred for the duration, which means that two hundred was never a thread count so much as a concurrency limit, and the limit was enforced in the one place in the stack that knew nothing about what the requests were doing. Exceed it and requests queue in the connector, which is admission control by accident: nobody chose two hundred, nobody tuned it against the database, and the number is in a different file from everything it affects.&lt;/p&gt;

&lt;p&gt;With virtual threads there is effectively no such ceiling, and the consequence is that the first real limit a request meets is the one in Part 2's datasource block. Ten thousand concurrent requests become ten thousand virtual threads, cheaply, and all ten thousand of them arrive at Hikari asking for one of twenty-five connections. Twenty-five proceed and the rest wait. This is the same backpressure as before, relocated to somewhere that actually understands the constraint, which is the right place for it, and it makes &lt;code&gt;connection-timeout=5000&lt;/code&gt; a far more consequential line than it looks. Five seconds is now the application's admission control: a request that cannot get a connection within it fails, visibly, with something the handler from Chapter 11 can turn into a response, rather than sitting in a queue nobody is measuring.&lt;/p&gt;

&lt;p&gt;Enabling this two years ago would have been a worse idea than it is now, and the reason is worth knowing because it is the sort of thing that silently undoes the whole feature. A virtual thread that blocked inside a &lt;code&gt;synchronized&lt;/code&gt; block used to pin itself to its carrier platform thread, so an application with a synchronized hot path quietly reverted to a small thread pool under load, which is the opposite of what the configuration appeared to request. JDK 24 removed that behaviour, and on the JDK 25 this project targets, a virtual thread blocking on a monitor unmounts like any other blocking operation. What remains is narrow: native frames can still pin, through a native method or the Foreign Function and Memory API calling back into blocking Java. Nothing in this codebase does either.&lt;/p&gt;

&lt;p&gt;What does not change is anything about the transaction model, which is worth stating because it is the first thing people assume breaks. A virtual thread is a thread. &lt;code&gt;@Transactional&lt;/code&gt; binds its entity manager to the current thread exactly as before, the &lt;code&gt;ThreadLocal&lt;/code&gt; machinery underneath it works unchanged, and the service layer cannot tell the difference. The one thing to be careful with is a &lt;code&gt;ThreadLocal&lt;/code&gt; used as a cache rather than as context: one that held two hundred copies of something under the old model will hold as many copies as there are concurrent requests under this one. There is no such cache here, which is the only reason this is a footnote rather than a section of its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  From compose to the JVM
&lt;/h2&gt;

&lt;p&gt;In a container, nothing passes a properties file. The entrypoint script does the translation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;var &lt;span class="k"&gt;in &lt;/span&gt;DB_URL DB_USER DB_PASSWORD ACTIVE_PROFILE&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"value=&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="nv"&gt;$var&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +&lt;span class="s2"&gt;"%T"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;: FATAL: &lt;/span&gt;&lt;span class="nv"&gt;$var&lt;/span&gt;&lt;span class="s2"&gt; is not set"&lt;/span&gt;
        &lt;span class="nb"&gt;exit &lt;/span&gt;1
    &lt;span class="k"&gt;fi
done

&lt;/span&gt;&lt;span class="nv"&gt;DEBUG_OPTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DEBUG&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;false&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"true"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;DEBUG_OPTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8787"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Strip carriage returns: .env files edited on Windows carry them,&lt;/span&gt;
&lt;span class="c"&gt;# and a trailing \r makes the JVM reject the last option.&lt;/span&gt;
&lt;span class="nv"&gt;JAVA_OPTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;JAVA_OPTS&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\r'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +&lt;span class="s2"&gt;"%T"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;: ===== Environment ====="&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +&lt;span class="s2"&gt;"%T"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;: ACTIVE_PROFILE=&lt;/span&gt;&lt;span class="nv"&gt;$ACTIVE_PROFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +&lt;span class="s2"&gt;"%T"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;: DB_URL=&lt;/span&gt;&lt;span class="nv"&gt;$DB_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +&lt;span class="s2"&gt;"%T"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;: DB_USER=&lt;/span&gt;&lt;span class="nv"&gt;$DB_USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +&lt;span class="s2"&gt;"%T"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;: ======================="&lt;/span&gt;

&lt;span class="nb"&gt;cd&lt;/span&gt; /runtime

&lt;span class="c"&gt;# $DEBUG_OPTS and $JAVA_OPTS are unquoted: word splitting is how&lt;/span&gt;
&lt;span class="c"&gt;# whitespace-separated JVM options arrive at java as separate arguments.&lt;/span&gt;
&lt;span class="c"&gt;# Options whose values contain spaces belong in JAVA_TOOL_OPTIONS instead.&lt;/span&gt;
&lt;span class="c"&gt;# exec allows Java to receive container shutdown signals directly.&lt;/span&gt;
&lt;span class="nb"&gt;exec &lt;/span&gt;java &lt;span class="nv"&gt;$DEBUG_OPTS&lt;/span&gt; &lt;span class="nv"&gt;$JAVA_OPTS&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"-Dspring.profiles.active=&lt;/span&gt;&lt;span class="nv"&gt;$ACTIVE_PROFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"-Dspring.datasource.url=&lt;/span&gt;&lt;span class="nv"&gt;$DB_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"-Dspring.datasource.username=&lt;/span&gt;&lt;span class="nv"&gt;$DB_USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"-Dspring.datasource.password=&lt;/span&gt;&lt;span class="nv"&gt;$DB_PASSWORD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-Ddebug&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-jar&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;JAR_LOCATION&lt;/span&gt;:?JAR_LOCATION&lt;span class="p"&gt; must be set&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spring Boot would have accepted &lt;code&gt;SPRING_DATASOURCE_URL&lt;/code&gt; directly, through relaxed binding, and this script could have been one line. Translating by hand buys two things.&lt;/p&gt;

&lt;p&gt;The first is the echo block. When a container will not start and the question is whether the variable arrived at all, this is the first thing in the log. With relaxed binding there is no such point: either Spring complains about something downstream, or you go digging through &lt;code&gt;docker inspect&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The second is that &lt;code&gt;DB_URL&lt;/code&gt; is a name this project owns. Compose files, deployment manifests and whatever CI does all refer to it, and none of them know that there is a Spring application on the other side. &lt;code&gt;SPRING_DATASOURCE_URL&lt;/code&gt; leaks the framework into the orchestration, and the day the framework changes, every one of those files changes with it.&lt;/p&gt;

&lt;p&gt;The cost is that the script needs maintaining, and a new setting has to be added to it rather than working automatically. That is fine while the list is short, and the list staying short is itself useful pressure.&lt;/p&gt;

&lt;p&gt;The explicit check at the top is there because an unset variable produces &lt;code&gt;-Dspring.datasource.password=&lt;/code&gt; and an attempt to connect with an empty password, which fails much further along and much less clearly. &lt;code&gt;exec&lt;/code&gt; makes the JVM process 1 in the container, so signals from Docker reach it directly rather than being swallowed by a shell that would have stayed between them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-Ddebug=false&lt;/code&gt; is not redundant either. Spring reads the &lt;code&gt;debug&lt;/code&gt; property, and if it is true, every autoconfiguration logger goes to DEBUG regardless of what &lt;code&gt;logback.xml&lt;/code&gt; says. Setting it explicitly means nothing in the environment can turn the log into a wall of Spring internals by accident.&lt;/p&gt;

&lt;p&gt;Three smaller things in that script are each a bug I would otherwise have hit twice. The debug agent is attached only when &lt;code&gt;DEBUG&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;, which keeps the decision in the environment rather than in the image, and makes a remote debugger something you turn on for a container instead of something you rebuild for. &lt;code&gt;JAVA_OPTS&lt;/code&gt; is piped through &lt;code&gt;tr -d '\r'&lt;/code&gt; because a &lt;code&gt;.env&lt;/code&gt; file edited on Windows carries carriage returns and a trailing one makes the JVM reject the last option with a message about a flag that looks perfectly spelled. And &lt;code&gt;$DEBUG_OPTS $JAVA_OPTS&lt;/code&gt; are the only unquoted expansions in the file: word splitting is how a whitespace-separated list of JVM options arrives at &lt;code&gt;java&lt;/code&gt; as separate arguments, which is also why an option whose value contains spaces belongs in &lt;code&gt;JAVA_TOOL_OPTIONS&lt;/code&gt; rather than here. The comments saying so are in the script because this is exactly the kind of line somebody tidies up by adding quotes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;${JAR_LOCATION:?JAR_LOCATION must be set}&lt;/code&gt; is the same instinct as the loop above it, applied to the one variable the loop cannot check, because it comes from the image rather than from the environment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Appenders in XML, levels in properties
&lt;/h2&gt;

&lt;p&gt;Logging is configured in two files, and the division between them is not arbitrary.&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;configuration&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;appender&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"console"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"ch.qos.logback.core.ConsoleAppender"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;encoder&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"ch.qos.logback.classic.encoder.PatternLayoutEncoder"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;pattern&amp;gt;&lt;/span&gt;%d{HH:mm:ss.SSS} [%thread] %highlight(%-5level) %cyan(%logger{36}) - %msg%n&lt;span class="nt"&gt;&amp;lt;/pattern&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/encoder&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/appender&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;appender&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"file"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"ch.qos.logback.core.rolling.RollingFileAppender"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;file&amp;gt;&lt;/span&gt;${LOG_DIR:-.}/advanced.log&lt;span class="nt"&gt;&amp;lt;/file&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;append&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/append&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;encoder&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"ch.qos.logback.classic.encoder.PatternLayoutEncoder"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;pattern&amp;gt;&lt;/span&gt;%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n&lt;span class="nt"&gt;&amp;lt;/pattern&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/encoder&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;rollingPolicy&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"ch.qos.logback.core.rolling.TimeBasedRollingPolicy"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;fileNamePattern&amp;gt;&lt;/span&gt;${LOG_DIR:-.}/%d{yyyy-MM, aux}/advanced-%d{yyyy-MM-dd}.log.gz&lt;span class="nt"&gt;&amp;lt;/fileNamePattern&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/rollingPolicy&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/appender&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;root&lt;/span&gt; &lt;span class="na"&gt;level=&lt;/span&gt;&lt;span class="s"&gt;"INFO"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;appender-ref&lt;/span&gt; &lt;span class="na"&gt;ref=&lt;/span&gt;&lt;span class="s"&gt;"console"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;appender-ref&lt;/span&gt; &lt;span class="na"&gt;ref=&lt;/span&gt;&lt;span class="s"&gt;"file"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/root&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/configuration&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;logback.xml&lt;/code&gt; holds structure: which appenders exist, what a line looks like, where the file goes and when it rolls. None of that differs between a laptop and production, and all of it is awkward to express as flat properties, which is exactly the case where a nested format earns its keep. The levels are not here. Every &lt;code&gt;logging.level.*&lt;/code&gt; is a property, because a level is a statement about an environment, and an environment is what profiles are for.&lt;/p&gt;

&lt;p&gt;That boundary is easy to state and easy to violate, and the reference project I lifted this file from violated it: a committed &lt;code&gt;&amp;lt;logger name="..." level="DEBUG" /&amp;gt;&lt;/code&gt; for the application's own package. It works on a laptop, it is invisible in review, and it ships DEBUG to production while &lt;code&gt;application-dev.properties&lt;/code&gt; sits there looking authoritative about log levels it no longer controls. Two files disagreeing about the same setting is worse than either of them being wrong, because the losing one still reads as true.&lt;/p&gt;

&lt;p&gt;The console pattern is coloured and the file pattern is not. Highlight escape codes are useful in a terminal and are literal bytes in a file, where they turn every &lt;code&gt;grep&lt;/code&gt; into a question about whether the match failed or the colour code got in the way. Same pattern otherwise, so a line copied out of one reads identically in the other.&lt;/p&gt;

&lt;p&gt;The log file's directory comes from &lt;code&gt;LOG_DIR&lt;/code&gt; with the working directory as its fallback, and that indirection exists because of a bug in the file I started from. That project wrote to &lt;code&gt;./app.log&lt;/code&gt; from logback and told the actuator its log file was at &lt;code&gt;/runtime/logs/app.log&lt;/code&gt;, so the logfile endpoint returned a 404 for a file that existed somewhere else entirely, and had done for long enough that nobody expected the endpoint to work. One variable, read by both, is the whole fix: logback writes there and &lt;code&gt;management.endpoint.logfile.external-file&lt;/code&gt; points at the same place.&lt;/p&gt;




&lt;h2&gt;
  
  
  An application that knows which commit it is
&lt;/h2&gt;

&lt;p&gt;The first question anyone asks a service that is behaving strangely is which version of itself it is running, and the answer is usually a guess based on when it was last deployed. Making the application answer it costs one resource file and one bean.&lt;/p&gt;

&lt;p&gt;Maven writes the file at build time by filtering a template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;buildJdk&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${java.version}  &lt;/span&gt;
&lt;span class="py"&gt;buildJvm&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${java.vm.vendor} ${java.vm.name} ${java.vm.version}  &lt;/span&gt;
&lt;span class="py"&gt;buildOs&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${os.name} ${os.version} (${os.arch})  &lt;/span&gt;
&lt;span class="py"&gt;buildTime&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${buildTime}  &lt;/span&gt;
&lt;span class="py"&gt;builtBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${user.name}  &lt;/span&gt;
&lt;span class="py"&gt;implementationBranch&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${scmBranch}  &lt;/span&gt;
&lt;span class="py"&gt;implementationRevision&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${scmRevision}  &lt;/span&gt;
&lt;span class="py"&gt;implementationTitle&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${project.artifactId}  &lt;/span&gt;
&lt;span class="py"&gt;implementationVendor&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${project.organization.name}  &lt;/span&gt;
&lt;span class="py"&gt;implementationVendorId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${project.groupId}  &lt;/span&gt;
&lt;span class="py"&gt;implementationVersion&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${project.version}  &lt;/span&gt;
&lt;span class="py"&gt;specificationTitle&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${project.name}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those placeholders are three different kinds of thing, which matters when one of them silently stays a placeholder. &lt;code&gt;project.*&lt;/code&gt; comes from the POM. &lt;code&gt;java.version&lt;/code&gt; and &lt;code&gt;user.name&lt;/code&gt; are JVM system properties that the resources plugin resolves while it runs, so they describe the machine that built the jar rather than the machine running it, which is the point. &lt;code&gt;scmBranch&lt;/code&gt;, &lt;code&gt;scmRevision&lt;/code&gt; and &lt;code&gt;buildTime&lt;/code&gt; all come from &lt;code&gt;buildnumber-maven-plugin&lt;/code&gt;, under names its configuration chooses rather than names Maven provides, which is why &lt;code&gt;timestampPropertyName&lt;/code&gt; is set to &lt;code&gt;buildTime&lt;/code&gt; and &lt;code&gt;buildNumberPropertyName&lt;/code&gt; to &lt;code&gt;scmRevision&lt;/code&gt;. None of the three exists unless the plugin's &lt;code&gt;create&lt;/code&gt; goal has already run, so it is bound to &lt;code&gt;generate-resources&lt;/code&gt;, one phase before the resources plugin reads the template. Get that wrong and the file ships with &lt;code&gt;${scmRevision}&lt;/code&gt; in it as a literal string, which is the worst outcome available: an endpoint that answers confidently with nothing.&lt;/p&gt;

&lt;p&gt;There is one hole in that - &lt;code&gt;revisionOnScmFailure&lt;/code&gt; is set to &lt;code&gt;0&lt;/code&gt;, so a build from an exported copy with no git metadata reports a revision of zero instead of failing. That is the same failure the paragraph above warns about, chosen rather than stumbled into: a build that is not from a checkout has no answer to give, and a conspicuous zero is better than a hash that happens to parse.&lt;/p&gt;

&lt;p&gt;Exposing it is a &lt;code&gt;MapInfoContributor&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BuildInfoContributor&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;MapInfoContributor&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;String&lt;/span&gt; &lt;span class="no"&gt;BUILD_PROPERTIES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/build.properties"&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;BuildInfoContributor&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;IOException&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;readBuildProperties&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&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;readBuildProperties&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;IOException&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;InputStream&lt;/span&gt; &lt;span class="n"&gt;is&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BuildInfoContributor&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;getResourceAsStream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;BUILD_PROPERTIES&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;is&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;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;BUILD_PROPERTIES&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" is missing from the jar"&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;productInfoProperties&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;productInfoProperties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;load&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is&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;buildProperties&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;TreeMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&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="k"&gt;for&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;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Entry&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;,&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="n"&gt;buildProperty&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;productInfoProperties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;entrySet&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;buildProperties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buildProperty&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getKey&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;buildProperty&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="o"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;buildProperties&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The null check is there because the version I started from did not have one, and the failure it produces is instructive. &lt;code&gt;getResourceAsStream&lt;/code&gt; returns null for a resource that is not on the classpath rather than throwing, &lt;code&gt;Properties.load(null)&lt;/code&gt; throws a &lt;code&gt;NullPointerException&lt;/code&gt;, and because this runs in a constructor, Spring reports it as a bean creation failure with an NPE several frames down in a class that has nothing to do with the cause. The actual cause is a resources-plugin configuration that stopped filtering, which is a one-line fix behind twenty lines of stack trace. Replacing it with a thrown &lt;code&gt;IllegalStateException&lt;/code&gt; naming the file turns a debugging session into reading the first line of the log.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;TreeMap&lt;/code&gt; rather than a &lt;code&gt;HashMap&lt;/code&gt; is a smaller decision in the same spirit. The endpoint renders the map in iteration order, and sorted output means two deployments can be compared by eye rather than by reading both twice.&lt;/p&gt;




&lt;h2&gt;
  
  
  Enough actuator to be useful
&lt;/h2&gt;

&lt;p&gt;The actuator goes on a separate port, bound to loopback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;management.server.port&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;8081&lt;/span&gt;
&lt;span class="py"&gt;management.server.address&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;
&lt;span class="py"&gt;management.endpoints.web.base-path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;
&lt;span class="py"&gt;management.endpoints.web.exposure.include&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;health,info,logfile&lt;/span&gt;

&lt;span class="py"&gt;management.endpoint.health.show-components&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="py"&gt;management.endpoint.health.show-details&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="py"&gt;management.endpoint.health.group.liveness.include&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ping&lt;/span&gt;
&lt;span class="py"&gt;management.endpoint.health.group.readiness.include&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;

&lt;span class="py"&gt;management.endpoint.logfile.external-file&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${LOG_DIR:.}/advanced.log&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The separate port is the part worth copying. On 8080 the health endpoint is reachable by anyone who can reach the application, and &lt;code&gt;show-details=always&lt;/code&gt; means it reports which components are up, including the database. On 8081 bound to &lt;code&gt;127.0.0.1&lt;/code&gt; it is reachable from inside the container and nowhere else, which is exactly who needs it: the Docker healthcheck in the next section, and a developer with a shell.&lt;/p&gt;

&lt;p&gt;Liveness and readiness are separated because they answer different questions. Liveness is whether the process should be restarted, and &lt;code&gt;ping&lt;/code&gt; is enough: the JVM is up and answering. Readiness is whether it should receive traffic, which includes the database, because an application that cannot reach its database should not be in the load balancer. Conflating them produces a restart loop whenever the database is briefly unavailable.&lt;/p&gt;

&lt;p&gt;The logfile endpoint reads the same &lt;code&gt;LOG_DIR&lt;/code&gt; the appender writes to, which is the whole reason that variable exists rather than two hard-coded paths that agree until one of them is edited.&lt;/p&gt;

&lt;p&gt;Three endpoints is a short list - Prometheus, loggers, heapdump, mappings and the rest are all useful, and every one of them is easier to argue about once there is something running to observe.&lt;/p&gt;




&lt;h2&gt;
  
  
  The first image, and three things wrong with it
&lt;/h2&gt;

&lt;p&gt;Everything above converges here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; bellsoft/liberica-openjdk-alpine:${liberica.version}&lt;/span&gt;

&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; LOG_DIR=/runtime/logs&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; JAR_LOCATION=/data/advanced-service.jar&lt;/span&gt;

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /runtime&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;chmod &lt;/span&gt;a+rwx &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; scripts /scripts&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; maven/application-${project.version}-exec.jar ${JAR_LOCATION}&lt;/span&gt;

&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8080 8787&lt;/span&gt;

&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=5s --timeout=3s --retries=20 \&lt;/span&gt;
    CMD wget -q -O - -S http://127.0.0.1:8081/health || exit 1

&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["/bin/sh", "/scripts/run.sh"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The base image is pinned to &lt;code&gt;25.0.4-9&lt;/code&gt;, a Java version and a build number rather than the &lt;code&gt;25&lt;/code&gt; that floats, and the value is a property in the parent POM so that the JDK is declared next to every other version this build pins. Filtering puts it in the &lt;code&gt;FROM&lt;/code&gt; line, the same mechanism that puts &lt;code&gt;project.version&lt;/code&gt; in the &lt;code&gt;COPY&lt;/code&gt;. &lt;code&gt;JAR_LOCATION&lt;/code&gt; is the &lt;code&gt;-exec&lt;/code&gt; jar from Part 1, which exists as a separate artifact precisely so that the image can name it without ambiguity, and it is copied from &lt;code&gt;maven/&lt;/code&gt; rather than from &lt;code&gt;target/&lt;/code&gt; because that is where the Maven plugin that builds this image places the assembly inside the build context. &lt;code&gt;LOG_DIR&lt;/code&gt; is the variable the logback appender and the actuator's logfile endpoint both read, which is why they cannot disagree about where the log is. And the healthcheck reaches &lt;code&gt;127.0.0.1:8081&lt;/code&gt;, which works only because the actuator is bound to loopback and the healthcheck runs inside the container: the one caller that needs it is the one caller that can reach it.&lt;/p&gt;

&lt;p&gt;Getting the healthcheck destination right matters more than it looks. The version I started from used &lt;code&gt;0.0.0.0&lt;/code&gt;, which is an address to bind to rather than an address to connect to. On Linux it usually resolves to the local host anyway, which is the worst kind of bug: correct by accident, and silently broken the day something changes underneath it.&lt;/p&gt;

&lt;p&gt;This image works. It also has three problems I can name, and naming them now is the right choice than presenting it as finished.&lt;/p&gt;

&lt;p&gt;It runs as root, and the &lt;code&gt;chmod a+rwx&lt;/code&gt; on the log directory is the tell. A world-writable directory is what you write when the question of which user owns the process has not been asked.&lt;/p&gt;

&lt;p&gt;It copies the fat jar as a single layer, so every change to a single service class produces a new layer containing every dependency the application has. Spring Boot can take a jar apart into layers that change at different rates, and this image does not.&lt;/p&gt;

&lt;p&gt;And the choice between a JDK, a JRE and a purpose-built runtime has not been made. This image carries a full JDK, which includes a compiler the service never calls, and the obvious correction is the JRE image from the same publisher. It is not quite that simple, because two things I use live in the JDK rather than in the JRE: the JDWP agent the script attaches when &lt;code&gt;DEBUG&lt;/code&gt; is true comes from &lt;code&gt;jdk.jdwp.agent&lt;/code&gt;, and &lt;code&gt;jcmd&lt;/code&gt;, &lt;code&gt;jstack&lt;/code&gt; and &lt;code&gt;jfr&lt;/code&gt; are what you reach for the first time a production container misbehaves. An image that cannot take a thread dump is a false economy. The version is a &lt;code&gt;jlink&lt;/code&gt; runtime with those modules included, which is smaller than either and needs a module list this project has not worked out yet. Until then the JDK pays for itself, and the only reason this is on the list is size, which starts to matter the day the image is pulled rather than built in place.&lt;/p&gt;

&lt;p&gt;None of the three is an oversight, and each one needs something this project does not have yet. Layering matters once the build runs often enough for the cache to pay, the runtime image is a module list rather than a one-line change, and the user question is properly answered alongside everything else about running this in an orchestrator. They are a chapter of their own, later, and the shape of it will be the same as every other chapter here: the first version was minimal, and the next one extends it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Running it, two ways
&lt;/h2&gt;

&lt;p&gt;A fresh clone should reach a running application without a document explaining how. One compose file, and an &lt;code&gt;.env&lt;/code&gt; beside it, do it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mysql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql:9.6.0&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_NAME}&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_USERNAME}&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3306:3306"&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mysqladmin"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ping"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-h"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;localhost"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-uroot"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-proot"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;

  &lt;span class="na"&gt;advanced-service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;advanced-service&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;advanced-service:${TAG}&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;mysql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512M&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DB_POOL_SIZE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
      &lt;span class="na"&gt;JAVA_OPTS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
        &lt;span class="s"&gt;-XX:MaxRAMPercentage=55&lt;/span&gt;
        &lt;span class="s"&gt;-XX:MaxMetaspaceSize=128m&lt;/span&gt;
        &lt;span class="s"&gt;-XX:ReservedCodeCacheSize=64m&lt;/span&gt;
        &lt;span class="s"&gt;-XX:MaxDirectMemorySize=32m&lt;/span&gt;
        &lt;span class="s"&gt;-XX:+UseSerialGC&lt;/span&gt;
        &lt;span class="s"&gt;-Xss512k&lt;/span&gt;
        &lt;span class="s"&gt;-XX:TieredStopAtLevel=1&lt;/span&gt;
      &lt;span class="na"&gt;DEBUG&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DEBUG}&lt;/span&gt;
      &lt;span class="na"&gt;ACTIVE_PROFILE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dev&lt;/span&gt;
      &lt;span class="na"&gt;DB_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jdbc:mysql://mysql:3306/${DB_NAME}?sslMode=DISABLED&amp;amp;allowPublicKeyRetrieval=true&lt;/span&gt;
      &lt;span class="na"&gt;DB_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_USERNAME}&lt;/span&gt;
      &lt;span class="na"&gt;DB_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8787:8787"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:8080"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four of the variables under &lt;code&gt;environment&lt;/code&gt; are the ones the script insists on, spelled exactly as it reads them, and that is the whole point of owning the names: this block is readable without knowing that there is a Spring application behind it. Their values are not here, though. They come from an &lt;code&gt;.env&lt;/code&gt; file sitting next to the compose file, which holds the database name, the credentials, the image tag and the debug switch, so the compose file describes the shape of the deployment and the &lt;code&gt;.env&lt;/code&gt; holds the things that differ between one machine and another. The debug default there is &lt;code&gt;false&lt;/code&gt;, because a file that is convenient to commit should not also be the one that attaches a debugger.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;JAVA_OPTS&lt;/code&gt; is the longest entry and the one that matters most. The memory limit is declared once, under &lt;code&gt;deploy.resources.limits&lt;/code&gt;, and the JVM is told to size itself as a percentage of it rather than given a fixed &lt;code&gt;-Xmx&lt;/code&gt;, which is the difference between a container you can resize and a container you have to rebuild. The rest of that block is what a small limit actually requires: a metaspace ceiling, a bounded code cache, the serial collector rather than G1's several heap regions and threads, and a smaller stack. &lt;code&gt;DB_POOL_SIZE&lt;/code&gt; is set to five in the same spirit, because the twenty-five from Part 2 is a production number and twenty-five connections are not free on either side of the socket.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;depends_on&lt;/code&gt; with &lt;code&gt;condition: service_healthy&lt;/code&gt; is worth the extra two lines over the bare form, which only waits for the container to be created and not for the database inside it to accept connections. With the condition, compose waits for the healthcheck. Flyway's retry settings from Part 1 then cover what remains, because a database that passes &lt;code&gt;mysqladmin ping&lt;/code&gt; is not necessarily a database that has finished initialising, and the two mechanisms protect against different parts of the same gap.&lt;/p&gt;

&lt;p&gt;The database port is still published even though the application now reaches MySQL over the compose network, and that is deliberate: it is what lets a client on the host inspect what the schema actually looks like once there is one.&lt;/p&gt;

&lt;p&gt;The second way is the one to use while writing code, and the reason is the cycle rather than the debugger. Attaching to the container works - that is what &lt;code&gt;DEBUG&lt;/code&gt; and 8787 are for, but every change still costs a rebuild of the jar, a rebuild of the image and a restart, where the same change from an IDE costs a recompiled class and often not even that. Bring up the database alone, copy the committed example file, and run the application from there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docs/docker/compose.yml up mysql
&lt;span class="nb"&gt;cp &lt;/span&gt;local.properties.example local.properties
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The run configuration passes the file in, along with the profile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;--spring.config.additional-location&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;file:./local.properties --spring.profiles.active=dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either way the log shows the same two things: Flyway connecting, finding no migrations to apply yet, and a context with ten beans that came from four configuration classes. &lt;code&gt;curl localhost:8081/health&lt;/code&gt; answers &lt;code&gt;UP&lt;/code&gt; from inside the container, or from the host when the application runs on it.&lt;/p&gt;

&lt;p&gt;That empty migrations directory is the last loose end. Part 1 configured Flyway without showing what it would run, and the schema it is waiting for turns out to carry decisions that three earlier chapters have been quietly depending on, which is why it gets a chapter of its own rather than a paragraph here.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the three parts add up to
&lt;/h2&gt;

&lt;p&gt;Thirteen chapters of code that could not start now starts, and the shape of how it starts is visible in four places rather than inferred from annotations. The &lt;code&gt;@Import&lt;/code&gt; list is the inventory. The four configuration levels each answer one question. The entrypoint script states its requirements and refuses to run without them. The actuator says whether the process is alive, whether it should receive traffic, which commit it was built from, and where to find the log.&lt;/p&gt;

&lt;p&gt;Several of the decisions in these three parts cost something, and it is worth naming them. Disabling the component scan means a line of typing per configuration class. Declaring beans by hand means three lines per service instead of one annotation. Translating environment variables by hand means the script needs maintaining and a new setting does not work automatically. In exchange, every one of those things is a place where a reader can see what the system is made of without running it, and &lt;code&gt;ProductsServiceImpl&lt;/code&gt; is still a class that a test can instantiate with &lt;code&gt;new&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What the application cannot do is answer a single request. There is no controller, no mapping, no request body, and the exception handler from Chapter 11 has nothing to handle, because nothing can be asked of it yet.&lt;/p&gt;




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

&lt;p&gt;The application starts, and the first thing it does is look for migrations and find none. Chapter 12 argued at length that the only guard against assigning the same product to a category twice is a unique constraint, and that constraint has been a sentence in a paragraph ever since.&lt;/p&gt;

&lt;p&gt;Chapter 14 is that file. Why four tables are one migration rather than four, why the identifier column is sixteen bytes in a particular order, what a &lt;code&gt;version&lt;/code&gt; column changes about the service layer, and which of the handler mappings from Chapter 11 turns out to be missing once optimistic locking exists.&lt;/p&gt;

&lt;p&gt;The three problems with the image stay open until there is a reason to solve them, which is the chapter on deploying this rather than running 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 13, Part 3, 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-p3&lt;/code&gt;):&lt;/strong&gt; &lt;a href="https://github.com/KamenIvanov/advanced-spring-multimodule/tree/chapter-13-spring-p3" 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/bootstrapping-the-system-part-2-where-the-settings-come-from-chapter-13-11ic"&gt;Read Chapter 13 Part 2: Bootstrapping the System, Where the Settings Come From&lt;/a&gt;&lt;br&gt;
▶️ &lt;a href="https://dev.to/kamenivanov/the-schema-three-chapters-were-already-relying-on-chapter-14-21j"&gt;Read Chapter 14: The Schema Three Chapters Were Already Relying On&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>backend</category>
      <category>devops</category>
      <category>docker</category>
      <category>java</category>
    </item>
    <item>
      <title>The generic CRUD repository didn't cost us a sprint. What we built around it did.</title>
      <dc:creator>Kamen</dc:creator>
      <pubDate>Fri, 23 Oct 2026 10:44:45 +0000</pubDate>
      <link>https://dev.to/kamenivanov/the-generic-crud-repository-didnt-cost-us-a-sprint-what-we-built-around-it-did-2fic</link>
      <guid>https://dev.to/kamenivanov/the-generic-crud-repository-didnt-cost-us-a-sprint-what-we-built-around-it-did-2fic</guid>
      <description>&lt;p&gt;The generic repository had been sold internally as a productivity win, and for a while it was. One base interface with &lt;code&gt;save&lt;/code&gt;, &lt;code&gt;findById&lt;/code&gt;, &lt;code&gt;findAll&lt;/code&gt;, &lt;code&gt;delete&lt;/code&gt;, parameterized over any domain object, meant a new service could be wired up to persistence in an afternoon instead of a day. Every team adopted it and nobody had to write the same boilerplate CRUD logic twice, and for the first year, that felt like exactly the kind of reuse the architecture was supposed to reward.&lt;/p&gt;

&lt;p&gt;The cracks showed up gradually, in the shape of business rules that didn't fit the generic contract and ended up scattered around it instead. A cancellation didn't just need &lt;code&gt;save&lt;/code&gt;, it needed to check three preconditions, emit a domain event, and update two related domain objects atomically. That logic didn't belong in a generic repository, so it got bolted onto the service layer as a special case, then another special case for refunds, then another for partial fulfillment, each one technically using the generic repository underneath while wrapping it in enough surrounding logic that the "generic" part stopped being where the real complexity lived. The repository stayed clean. Everything around it didn't.&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%2Fkd19e4euh7f9rfxrblhc.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%2Fkd19e4euh7f9rfxrblhc.png" alt="Generic repository VS Use case boundaries" width="799" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The actual cost showed up during a sprint meant to add a straightforward feature: partial refunds needed to trigger a different notification path than full refunds. Finding where refund logic actually lived took most of the first two days, because it wasn't in one place, it was distributed across four service classes, each calling the same generic &lt;code&gt;save&lt;/code&gt; method with different surrounding conditionals that had accumulated independently over a year of separate contributors solving separate tickets. There was no single use case class to open and read top to bottom. There was a repository interface that told you nothing about what a refund actually was as an operation.&lt;/p&gt;

&lt;p&gt;We spent the rest of that sprint doing something other than the feature: extracting explicit use case classes, &lt;code&gt;ProcessFullRefund&lt;/code&gt;, &lt;code&gt;ProcessPartialRefund&lt;/code&gt;, each owning its own preconditions and side effects, with the generic repository pushed back down to where it belonged, a plain persistence detail behind the use case instead of the abstraction the business logic organized itself around. The feature that started the sprint shipped in the following one, in a day, because by then there was finally a single class whose job was to answer the question "what does a partial refund actually do." Finding the same logic afterward meant opening one class and reading it top to bottom, about half an hour.&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%2Ft6dyx52sejk6qvqj50o9.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%2Ft6dyx52sejk6qvqj50o9.png" alt="Time to locate BL" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A generic CRUD repository isn't wrong for what it actually is, a persistence detail. It becomes expensive the moment a team starts treating "wire it to the generic repository" as equivalent to "the business logic is handled," because those are not the same claim, and the gap between them doesn't show up on a velocity chart until the day someone needs to change behavior that was never given a home of its own.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;More like this?&lt;/strong&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;&lt;em&gt;Disclosure: the links below are affiliate links from the Javarevisited publication. If you buy through them, I may earn a commission at no extra cost to you.&lt;/em&gt;&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. The &lt;strong&gt;20% discount&lt;/strong&gt; is applied automatically at checkout through these links; if it doesn't show up, use promo code &lt;strong&gt;friends20&lt;/strong&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>programming</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>We Stopped Importing Spring Into the Domain Module. The Compile Errors Exposed 30 Boundary Violations.</title>
      <dc:creator>Kamen</dc:creator>
      <pubDate>Wed, 21 Oct 2026 13:07:10 +0000</pubDate>
      <link>https://dev.to/kamenivanov/we-stopped-importing-spring-into-the-domain-module-the-compile-errors-exposed-30-boundary-4jfm</link>
      <guid>https://dev.to/kamenivanov/we-stopped-importing-spring-into-the-domain-module-the-compile-errors-exposed-30-boundary-4jfm</guid>
      <description>&lt;p&gt;The domain module was supposed to be framework-free. That was the whole premise of the module split back when the project moved to a multi-module Maven layout: domain objects and use cases in one module, persistence and web concerns in others, a dependency arrow pointing one direction only. Nobody was supposed to be able to import Spring into domain code, because domain code wasn't supposed to know Spring existed.&lt;/p&gt;

&lt;p&gt;Nobody enforced that on purpose. It was a convention, documented once in an architecture decision record nobody had reread in over a year, and conventions without enforcement decay exactly as fast as you'd expect. A new engineer needing a bean somewhere reached for &lt;code&gt;@Component&lt;/code&gt; because that's what got autowired correctly, and it worked, because the domain module still had &lt;code&gt;spring-context&lt;/code&gt; on its classpath as a transitive dependency from something else. Nothing broke, nothing failed review, because nobody was specifically looking for it, and the annotation looked completely at home next to everything else in the class.&lt;/p&gt;

&lt;p&gt;The point where this stopped being theoretical was a routine cleanup: pulling &lt;code&gt;spring-context&lt;/code&gt; off the domain module's dependency list entirely, on the assumption that removing an unused transitive dependency was a safe, boring change. The build didn't just fail, it failed in 30 places, scattered across use case classes, domain services, even two places inside what were supposed to be pure value objects.&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%2Fz36d46h02vhb5nusuap5.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%2Fz36d46h02vhb5nusuap5.png" alt="Dependency leakage" width="800" height="353"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each of those 30 was a small decision that had made sense in isolation. A &lt;code&gt;@Value&lt;/code&gt; here to inject a configuration property directly into a domain service instead of passing it through a constructor from the application layer. A &lt;code&gt;@Component&lt;/code&gt; there because Spring's classpath scanning was easier than manually wiring a dependency. None of them were dramatic, and none of them showed up in code review as anything worth blocking, because reviewers were reading for correctness, not for which module a symbol was imported from.&lt;/p&gt;

&lt;p&gt;The cost wasn't abstract. Domain logic that imports Spring can't be unit tested without a Spring context spinning up somewhere in the test, even for a pure business rule that has nothing to do with dependency injection. It can't be reused in a different delivery mechanism without dragging a framework along with it. And it quietly breaks the one guarantee the module boundary was supposed to provide, that the rules governing a claim, a payout, or an account state don't depend on which web framework happens to be serving the request.&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%2Fuzr4m38lb8ubg06rz8n7.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%2Fuzr4m38lb8ubg06rz8n7.png" alt="Boundary Violations by Type" width="800" height="320"&gt;&lt;/a&gt;&lt;br&gt;
Fixing the 30 was a day of mechanical refactoring. Making sure it stayed fixed was the part that mattered: an ArchUnit rule added to the build that fails any test run where a class in the domain package imports anything from &lt;code&gt;org.springframework&lt;/code&gt;, with the dependency itself removed from the module's POM so it can't come back in silently through someone else's transitive import. The convention that used to live in a document nobody read now lives in the build, which is the only place a convention survives contact with a deadline.&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>architecture</category>
      <category>backend</category>
      <category>java</category>
      <category>refactoring</category>
    </item>
    <item>
      <title>Bootstrapping the System, Part 2: Where the Settings Come From (Chapter 13)</title>
      <dc:creator>Kamen</dc:creator>
      <pubDate>Mon, 19 Oct 2026 07:33:39 +0000</pubDate>
      <link>https://dev.to/kamenivanov/bootstrapping-the-system-part-2-where-the-settings-come-from-chapter-13-11ic</link>
      <guid>https://dev.to/kamenivanov/bootstrapping-the-system-part-2-where-the-settings-come-from-chapter-13-11ic</guid>
      <description>&lt;p&gt;Part 1 left the application with a complete set of beans and no way to start. Both &lt;code&gt;PersistenceConfiguration&lt;/code&gt; and &lt;code&gt;FlywayConfiguration&lt;/code&gt; take a &lt;code&gt;HikariDataSource&lt;/code&gt; as a parameter, nothing declares one, and a datasource needs three things that cannot be committed to a repository: a URL, a username and a password.&lt;/p&gt;

&lt;p&gt;The lazy fix is a &lt;code&gt;local.properties&lt;/code&gt; with all three in it, gitignored, and a note in the README. That works on a laptop and nowhere else. The application also has to start in a container, where there is no file to read, and in whatever CI eventually runs it, where neither the file nor the container's environment looks like either of the first two. One of those three contexts will be wrong whichever one gets to be the default.&lt;/p&gt;

&lt;p&gt;So the question is not where the settings live. It is how many different questions a setting can be answering, and giving each of those questions its own place.&lt;/p&gt;




&lt;h2&gt;
  
  
  Properties, and the four places they come from
&lt;/h2&gt;

&lt;p&gt;This project uses &lt;code&gt;.properties&lt;/code&gt; rather than &lt;code&gt;.yml&lt;/code&gt;, and the reason is unglamorous: one setting per line, greppable, and the key is visible in full at every line. In yml, finding out whether &lt;code&gt;spring.datasource.hikari.maximum-pool-size&lt;/code&gt; is set means reading an indented tree and assembling the path in your head. With properties, searching for the string finds it. Blocks of related settings sit next to each other and read like a table. Yml wins on nesting and on lists, and if this project grows a configuration section that is genuinely a list of objects, that section will be worth moving. Until then the flat file is easier to scan. Configuration arrives at the running application from four places, and each one answers a different question.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;application.properties&lt;/code&gt; holds what is true everywhere. The context path, the server port, the connection pool sizing, the JPA settings, the actuator exposure. If a setting would be identical on a laptop, in staging and in production, it lives here and nowhere else.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;application-dev.properties&lt;/code&gt; holds what is true of an environment. Log levels verbose enough to be useful while developing, and whatever else differs between dev, staging and production as a matter of what the environment is for. This is what a profile is: a description of an environment, shared by everyone who deploys to it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;local.properties&lt;/code&gt; holds what is true of one machine. The database URL, the username, the password. It is in &lt;code&gt;.gitignore&lt;/code&gt;, it is passed as an additional config location when running the application, and it never appears in the repository. A profile would be the wrong tool here, because a profile named after a developer is in git by definition, and the moment two people have one, &lt;code&gt;application-dev.properties&lt;/code&gt; describes nobody.&lt;/p&gt;

&lt;p&gt;What the repository does carry is &lt;code&gt;local.properties.example&lt;/code&gt;, with the three keys and values that match the compose file from Part 3, and a first line saying to copy it. That distinction is worth making explicitly, because the tempting shortcut is to commit the real file on the grounds that &lt;code&gt;root&lt;/code&gt; against &lt;code&gt;localhost&lt;/code&gt; is not a secret. It is not a secret, and committing it is still wrong, for a duller reason than security: the moment the file is in git, every developer's machine-specific values are in everyone's working copy, and the first person who changes a port has changed it for everybody. The example file is the shape of the thing, the thing itself belongs to whoever is running it.&lt;/p&gt;

&lt;p&gt;Environment variables hold what is true of a container, and this is the place that gets conflated with the other three most often. A container has no file to read. Whatever is true of a particular deployment arrives through its environment, which makes the name of the variable part of the contract between this application and whatever starts it: a compose file, a deployment manifest, a CI job. Part 3 is where that contract gets written, in a script that translates those names into something a JVM will accept and refuses to start when one of them is missing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The datasource block, line by line
&lt;/h2&gt;

&lt;p&gt;The largest block in &lt;code&gt;application.properties&lt;/code&gt; is the datasource, and it is worth reading one line at a time, because very little of it is boilerplate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spring.jpa.open-in-view&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;

&lt;span class="py"&gt;spring.datasource.driver-class-name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;com.mysql.cj.jdbc.Driver&lt;/span&gt;
&lt;span class="py"&gt;spring.datasource.hikari.pool-name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;advanced-connection-pool&lt;/span&gt;
&lt;span class="py"&gt;spring.datasource.hikari.initialization-fail-timeout&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;-1&lt;/span&gt;

&lt;span class="py"&gt;spring.datasource.hikari.data-source-properties.useServerPrepStmts&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spring.datasource.hikari.data-source-properties.cachePrepStmts&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spring.datasource.hikari.data-source-properties.prepStmtCacheSize&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;250&lt;/span&gt;
&lt;span class="py"&gt;spring.datasource.hikari.data-source-properties.prepStmtCacheSqlLimit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;2048&lt;/span&gt;

&lt;span class="py"&gt;spring.datasource.hikari.maximum-pool-size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${DB_POOL_SIZE:25}&lt;/span&gt;
&lt;span class="py"&gt;spring.datasource.hikari.minimum-idle&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${DB_POOL_SIZE:25}&lt;/span&gt;

&lt;span class="py"&gt;spring.datasource.hikari.max-lifetime&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1800000&lt;/span&gt;
&lt;span class="py"&gt;spring.datasource.hikari.connection-timeout&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;open-in-view=false&lt;/code&gt; disables the filter that holds a Hibernate session open for the duration of the whole HTTP request. Boot enables it by default and logs a warning about it on every single start, which is an unusually direct admission that the default is wrong. With the filter on, a lazy association can be loaded during JSON serialization, long after the service method has returned, which means a query firing from the web layer that nothing in the service layer ever asked for. This project is partly protected from that by its own structure, since the domain module contains no entities and the transformers in &lt;code&gt;dao-impl&lt;/code&gt; copy what they need while the session is still open. Turning the filter off converts the remaining risk from a silent extra query into a visible failure, which is the version I would rather debug.&lt;/p&gt;

&lt;p&gt;The driver class is redundant, strictly speaking, because Boot infers it from the JDBC URL. I keep it because the URL is not in this file at all: it arrives from &lt;code&gt;local.properties&lt;/code&gt; on a laptop and from an environment variable in a container, which leaves the driver line as the only place in the committed configuration that states which database this application talks to. &lt;code&gt;pool-name&lt;/code&gt; earns its line the same way, by being visible later: it appears in HikariCP's log output and in the names of its threads, so a thread dump taken during an incident says which pool a stuck thread was waiting on.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;initialization-fail-timeout=-1&lt;/code&gt; tells Hikari not to fail pool creation when the database is unreachable at startup. It belongs to the same decision as Flyway's infinite connect retries from earlier in this chapter: in a container the application and the database start together, the database is slower, and waiting is better than crash-looping while the orchestrator restarts a process that was never broken. It is worth being precise about what this does not do. It does not hide a failure, the application starts, the readiness probe reports it as not ready because the database health indicator is failing, and the orchestrator keeps traffic away from it. The failure is still visible, just in the place designed to report it.&lt;/p&gt;

&lt;p&gt;The four prepared statement properties are a single setting wearing four names, and the ordering matters. MySQL's client-side statement cache does nothing on its own - &lt;code&gt;cachePrepStmts&lt;/code&gt; without &lt;code&gt;useServerPrepStmts&lt;/code&gt; caches objects that still send the full SQL text on every execution. The pair together moves parsing to the server once per statement shape and then reuses it, which is worth having in a system where the same handful of queries run constantly. The two numbers come from HikariCP's own MySQL guidance rather than from a measurement of this project, and I am stating that rather than implying otherwise.&lt;/p&gt;

&lt;p&gt;The pool is deliberately fixed, with maximum and minimum set to the same value from the same variable. Pool resizing is cheap until the moment it is not. A spike arrives, the pool grows, and every new connection costs a TCP connect, a MySQL handshake and an authentication round trip, all of it paid inside the request unlucky enough to have triggered the growth. Fixing the size pays that cost once at startup instead. The trade-off is real and in the other direction: twenty-five connections are held open by an application doing nothing, and on a database shared by several services, connections are the scarce resource rather than memory. Pulling both numbers from one variable means an environment can scale the pool down without accidentally making it dynamic again.&lt;/p&gt;

&lt;p&gt;Strictly speaking the second line restates a default. Leaving &lt;code&gt;minimumIdle&lt;/code&gt; unset is what HikariCP's own documentation recommends, and an unset minimum already equals the maximum. I set it anyway, for two reasons that are about reading rather than behaviour: one variable visibly driving both numbers is what makes the fixed pool obvious to someone scanning the file, and an environment that wants a smaller pool should not have to know which of the two settings is the one that matters. That is a judgement call rather than a rule, and the next paragraph is about a line where the same judgement goes the other way.&lt;/p&gt;

&lt;p&gt;What is not in that block is &lt;code&gt;idle-timeout&lt;/code&gt;, and the reason is not that obvious - Hikari only retires idle connections down to &lt;code&gt;minimumIdle&lt;/code&gt;, so when minimum and maximum are equal there is nothing for an idle timeout to act on and the setting is ignored entirely. It had been sitting in this file looking exactly like tuning, left over from a time when the pool was allowed to shrink. A configuration file nobody reads end to end accumulates these: lines that were correct under an earlier decision and became decoration under a later one, while still reading as deliberate to the next person who opens the file.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;max-lifetime&lt;/code&gt; is the one that does the work the idle timeout appeared to be doing. Thirty minutes retires every connection regardless of whether it was busy, which keeps the pool ahead of MySQL's own &lt;code&gt;wait_timeout&lt;/code&gt; and ahead of the load balancers and firewalls that silently drop long-lived TCP connections. A connection the pool believes is alive and the network has already discarded is the source of the stale-connection errors that only ever show up after a quiet period.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;connection-timeout=5000&lt;/code&gt; is a decision about failure shape. The default is thirty seconds, which means that under contention a thread waits half a minute for a connection before giving up, and the symptom a user sees is a request that eventually succeeds very slowly. Five seconds turns the same condition into errors, which are visible in a way that latency is not. This is also the setting that would have made the &lt;code&gt;REQUIRES_NEW&lt;/code&gt; approach from Chapter 12 fail loudly rather than quietly: a per-item transaction across a two-hundred-item batch exhausts a twenty-five connection pool immediately, and with this timeout it says so in five seconds instead of appearing as a request that takes ten minutes.&lt;/p&gt;

&lt;p&gt;Two lines came out of this block that are worth mentioning for their absence. &lt;code&gt;spring.jpa.database&lt;/code&gt; and &lt;code&gt;spring.jpa.database-platform&lt;/code&gt; both declare the Hibernate dialect, Hibernate resolves it from the connection metadata without either of them, and keeping both means two places to edit when the database changes and two chances to edit only one.&lt;/p&gt;

&lt;p&gt;Within a single file, defaults go inline, as the pool size above does. The value after the colon is what applies when the variable is absent, which makes the file self-documenting about what can be overridden and what it falls back to. It also marks the setting as optional, and that is the distinction the entrypoint script in Part 3 makes from the other direction: a property with no inline default is one the container must supply, and that is precisely the list the script refuses to start without.&lt;/p&gt;




&lt;h2&gt;
  
  
  Typed configuration
&lt;/h2&gt;

&lt;p&gt;Flat properties are untyped strings and are only checked when something reads them. &lt;code&gt;@ConfigurationProperties&lt;/code&gt; turns a group of them into an object that is validated at startup, and wiring one in is the first change this part makes to the entry point from Part 1: the class list grows by an &lt;code&gt;@EnableConfigurationProperties&lt;/code&gt; block alongside the &lt;code&gt;@Import&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ConfigurationProperties&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"app.assignments"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Validated&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;AssignmentProperties&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nd"&gt;@NotNull&lt;/span&gt; &lt;span class="nd"&gt;@Min&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="nd"&gt;@Max&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Integer&lt;/span&gt; &lt;span class="n"&gt;maxBatchSize&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;Bound from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;app.assignments.max-batch-size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the size cap Chapter 12 said the bulk assignment endpoint would need. The chapter argued that 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, and left the bound unspecified. Here it is a number with a name, validated into a range.&lt;/p&gt;

&lt;p&gt;Where it gets read is a decision in its own right, and the obvious answer is wrong. &lt;code&gt;CategoryAssignmentsServiceImpl&lt;/code&gt; is the class that loops over the ids, so injecting the properties record there looks natural. It would also be the first time in thirteen chapters that a class in &lt;code&gt;business-logic&lt;/code&gt; took a Spring configuration object as a constructor argument, which is precisely the boundary the previous twelve were spent defending. A maximum batch size is a statement about what the API accepts, not about what the business rule allows: a request carrying ten thousand ids should be refused before anything reads a database, and refusing it is the job of the layer that parses the request. So the record is read by the controller, in the module that is allowed to know about Spring, and the service keeps a constructor that a test can satisfy with three mocks.&lt;/p&gt;

&lt;p&gt;That leaves this chapter introducing a configuration value before the thing that consumes it exists, which is a slightly odd order to publish in and an honest reflection of how it happened. The alternative was to hold the property back and reopen the entry point two chapters later, which buys tidiness in exchange for pretending the decision was made at a different time than it was.&lt;/p&gt;

&lt;p&gt;The record is registered through &lt;code&gt;@EnableConfigurationProperties&lt;/code&gt; on the application class rather than by annotating it with &lt;code&gt;@Component&lt;/code&gt;, which keeps it consistent with everything else: the application decides what exists.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@Validated&lt;/code&gt; is what makes the constraints run. Without it the annotations are documentation. With it, &lt;code&gt;max-batch-size=0&lt;/code&gt; fails the context startup with a message naming the property and the constraint, which is a far better outcome than a bulk endpoint that silently accepts batches of zero.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this part settled
&lt;/h2&gt;

&lt;p&gt;A setting now has exactly one correct home, chosen by what it is true of rather than by what is convenient. Identical everywhere goes in &lt;code&gt;application.properties&lt;/code&gt;. True of an environment goes in a profile. True of one machine goes in a gitignored file nobody else sees. True of a container arrives as an environment variable, and what happens to it on the way into the JVM is the subject of Part 3.&lt;/p&gt;

&lt;p&gt;The datasource block turned out to be the densest file, and two of its lines were doing nothing: an idle timeout that cannot fire against a fixed pool, and a pair of dialect declarations saying the same thing twice. Both had been correct under an earlier decision and had quietly become decoration under a later one, which is what a configuration file does when nobody reads it end to end.&lt;/p&gt;

&lt;p&gt;Reading a file like that end to end is most of the work, and it is work that only pays once. Every setting here is now either a decision somebody can disagree with or a default nobody has to think about, and the difference between the two is written down rather than inferred.&lt;/p&gt;




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

&lt;p&gt;Every setting now has a correct home, and the process that reads them is still unconfigured in every respect that is not a property. Nothing has decided how many requests it may hold at once. Nothing has decided what a log line looks like or where it is written. Nothing turns a container's environment into something a JVM will accept, given that nothing hands a properties file to a container. And the application cannot yet answer the first question anyone asks of a running service, which is which version of itself it happens to be.&lt;/p&gt;

&lt;p&gt;Part 3 is those four, and it ends with an image and a compose file, so that from here on the whole thing starts with one command.&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 2, 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-p2&lt;/code&gt;):&lt;/strong&gt; &lt;a href="https://github.com/KamenIvanov/advanced-spring-multimodule/tree/chapter-13-spring-p2" 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/bootstrapping-the-system-part-1-nothing-is-a-bean-yet-chapter-13-2l4o"&gt;Read Chapter 13 Part 1: Bootstrapping the System, Nothing Is a Bean Yet&lt;/a&gt; &lt;br&gt;
▶️ &lt;a href="https://dev.to/kamenivanov/bootstrapping-the-system-part-3-making-it-run-chapter-13-eig"&gt;Read Chapter 13 Part 3: Bootstrapping the System, Making It Run&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>java</category>
      <category>softwareengineering</category>
    </item>
    <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;
▶️ &lt;a href="https://dev.to/kamenivanov/bootstrapping-the-system-part-2-where-the-settings-come-from-chapter-13-11ic"&gt;Read Chapter 13 Part 2: Bootstrapping the System, Where the Settings Come From&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>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>
  </channel>
</rss>
