<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[devdomain - Medium]]></title>
        <description><![CDATA[DevDomain explores software development for insights, guides, and the latest industry news in helping all code-level coders. Delve deep into the newest trends in tech, level up your skills with expert advice, and discuss them with the rest of the community. - Medium]]></description>
        <link>https://medium.com/devdomain?source=rss----026d10b274d0---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>devdomain - Medium</title>
            <link>https://medium.com/devdomain?source=rss----026d10b274d0---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Thu, 05 Nov 2026 23:07:40 GMT</lastBuildDate>
        <atom:link href="https://medium.com/feed/devdomain" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Spring Data JPA Relationships and the N+1 Problem, Solved]]></title>
            <link>https://medium.com/devdomain/spring-data-jpa-relationships-and-the-n-1-problem-solved-b240aa27b53b?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/b240aa27b53b</guid>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[hibernate]]></category>
            <category><![CDATA[jpa]]></category>
            <category><![CDATA[performance]]></category>
            <category><![CDATA[java]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Thu, 05 Nov 2026 20:21:01 GMT</pubDate>
            <atom:updated>2026-11-05T20:21:01.623Z</atom:updated>
            <content:encoded><![CDATA[<p>You build a clean domain model. Author has many Books. Book belongs to a Publisher. Spring Data JPA makes it effortless: a few annotations, a findAll(), and you&#39;re done. The demo works. The tests pass.</p><p>Then it hits production with real data, and your “load all authors” endpoint fires <strong>301 SQL queries</strong> for 300 authors. Latency spikes, the database connection pool drains, and your APM dashboard turns red.</p><p>This is the <strong>N+1 select problem</strong>, and it is the single most common performance disaster in Spring applications. The good news: once you understand <em>why</em> it happens, you have a toolbox of precise fixes. This article walks through relationship mapping, fetch semantics, a live reproduction of N+1, and every practical way to solve it — JOIN FETCH, @EntityGraph, batch fetching, DTO projections, and when to drop down to native SQL.</p><h3>The domain model</h3><p>We’ll use three entities throughout. Authors write books; each book has one publisher; authors and tags have a many-to-many relationship.</p><pre>import jakarta.persistence.*;<br>import java.util.ArrayList;<br>import java.util.HashSet;<br>import java.util.List;<br>import java.util.Set;</pre><pre>@Entity<br>@Table(name = &quot;author&quot;)<br>public class Author {</pre><pre>    @Id<br>    @GeneratedValue(strategy = GenerationType.IDENTITY)<br>    private Long id;</pre><pre>    private String name;</pre><pre>    @OneToMany(mappedBy = &quot;author&quot;, cascade = CascadeType.ALL, orphanRemoval = true)<br>    private List&lt;Book&gt; books = new ArrayList&lt;&gt;();</pre><pre>    @ManyToMany<br>    @JoinTable(<br>        name = &quot;author_tag&quot;,<br>        joinColumns = @JoinColumn(name = &quot;author_id&quot;),<br>        inverseJoinColumns = @JoinColumn(name = &quot;tag_id&quot;))<br>    private Set&lt;Tag&gt; tags = new HashSet&lt;&gt;();</pre><pre>    // helper keeps both sides of the relationship in sync<br>    public void addBook(Book book) {<br>        books.add(book);<br>        book.setAuthor(this);<br>    }</pre><pre>    // getters / setters omitted for brevity<br>}</pre><pre>@Entity<br>@Table(name = &quot;book&quot;)<br>public class Book {</pre><pre>    @Id<br>    @GeneratedValue(strategy = GenerationType.IDENTITY)<br>    private Long id;</pre><pre>    private String title;</pre><pre>    @ManyToOne(fetch = FetchType.LAZY)<br>    @JoinColumn(name = &quot;author_id&quot;)<br>    private Author author;</pre><pre>    @ManyToOne(fetch = FetchType.LAZY)<br>    @JoinColumn(name = &quot;publisher_id&quot;)<br>    private Publisher publisher;</pre><pre>    // getters / setters<br>}</pre><pre>@Entity<br>@Table(name = &quot;publisher&quot;)<br>public class Publisher {</pre><pre>    @Id<br>    @GeneratedValue(strategy = GenerationType.IDENTITY)<br>    private Long id;</pre><pre>    private String name;<br>}</pre><pre>@Entity<br>@Table(name = &quot;tag&quot;)<br>public class Tag {</pre><pre>    @Id<br>    @GeneratedValue(strategy = GenerationType.IDENTITY)<br>    private Long id;</pre><pre>    private String label;<br>}</pre><p>A few mapping rules worth internalizing right now:</p><ul><li>The <strong>mappedBy</strong> side is the <em>inverse</em> (non-owning) side. The owning side is the one with the foreign key — here, Book owns the author_id column. Setting books on Author without setting book.author will not persist the relationship correctly.</li><li>Always initialize collections (= new ArrayList&lt;&gt;()) to avoid NullPointerException before persist.</li><li>Use a Set for @ManyToMany to avoid duplicate join rows and to sidestep Hibernate&#39;s notorious MultipleBagFetchException (more on that later).</li></ul><h3>Fetch types: LAZY vs EAGER</h3><p>Every association has a fetch strategy. It determines <em>when</em> the related data is loaded.</p><pre>| Association | Default fetch | Recommended |<br>|-------------|---------------|-------------|<br>| `@OneToMany` | LAZY | LAZY (keep it) |<br>| `@ManyToMany` | LAZY | LAZY (keep it) |<br>| `@ManyToOne` | **EAGER** | **LAZY (override it)** |<br>| `@OneToOne` | **EAGER** | **LAZY (override it)** |</pre><p>Notice the trap: the *ToOne associations default to <strong>EAGER</strong>. That means every time you load a Book, Hibernate also loads its Author and Publisher — whether you need them or not. EAGER fetching is the silent partner of the N+1 problem because it removes your ability to <em>choose</em> when joins happen.</p><p><strong>Rule of thumb: make every association LAZY, then fetch eagerly per-query when you actually need the data.</strong> That’s why both @ManyToOne mappings above explicitly set fetch = FetchType.LAZY.</p><p>LAZY works through proxies. When you access book.getAuthor(), Hibernate issues a SELECT <em>at that moment</em> — but only if the persistence context (the Hibernate Session) is still open. Access it after the transaction closes and you get the dreaded:</p><pre>org.hibernate.LazyInitializationException: could not initialize proxy – no Session</pre><p>We’ll see how DTO projections and explicit fetching eliminate this.</p><h3>Reproducing the N+1 problem</h3><p>Here’s a repository and a service that looks completely innocent.</p><pre>public interface AuthorRepository extends JpaRepository&lt;Author, Long&gt; {<br>}</pre><pre>@Service<br>public class AuthorService {</pre><pre>    private final AuthorRepository authorRepository;</pre><pre>    public AuthorService(AuthorRepository authorRepository) {<br>        this.authorRepository = authorRepository;<br>    }</pre><pre>    @Transactional(readOnly = true)<br>    public List&lt;String&gt; listAuthorsWithBookCount() {<br>        List&lt;Author&gt; authors = authorRepository.findAll();   // query #1<br>        return authors.stream()<br>            .map(a -&gt; a.getName() + &quot; (&quot; + a.getBooks().size() + &quot; books)&quot;) // queries 2..N+1<br>            .toList();<br>    }<br>}</pre><p>Turn on SQL logging so you can <em>see</em> it:</p><pre># application.properties<br>spring.jpa.show-sql=true<br>spring.jpa.properties.hibernate.format_sql=true<br>logging.level.org.hibernate.SQL=DEBUG<br>logging.level.org.hibernate.orm.jdbc.bind=TRACE</pre><p>With 300 authors, the log shows:</p><pre>select a.id, a.name from author a;                       -- 1 query<br>select b.* from book b where b.author_id = ?;            -- repeated 300 times</pre><p><strong>1 query to load the authors, then N queries to lazily load each author’s books.</strong> That’s N+1. The latency is roughly linear in the number of parent rows, and round-trip overhead dominates. At scale this is catastrophic.</p><p>The same thing happens with EAGER @ManyToOne: load 300 books and Hibernate fires a separate SELECT for each distinct author and publisher.</p><h3>Fix #1: JOIN FETCH in JPQL</h3><p>The most direct fix is to tell Hibernate to load the association in the <em>same</em> query using a JOIN FETCH.</p><pre>public interface AuthorRepository extends JpaRepository&lt;Author, Long&gt; {</pre><pre>    @Query(&quot;select distinct a from Author a left join fetch a.books&quot;)<br>    List&lt;Author&gt; findAllWithBooks();<br>}</pre><p>Now it’s a single SQL statement:</p><pre>select distinct a.id, a.name, b.id, b.title, b.author_id, b.publisher_id<br>from author a<br>left join book b on b.author_id = a.id;</pre><p>Notes that matter in production:</p><ul><li>Use left join fetch (not inner) if you want authors who have zero books.</li><li>distinct deduplicates the parent rows in the returned List. Since Hibernate 6 the SQL-level DISTINCT is automatically removed when it isn&#39;t needed, so this distinct is essentially a JPA-level hint and does not add a costly SELECT DISTINCT to the database query.</li></ul><p><strong>The pagination trap.</strong> You cannot safely JOIN FETCH a collection <em>and</em> paginate at the same time. If you add Pageable to a query that fetches a @OneToMany, Hibernate logs:</p><pre>HHH000104: firstResult/maxResults specified with collection fetch; applying in memory</pre><p>It fetches the <em>entire</em> result set and paginates in Java heap — a memory bomb. For paginated collection fetching, use batch fetching (Fix #3) or two queries: page the IDs first, then fetch the collections for those IDs.</p><h3>Fix #2: @EntityGraph</h3><p>JOIN FETCH couples the fetch plan to a hand-written query. @EntityGraph lets you declare <em>which</em> associations to load eagerly while reusing derived query methods.</p><pre>public interface AuthorRepository extends JpaRepository&lt;Author, Long&gt; {</pre><pre>    @EntityGraph(attributePaths = {&quot;books&quot;})<br>    @Query(&quot;select a from Author a&quot;)<br>    List&lt;Author&gt; findAllWithBooksGraph();</pre><pre>    // works on derived queries too<br>    @EntityGraph(attributePaths = {&quot;books&quot;, &quot;tags&quot;})<br>    List&lt;Author&gt; findByNameContaining(String fragment);<br>}</pre><p>attributePaths accepts nested paths, so you can fetch Book <em>and</em> its Publisher in one shot from the Book repository:</p><pre>@EntityGraph(attributePaths = {&quot;author&quot;, &quot;publisher&quot;})<br>List&lt;Book&gt; findByTitleContaining(String fragment);</pre><p>For reuse across many queries, define a named graph on the entity:</p><pre>@Entity<br>@NamedEntityGraph(<br>    name = &quot;Author.withBooks&quot;,<br>    attributeNodes = @NamedAttributeNode(&quot;books&quot;))<br>@Table(name = &quot;author&quot;)<br>public class Author { /* ... */ }</pre><pre>@EntityGraph(value = &quot;Author.withBooks&quot;, type = EntityGraph.EntityGraphType.LOAD)<br>List&lt;Author&gt; findAll();</pre><p>@EntityGraph generates a LEFT JOIN FETCH under the hood, so it carries the same pagination caveat for collections. Its strength is declarative, query-method-friendly fetch plans.</p><h3>Fix #3: batch fetching</h3><p>Sometimes you genuinely want lazy loading but in efficient <em>batches</em> rather than one query per row. Hibernate’s @BatchSize (or the global default) turns N individual SELECTs into ceil(N / batchSize) IN-clause queries.</p><pre>@OneToMany(mappedBy = &quot;author&quot;)<br>@BatchSize(size = 50)<br>private List&lt;Book&gt; books = new ArrayList&lt;&gt;();</pre><p>Or set it globally:</p><pre>spring.jpa.properties.hibernate.default_batch_fetch_size=50</pre><p>Now loading 300 authors’ books costs:</p><pre>select b.* from book b where b.author_id in (?, ?, ?, ... 50 ids ...);  -- 6 queries</pre><p>300 authors / 50 = <strong>6 queries instead of 300</strong>. Batch fetching is the best general-purpose default because it works <em>with</em> pagination (you page the parents normally, then collections load in batches) and requires zero query rewrites. I enable default_batch_fetch_size in nearly every project I touch.</p><h3>Fix #4: DTO projections</h3><p>The fastest query is one that selects only the columns you need and never inflates managed entities at all. For read-heavy endpoints, project straight into a DTO.</p><p><strong>Constructor expression (JPQL):</strong></p><pre>public record AuthorSummary(Long id, String name, long bookCount) {}</pre><pre>@Query(&quot;&quot;&quot;<br>    select new com.example.dto.AuthorSummary(a.id, a.name, count(b))<br>    from Author a left join a.books b<br>    group by a.id, a.name<br>    &quot;&quot;&quot;)<br>List&lt;AuthorSummary&gt; findAuthorSummaries();</pre><p>This is a single GROUP BY query that returns exactly three columns. No proxies, no LazyInitializationException, no N+1 — and it&#39;s safe to paginate because there&#39;s no collection fetch.</p><p><strong>Interface-based projections</strong> are even less code; Spring Data implements them for you:</p><pre>public interface AuthorView {<br>    Long getId();<br>    String getName();<br>}</pre><pre>List&lt;AuthorView&gt; findByNameContaining(String fragment);</pre><p>Spring Data inspects the interface getters and generates a query selecting only id and name. For nested data, define a projection-of-projections — but be aware that nested interface projections on associations can themselves re-introduce N+1, so verify with SQL logging.</p><p>DTO projections are my default for any endpoint that <em>reads</em> data for display. Reserve managed entities for the cases where you actually mutate state.</p><h3>Fix #5: native queries (when JPQL isn’t enough)</h3><p>JPQL covers most needs, but reach for native SQL when you need database-specific features: window functions, CTEs, JSONB operators, full-text search, vendor hints, or a finely tuned execution plan.</p><pre>@Query(value = &quot;&quot;&quot;<br>    select a.id as id, a.name as name, count(b.id) as bookCount<br>    from author a<br>    left join book b on b.author_id = a.id<br>    where a.name ilike :fragment<br>    group by a.id, a.name<br>    order by bookCount desc<br>    limit :limit<br>    &quot;&quot;&quot;,<br>    nativeQuery = true)<br>List&lt;AuthorSummary&gt; searchTopAuthors(@Param(&quot;fragment&quot;) String fragment,<br>                                     @Param(&quot;limit&quot;) int limit);</pre><p>For native queries returning DTOs, the column aliases must match the projection property names (or use a @SqlResultSetMapping). Native queries bypass JPQL portability and the persistence context&#39;s dirty checking, so use them deliberately — they are an escape hatch, not a default.</p><h3>Choosing the right tool</h3><pre>| Technique | Best for | Watch out for |<br>|-----------|----------|---------------|<br>| `JOIN FETCH` | Loading a specific graph in one query | Cannot paginate collection fetches; `MultipleBagFetchException` |<br>| `@EntityGraph` | Reusable declarative fetch plans on derived methods | Same pagination caveat as JOIN FETCH |<br>| `@BatchSize` / `default_batch_fetch_size` | Pagination + lazy collections; general default | Still issues N/batch extra queries |<br>| DTO projection | Read-only display endpoints | Read-only; no entity mutation |<br>| Native query | Vendor-specific SQL, complex aggregation | Not portable; bypasses dirty checking |</pre><h3>The MultipleBagFetchException</h3><p>If you try to JOIN FETCH two List collections at once:</p><pre>@Query(&quot;select a from Author a left join fetch a.books left join fetch a.tags&quot;)<br>List&lt;Author&gt; brokenFetch();</pre><p>Hibernate throws:</p><pre>org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags</pre><p>A “bag” is an unordered List. Fetching two of them via SQL JOIN produces a cartesian product Hibernate refuses to deduplicate. Two clean fixes:</p><ol><li><strong>Use </strong><strong>Set instead of </strong><strong>List</strong> for the collections — sets don&#39;t trigger the exception.</li><li><strong>Keep </strong><strong>Lists but combine with batch fetching:</strong> JOIN FETCH one collection, and let @BatchSize handle the second.</li></ol><p>Option 2 is usually best for performance because even a single multi-collection JOIN creates a cartesian explosion (authors × books × tags rows).</p><h3>Verifying you actually fixed it</h3><p>Don’t trust your eyes — count queries in tests. Add an assertion library or use Hibernate’s statistics:</p><pre>spring.jpa.properties.hibernate.generate_statistics=true</pre><pre>@Test<br>void listingAuthorsUsesASingleQuery() {<br>    Statistics stats = entityManagerFactory<br>        .unwrap(SessionFactory.class).getStatistics();<br>    stats.clear();</pre><pre>    authorService.listAuthorsWithBookCount();</pre><pre>    assertThat(stats.getPrepareStatementCount()).isEqualTo(1);<br>}</pre><p>Better still, add the <strong>datasource-proxy</strong> or <strong>p6spy</strong> library, or the popular quick-perf / db-util assertions, to fail the build when a method exceeds its query budget. Catching N+1 in CI is far cheaper than catching it in production.</p><h3>Common pitfalls</h3><ul><li><strong>Trusting </strong><strong>*ToOne defaults.</strong> @ManyToOne and @OneToOne are EAGER by default. Always override to FetchType.LAZY and fetch per-query.</li><li><strong>open-session-in-view masking the problem.</strong> Spring Boot enables spring.jpa.open-in-view=true by default. It keeps the session open during view rendering, so lazy loads <em>work</em> — by silently firing N+1 queries during serialization. Set spring.jpa.open-in-view=false and fetch explicitly. The errors you then get are your real fetch bugs surfacing.</li><li><strong>Paginating a collection JOIN FETCH.</strong> Triggers in-memory pagination of the entire result set. Use batch fetching or two-step ID-then-fetch instead.</li><li><strong>@OneToMany without </strong><strong>mappedBy.</strong> Omitting mappedBy makes Hibernate create a redundant join table for a one-to-many. Always map the inverse side with mappedBy.</li><li><strong>Forgetting to sync both sides.</strong> Setting only the parent collection (not the child’s @ManyToOne) leaves the FK null. Use helper methods like addBook.</li><li><strong>equals/</strong><strong>hashCode on mutable fields or the generated id.</strong> Breaks Set membership and can cause duplicate or lost entities. Prefer a business key or a UUID assigned before persist.</li><li><strong>Returning entities straight from controllers.</strong> Lazy proxies serialize unpredictably and leak your schema. Map to DTOs at the boundary.</li></ul><h3>Wrapping up</h3><p>The N+1 problem isn’t a bug in Hibernate — it’s the cost of letting lazy loading happen implicitly, one row at a time. The cure is to make fetching <em>explicit and intentional</em>:</p><ol><li>Make all associations LAZY.</li><li>Turn off open-in-view.</li><li>Fetch exactly what each query needs via JOIN FETCH, @EntityGraph, or DTO projections.</li><li>Set a global default_batch_fetch_size as a safety net.</li><li>Assert query counts in tests so regressions can’t sneak back in.</li></ol><p>Do that and your “load all authors” endpoint fires <em>one</em> query instead of 301 — and stays that way.</p><p>If this helped you slay an N+1 dragon, <strong>follow the devdomain publication</strong> for more deep Spring and JPA deep-dives. Got a fetching war story or a trick I missed? Drop it in the <strong>comments</strong> — I read and reply to every one.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b240aa27b53b" width="1" height="1" alt=""><hr><p><a href="https://medium.com/devdomain/spring-data-jpa-relationships-and-the-n-1-problem-solved-b240aa27b53b">Spring Data JPA Relationships and the N+1 Problem, Solved</a> was originally published in <a href="https://medium.com/devdomain">devdomain</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Saga Pattern: Managing Distributed Transactions Across MicroservicesYou split a monolith into…]]></title>
            <link>https://medium.com/devdomain/the-saga-pattern-managing-distributed-transactions-across-microservicesyou-split-a-monolith-into-ce910ef5beb6?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/ce910ef5beb6</guid>
            <category><![CDATA[distributed-systems]]></category>
            <category><![CDATA[microservices]]></category>
            <category><![CDATA[kafka]]></category>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[spring-boot]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Tue, 03 Nov 2026 20:11:01 GMT</pubDate>
            <atom:updated>2026-11-03T20:11:01.606Z</atom:updated>
            <content:encoded><![CDATA[<p>The Saga Pattern: Managing Distributed Transactions Across MicroservicesYou split a monolith into services. Orders, payments, and inventory each own their own database. Then the first hard requirement lands: “placing an order must charge the customer and reserve stock — all or nothing.” In a monolith that was one @Transactional method and a single database commit. Across three services with three databases, that single commit no longer exists. The saga pattern is how we get the <em>business</em> guarantee back without pretending the <em>technical</em> guarantee is still there.</p><p>This article explains why the obvious solution (two-phase commit) fails at scale, the two saga styles, how compensation actually works, the failure-handling vocabulary you need (semantic locks, retriable vs compensatable steps, isolation anomalies), and concrete Spring Boot 3.x code for both choreography and orchestration.</p><h3>Why two-phase commit fails at scale</h3><p>Two-phase commit (2PC) is the textbook answer to distributed atomicity. A coordinator asks every participant to <em>prepare</em> (phase 1); if all vote yes, it tells them to <em>commit</em> (phase 2). It genuinely gives you ACID across nodes. It also has properties that make it a poor fit for internet-scale microservices.</p><ul><li><strong>Synchronous coupling and blocking.</strong> Between prepare and commit, each participant holds locks on the affected rows. Every service in the transaction must be up, reachable, and fast <em>at the same time</em>. The transaction’s availability becomes the product of every participant’s availability — the opposite of why you split into services.</li><li><strong>The coordinator is a failure point.</strong> If the coordinator crashes after participants vote “yes” but before it broadcasts the decision, participants are stuck holding locks, unable to safely commit or abort. This is the classic 2PC blocking problem. Recovery requires a durable coordinator log and careful timeout handling.</li><li><strong>It does not scale with latency.</strong> Locks held for the duration of a network round trip kill throughput under contention. A hot row (a popular SKU’s stock count) becomes a global serialization point.</li><li><strong>Heterogeneity.</strong> 2PC needs an XA-capable resource manager on every participant. Your payment provider is an HTTP API. Kafka is not an XA resource in any practical sense. A third-party shipping service will never enlist in your transaction. The moment one participant cannot speak XA, 2PC is off the table.</li></ul><p>The deeper issue is philosophical. Microservices buy you <em>autonomy</em> — independent deployment, independent scaling, independent failure. 2PC reintroduces a tight temporal coupling that throws that autonomy away. Sagas make the opposite trade: they give up immediate, global isolation in exchange for availability and loose coupling.</p><h3>What a saga actually is</h3><p>A saga is a sequence of <strong>local</strong> transactions. Each local transaction updates a single service’s database and publishes a message (an event or a command) that triggers the next local transaction. There is no global lock and no global commit. Each step commits independently and immediately.</p><p>Because there is no rollback across services, a saga handles failure differently from a database. If step 4 fails, you cannot “roll back” steps 1–3 — they already committed. Instead you run <strong>compensating transactions</strong>: new local transactions that semantically undo the effects of the earlier committed steps, in reverse order.</p><p>So a saga has two flavors of step:</p><ul><li><strong>Forward (compensatable) transactions</strong> — T1, T2, ..., Tn — the happy path.</li><li><strong>Compensating transactions</strong> — C1, C2, ..., Cn-1 — the undo path. Ci semantically reverses Ti.</li></ul><p>The guarantee a saga gives you is: <strong>either all forward transactions complete, or a subset of forward transactions complete and the corresponding compensations run.</strong> That is <em>atomicity</em>, but not <em>isolation</em>. We will come back to the isolation hole, because it is where most people get hurt.</p><h3>The worked example: Order → Payment → Inventory</h3><p>Throughout, we use a single scenario. A customer places an order. The system must:</p><ol><li>Create the order in PENDING state (Order Service).</li><li>Charge the customer (Payment Service).</li><li>Reserve stock for the line items (Inventory Service).</li><li>Mark the order CONFIRMED (Order Service).</li></ol><p>If payment fails, we cancel the order. If inventory reservation fails, we <em>refund</em> the payment and cancel the order. Here is the flow with its compensation arrows.</p><pre>Happy path (forward transactions)<br>   ───────────────────────────────────────────────►<br> ┌─────────┐   ┌─────────┐   ┌───────────┐   ┌─────────┐<br> │ ORDER   │   │ PAYMENT │   │ INVENTORY │   │ ORDER   │<br> │ create  │──►│ charge  │──►│ reserve   │──►│ confirm │<br> │ (T1)    │   │ (T2)    │   │ (T3)      │   │ (T4)    │<br> └─────────┘   └─────────┘   └───────────┘   └─────────┘<br>      ▲             ▲              │<br>      │             │              │ T3 fails (out of stock)<br>      │  C1         │  C2          ▼<br> ┌─────────┐   ┌─────────┐   ┌───────────┐<br> │ ORDER   │◄──│ PAYMENT │◄──│   (no-op: │<br> │ cancel  │   │ refund  │   │  nothing  │<br> │ (C1)    │   │ (C2)    │   │ reserved) │<br> └─────────┘   └─────────┘   └───────────┘<br>   ◄───────────────────────────────────────<br>   Compensation path (reverse order)</pre><p>The key insight: compensations run in <strong>reverse order</strong> of the forward steps that succeeded. If T3 fails, we compensate T2 (refund) then T1 (cancel order). T3 itself produced no committed effect, so it needs no compensation.</p><h3>Choreography vs orchestration</h3><p>There are two ways to coordinate the steps. They are not interchangeable — the choice shapes coupling, observability, and how hard the failure logic is to reason about.</p><p><strong>Choreography</strong> is decentralized. Each service listens for events and reacts by doing its local work and emitting its own event. There is no central brain. Order Service emits OrderCreated; Payment Service hears it, charges, emits PaymentCompleted; Inventory Service hears that, reserves, emits StockReserved; Order Service hears that and confirms. The &quot;workflow&quot; is an emergent property of who subscribes to what.</p><p><strong>Orchestration</strong> is centralized. A dedicated orchestrator (a saga execution coordinator) sends explicit <em>commands</em> (ChargePayment, ReserveStock) and waits for <em>replies</em>. The orchestrator holds the state machine: it knows what step we are on, what to do next, and which compensations to fire on failure.</p><pre>| Dimension | Choreography | Orchestration |<br>|---|---|---|<br>| Coordination | Decentralized, event-driven | Central orchestrator with explicit commands |<br>| Coupling | Looser at runtime, tighter conceptually (everyone knows the event contract) | Services coupled to orchestrator, not to each other |<br>| Workflow visibility | Implicit, scattered across services | Explicit, in one place — easy to read |<br>| Adding a step | Risk of cyclic event dependencies; hard to see full flow | Edit the orchestrator state machine |<br>| Failure/compensation logic | Spread across services, easy to get subtly wrong | Centralized, easier to test exhaustively |<br>| Single point of failure | None inherent | Orchestrator (mitigated by persisting saga state) |<br>| Best for | Short sagas (2–4 steps), few participants | Long or complex sagas, many branches |</pre><p>Rule of thumb: choreography for simple, short flows where the event chain is obvious; orchestration once you have branching, more than ~4 steps, or you need a human to be able to answer “what state is order #4471 in?” without grepping five services.</p><h3>Choreography with Spring Boot 3.x and Kafka</h3><p>We use Kafka as the event backbone. The critical correctness concern is the <strong>dual-write problem</strong>: a service must update its own database <em>and</em> publish an event atomically. If it commits the DB row but crashes before publishing, the saga stalls. The standard fix is the <strong>transactional outbox</strong>: write the event into an outbox table in the <em>same</em> local transaction as the business change, then a separate relay publishes from the outbox to Kafka.</p><p>First, the order entity and the outbox-backed event write. Using Spring’s ApplicationEventPublisher lets us decouple, but the outbox row is what guarantees durability.</p><pre>@Entity<br>@Table(name = &quot;orders&quot;)<br>public class Order {<br>    @Id<br>    private UUID id;</pre><pre>    @Enumerated(EnumType.STRING)<br>    private OrderStatus status; // PENDING, CONFIRMED, CANCELLED</pre><pre>    private String customerId;<br>    private BigDecimal amount;</pre><pre>    @Version<br>    private long version; // optimistic locking guards concurrent updates</pre><pre>    // getters/setters/constructors omitted for brevity<br>}</pre><pre>@Entity<br>@Table(name = &quot;outbox&quot;)<br>public class OutboxEvent {<br>    @Id<br>    private UUID id;<br>    private String aggregateType; // &quot;order&quot;<br>    private String aggregateId;   // order id<br>    private String type;          // &quot;OrderCreated&quot;<br>    @Column(columnDefinition = &quot;jsonb&quot;)<br>    private String payload;<br>    private Instant createdAt;<br>    // getters/setters omitted<br>}</pre><pre>@Service<br>public class OrderService {</pre><pre>    private final OrderRepository orders;<br>    private final OutboxRepository outbox;<br>    private final ObjectMapper mapper;</pre><pre>    public OrderService(OrderRepository orders, OutboxRepository outbox, ObjectMapper mapper) {<br>        this.orders = orders;<br>        this.outbox = outbox;<br>        this.mapper = mapper;<br>    }</pre><pre>    @Transactional<br>    public Order placeOrder(String customerId, BigDecimal amount) {<br>        Order order = new Order(UUID.randomUUID(), OrderStatus.PENDING, customerId, amount);<br>        orders.save(order);</pre><pre>        // Same transaction as the order insert: no dual-write hole.<br>        var event = new OrderCreated(order.getId(), customerId, amount);<br>        outbox.save(new OutboxEvent(<br>                UUID.randomUUID(), &quot;order&quot;, order.getId().toString(),<br>                &quot;OrderCreated&quot;, writeJson(event), Instant.now()));<br>        return order;<br>    }</pre><pre>    private String writeJson(Object o) {<br>        try {<br>            return mapper.writeValueAsString(o);<br>        } catch (JsonProcessingException e) {<br>            throw new IllegalStateException(&quot;event serialization failed&quot;, e);<br>        }<br>    }<br>}</pre><p>In production you would typically let Debezium tail the outbox table&#39;s transaction log straight into Kafka. If you do not want CDC, a simple polling relay is fine for moderate volumes:</p><pre>@Component<br>public class OutboxRelay {</pre><pre>    private final OutboxRepository outbox;<br>    private final KafkaTemplate&lt;String, String&gt; kafka;</pre><pre>    public OutboxRelay(OutboxRepository outbox, KafkaTemplate&lt;String, String&gt; kafka) {<br>        this.outbox = outbox;<br>        this.kafka = kafka;<br>    }</pre><pre>    @Scheduled(fixedDelay = 500)<br>    @Transactional<br>    public void publishPending() {<br>        List&lt;OutboxEvent&gt; batch = outbox.findTop100ByOrderByCreatedAtAsc();<br>        for (OutboxEvent e : batch) {<br>            // topic name derived from aggregate type; key = aggregateId for ordering<br>            kafka.send(e.getAggregateType() + &quot;-events&quot;, e.getAggregateId(), e.getPayload());<br>            outbox.delete(e);<br>        }<br>    }<br>}</pre><p>Note the Kafka <strong>key</strong> is the aggregate id. That preserves per-order ordering on the topic partition, which matters: you do not want PaymentCompleted overtaking OrderCreated for the same order.</p><p>Now the Payment Service consumes OrderCreated and reacts. The consumer must be <strong>idempotent</strong> — Kafka guarantees at-least-once delivery, so the same event can arrive twice.</p><pre>@Component<br>public class PaymentEventHandler {</pre><pre>    private final PaymentRepository payments;<br>    private final ProcessedEventRepository processed;<br>    private final OutboxRepository outbox;<br>    private final ObjectMapper mapper;</pre><pre>    // constructor omitted</pre><pre>    @KafkaListener(topics = &quot;order-events&quot;, groupId = &quot;payment-service&quot;)<br>    @Transactional<br>    public void onOrderEvent(ConsumerRecord&lt;String, String&gt; record) throws Exception {<br>        JsonNode node = mapper.readTree(record.value());<br>        if (!&quot;OrderCreated&quot;.equals(node.get(&quot;type&quot;).asText())) {<br>            return; // not our concern<br>        }<br>        var event = mapper.treeToValue(node, OrderCreated.class);</pre><pre>        // Idempotency guard: dedupe by event id in the same transaction.<br>        if (processed.existsById(event.eventId())) {<br>            return;<br>        }<br>        processed.save(new ProcessedEvent(event.eventId(), Instant.now()));</pre><pre>        try {<br>            payments.save(Payment.charge(event.orderId(), event.amount()));<br>            emit(&quot;PaymentCompleted&quot;, new PaymentCompleted(event.orderId(), event.amount()));<br>        } catch (PaymentDeclinedException ex) {<br>            // No money moved; emit a failure event to trigger compensation upstream.<br>            emit(&quot;PaymentFailed&quot;, new PaymentFailed(event.orderId(), ex.getReason()));<br>        }<br>    }</pre><pre>    private void emit(String type, Object payload) {<br>        outbox.save(new OutboxEvent(UUID.randomUUID(), &quot;payment&quot;,<br>                ((HasOrderId) payload).orderId().toString(), type, writeJson(payload), Instant.now()));<br>    }<br>    // writeJson omitted<br>}</pre><p>Inventory then listens for PaymentCompleted and tries to reserve stock. On failure it emits StockReservationFailed, which Payment Service consumes to issue a refund (its compensation, C2), and Order Service consumes to cancel the order (C1). Each compensation is itself a local transaction that emits its own event — the compensation chain <em>is</em> just more choreography running backwards.</p><p>The danger of choreography is visible here: there is no single place that says “the order saga has these four steps and these three compensations.” A new engineer must trace topic subscriptions across three repos. This is exactly the pain orchestration removes.</p><h3>Orchestration with an explicit coordinator</h3><p>In orchestration, the orchestrator owns a persisted state machine. It sends commands and consumes replies. Persisting saga state is non-negotiable: if the orchestrator restarts mid-saga, it must resume from where it left off, not from the start.</p><pre>@Entity<br>@Table(name = &quot;order_saga_state&quot;)<br>public class OrderSagaState {<br>    @Id<br>    private UUID sagaId;<br>    private UUID orderId;</pre><pre>    @Enumerated(EnumType.STRING)<br>    private SagaStep step; // STARTED, PAYMENT_REQUESTED, STOCK_REQUESTED, COMPLETED, COMPENSATING, FAILED</pre><pre>    private boolean paymentCharged; // track what must be compensated<br>    private boolean stockReserved;</pre><pre>    @Version<br>    private long version;<br>    // getters/setters omitted<br>}</pre><pre>@Service<br>public class OrderSagaOrchestrator {</pre><pre>    private final OrderSagaStateRepository state;<br>    private final CommandGateway commands; // thin wrapper over KafkaTemplate for command topics</pre><pre>    public OrderSagaOrchestrator(OrderSagaStateRepository state, CommandGateway commands) {<br>        this.state = state;<br>        this.commands = commands;<br>    }</pre><pre>    @Transactional<br>    public void start(UUID orderId, BigDecimal amount, String customerId) {<br>        var saga = new OrderSagaState(UUID.randomUUID(), orderId, SagaStep.PAYMENT_REQUESTED);<br>        state.save(saga);<br>        commands.send(&quot;payment-commands&quot;,<br>                new ChargePayment(saga.getSagaId(), orderId, customerId, amount));<br>    }</pre><pre>    @KafkaListener(topics = &quot;payment-replies&quot;, groupId = &quot;order-orchestrator&quot;)<br>    @Transactional<br>    public void onPaymentReply(PaymentReply reply) {<br>        OrderSagaState saga = state.findById(reply.sagaId()).orElseThrow();<br>        if (reply.success()) {<br>            saga.setPaymentCharged(true);<br>            saga.setStep(SagaStep.STOCK_REQUESTED);<br>            commands.send(&quot;inventory-commands&quot;,<br>                    new ReserveStock(saga.getSagaId(), saga.getOrderId()));<br>        } else {<br>            // Payment failed: nothing to compensate yet, just fail the order.<br>            saga.setStep(SagaStep.FAILED);<br>            commands.send(&quot;order-commands&quot;, new CancelOrder(saga.getOrderId()));<br>        }<br>    }</pre><pre>    @KafkaListener(topics = &quot;inventory-replies&quot;, groupId = &quot;order-orchestrator&quot;)<br>    @Transactional<br>    public void onInventoryReply(InventoryReply reply) {<br>        OrderSagaState saga = state.findById(reply.sagaId()).orElseThrow();<br>        if (reply.success()) {<br>            saga.setStockReserved(true);<br>            saga.setStep(SagaStep.COMPLETED);<br>            commands.send(&quot;order-commands&quot;, new ConfirmOrder(saga.getOrderId()));<br>        } else {<br>            // Stock failed: compensate in reverse order. Refund first, then cancel.<br>            saga.setStep(SagaStep.COMPENSATING);<br>            if (saga.isPaymentCharged()) {<br>                commands.send(&quot;payment-commands&quot;,<br>                        new RefundPayment(saga.getSagaId(), saga.getOrderId()));<br>            }<br>            commands.send(&quot;order-commands&quot;, new CancelOrder(saga.getOrderId()));<br>        }<br>    }<br>}</pre><p>The orchestrator reads top-to-bottom like the business process. Adding a fraud-check step is a localized edit to this class plus one new command/reply pair — you do not touch the event subscriptions of unrelated services. The cost is that the orchestrator is now a component every order flows through; you mitigate its single-point-of-failure risk by running multiple instances against the shared, persisted saga state with optimistic locking (@Version).</p><p>For non-trivial systems, do not hand-roll this state machine. <strong>Spring Statemachine</strong>, <strong>Axon Framework</strong>’s saga support, <strong>Eventuate Tram Sagas</strong>, or a workflow engine like <strong>Temporal</strong> or <strong>Camunda</strong> give you persistence, retries, and timeouts out of the box. The hand-rolled version above is for understanding what those libraries are doing.</p><h3>Failure handling: the part that is actually hard</h3><p>The forward path is easy. The value of the saga pattern is in the failure semantics, and there is real vocabulary here.</p><h3>Retriable vs compensatable steps</h3><p>Eventuate’s model (from Chris Richardson’s <em>Microservices Patterns</em>) classifies each step:</p><ul><li><strong>Compensatable transactions</strong> — steps that may need to be undone because a later step can fail. (Create order, Charge payment.)</li><li><strong>Pivot transaction</strong> — the point of no return. Once it commits, the saga <em>will</em> complete forward; there is no going back. (Often the last step that could fail for a business reason — e.g. Reserve stock if everything after it is guaranteed to succeed.)</li><li><strong>Retriable transactions</strong> — steps <em>after</em> the pivot that must eventually succeed and are therefore only ever retried, never compensated. (Confirm order, Send confirmation email.)</li></ul><p>Why this matters: you design the saga so that everything that can fail for a business reason happens <em>before</em> the pivot, and everything after the pivot is something you can guarantee by retrying. Send confirmation email must never be allowed to fail the whole order — it is retriable. You do not refund a customer because an SMTP server was briefly down.</p><h3>Idempotency and retries</h3><p>Every consumer must be idempotent because messaging is at-least-once. We saw the ProcessedEvent dedupe table above. Compensations must <em>also</em> be idempotent — a refund command might be delivered twice, and you must refund only once. Track compensation state explicitly (the paymentCharged flag), and make the refund a no-op if it has already run.</p><p>Compensations themselves can fail (the payment gateway is down during a refund). The rule: <strong>compensations must be retried until they succeed</strong>, with backoff and a dead-letter queue plus alerting as the last resort. A compensation that gives up leaves the system in an inconsistent state — money taken, order cancelled. This is the worst outcome and warrants a human.</p><h3>Semantic locks: closing the isolation hole</h3><p>Here is the uncomfortable truth: <strong>sagas are not isolated.</strong> While a saga is in flight, its partial, uncommitted-from-a-business-view changes are visible to everyone. A monolithic transaction would hide them until commit. The classic anomalies (from the same literature):</p><ul><li><strong>Lost updates</strong> — saga A overwrites a change made by saga B without seeing it.</li><li><strong>Dirty reads</strong> — saga B reads data that saga A has changed but later compensates away. B made a decision on data that “never really happened.”</li><li><strong>Fuzzy / non-repeatable reads</strong> — different steps of the same saga read the same data and get different answers because another saga modified it in between.</li></ul><p>The standard countermeasure is the <strong>semantic lock</strong>: instead of locking rows at the database level, you mark the affected business entity with an application-level flag indicating an in-progress saga. The Order.status = PENDING is exactly this — it tells the rest of the system &quot;this order is not finished; do not treat it as confirmed.&quot; A stock reservation is another semantic lock: the quantity is moved to a RESERVED bucket, visible as unavailable, but not yet SOLD. If the saga compensates, the reservation is released.</p><pre>@Service<br>public class InventoryService {</pre><pre>    private final StockRepository stock;</pre><pre>    public InventoryService(StockRepository stock) {<br>        this.stock = stock;<br>    }</pre><pre>    @Transactional<br>    public boolean reserve(UUID orderId, String sku, int qty) {<br>        // Optimistic-locked read-modify-write avoids lost updates on hot SKUs.<br>        StockItem item = stock.findBySku(sku).orElseThrow();<br>        if (item.available() &lt; qty) {<br>            return false; // triggers StockReservationFailed -&gt; compensation<br>        }<br>        // Semantic lock: move to RESERVED, not SOLD. Visible as unavailable to others.<br>        item.reserve(orderId, qty);<br>        stock.save(item); // @Version on StockItem throws on concurrent modification -&gt; retry<br>        return true;<br>    }</pre><pre>    @Transactional<br>    public void releaseReservation(UUID orderId, String sku, int qty) {<br>        // Compensation C3 — idempotent: only release if a reservation exists.<br>        stock.findBySku(sku).ifPresent(item -&gt; {<br>            if (item.hasReservation(orderId)) {<br>                item.release(orderId, qty);<br>                stock.save(item);<br>            }<br>        });<br>    }<br>}</pre><p>Other countermeasures worth knowing: <strong>commutative updates</strong> (design operations so order does not matter — credit/debit instead of setBalance), <strong>pessimistic views</strong> (reorder steps so the riskiest comes first, shrinking the window of inconsistency), and <strong>re-reads / version checks</strong> before compensating to detect intervening changes. None of these gives you true isolation; they reduce the probability and blast radius of anomalies. You must decide, per saga, which anomalies your business can tolerate.</p><h3>When to use the saga pattern — and when not to</h3><p>Use a saga when:</p><ul><li>A business operation spans <strong>multiple services with separate databases</strong> and you need an all-or-nothing <em>business</em> outcome.</li><li>The participants cannot or should not be coupled by 2PC (third-party APIs, message brokers, high-throughput hot rows).</li><li>You can tolerate <strong>eventual consistency</strong> and brief windows where partial results are visible.</li></ul><p>Do <strong>not</strong> reach for a saga when:</p><ul><li>The data belongs together. If three services are always changing in one transaction, that is a hint your service boundaries are wrong. The cheapest fix is often to <strong>merge them into one service</strong> with one database and one ordinary @Transactional — no saga needed.</li><li>You need <strong>strong isolation / read-your-writes</strong> semantics for the in-flight operation. Sagas cannot give you serializability across services.</li><li>The operation is genuinely <strong>single-service</strong>. Do not build a saga for something a local transaction handles.</li></ul><h3>Pitfalls checklist</h3><ul><li><strong>Dual-write without an outbox.</strong> Committing the DB then publishing as two separate operations <em>will</em> eventually drop or duplicate events. Always use a transactional outbox (or CDC) so the event and the state change share one transaction.</li><li><strong>Non-idempotent consumers and compensations.</strong> At-least-once delivery means everything runs more than once. Dedupe by event id; make compensations no-ops when already applied.</li><li><strong>Forgetting that compensations can fail.</strong> Retry them forever (with backoff) and alert; never silently drop.</li><li><strong>Ignoring isolation anomalies.</strong> Audit every saga for dirty reads and lost updates. Add semantic locks (status flags, reservations) deliberately, not by accident.</li><li><strong>Choreography sprawl.</strong> Beyond ~4 steps or any branching, the implicit event web becomes unmaintainable and prone to cyclic dependencies. Switch to orchestration.</li><li><strong>No saga timeout.</strong> A step’s reply may never come. Set per-step timeouts so a stuck saga triggers compensation instead of hanging forever.</li><li><strong>Treating retriable steps as compensatable.</strong> Do not unwind a customer’s whole order because a non-critical post-pivot step (email, analytics) failed. Retry it.</li></ul><h3>Closing thought</h3><p>Sagas are not a free upgrade from 2PC — they are a different deal. You trade global atomicity-with-isolation for atomicity-without-isolation, and you pay for it in idempotency code, compensation logic, and careful anomaly analysis. Done well, they let independent services stay independent while still delivering coherent business outcomes. Done carelessly, they leave money charged against cancelled orders. The difference is entirely in how seriously you take the failure path.</p><p>If this breakdown helped, follow the <strong>devdomain</strong> publication for more deep dives into distributed systems and backend architecture. Have you run sagas in production — choreography or orchestration, and what bit you? Drop your war stories in the comments; the messy real-world cases are the best part.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ce910ef5beb6" width="1" height="1" alt=""><hr><p><a href="https://medium.com/devdomain/the-saga-pattern-managing-distributed-transactions-across-microservicesyou-split-a-monolith-into-ce910ef5beb6">The Saga Pattern: Managing Distributed Transactions Across MicroservicesYou split a monolith into…</a> was originally published in <a href="https://medium.com/devdomain">devdomain</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Spring Boot Error Handling: @ControllerAdvice and ProblemDetail (RFC 7807)Most APIs leak.]]></title>
            <link>https://medium.com/devdomain/spring-boot-error-handling-controlleradvice-and-problemdetail-rfc-7807-most-apis-leak-54c78245a22a?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/54c78245a22a</guid>
            <category><![CDATA[rest-api]]></category>
            <category><![CDATA[problemdetails]]></category>
            <category><![CDATA[error-handling]]></category>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[java]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Thu, 29 Oct 2026 19:46:01 GMT</pubDate>
            <atom:updated>2026-10-29T19:46:01.790Z</atom:updated>
            <content:encoded><![CDATA[<p>Spring Boot Error Handling: <a href="http://twitter.com/ControllerAdvice">@ControllerAdvice</a> and ProblemDetail (RFC 7807)Most APIs leak. Not data — <em>error shapes</em>. One endpoint returns {&quot;error&quot;: &quot;not found&quot;}, another returns {&quot;message&quot;: &quot;...&quot;, &quot;code&quot;: 42}, a validation failure dumps Spring&#39;s default Whitelabel-style payload, and an unhandled exception returns a 500 with a stack trace the client can&#39;t use. Every consumer writes bespoke parsing for every endpoint, and your support channel fills with &quot;the API returned something weird.&quot;</p><p>The fix is a single, consistent error contract enforced in one place. Spring Boot gives you the tools: @ControllerAdvice for centralized handling, @ExceptionHandler for mapping exceptions to responses, and — since Spring 6 / Boot 3 — first-class support for <strong>ProblemDetail</strong>, the standardized error format defined by <strong>RFC 7807 (now RFC 9457)</strong>. This article builds a complete, production-grade error-handling layer.</p><h3>The problem with scattered error handling</h3><p>Without a strategy, error handling spreads across controllers:</p><pre>// DON&#39;T: error handling tangled into business code<br>@GetMapping(&quot;/orders/{id}&quot;)<br>public ResponseEntity&lt;?&gt; getOrder(@PathVariable Long id) {<br>    Order o = repo.findById(id).orElse(null);<br>    if (o == null) {<br>        return ResponseEntity.status(404)<br>            .body(Map.of(&quot;error&quot;, &quot;order not found&quot;));<br>    }<br>    return ResponseEntity.ok(o);<br>}</pre><p>Every controller reinvents the wheel, formats differ, and cross-cutting concerns (logging, correlation IDs) get copy-pasted. Centralize instead: let controllers throw meaningful exceptions and translate them in one advice.</p><h3>RFC 7807: a standard error shape</h3><p>RFC 7807 (“Problem Details for HTTP APIs”) defines a media type, application/problem+json, and a canonical structure:</p><pre>| Field | Meaning |<br>|---|---|<br>| `type` | A URI identifying the problem type (a stable, dereferenceable category) |<br>| `title` | A short, human-readable summary of the problem type |<br>| `status` | The HTTP status code |<br>| `detail` | A human-readable explanation specific to this occurrence |<br>| `instance` | A URI identifying this specific occurrence |</pre><p>Plus any <strong>extension members</strong> you add (e.g. errors, traceId). A compliant response looks like:</p><pre>{<br>  &quot;type&quot;: &quot;https://api.example.com/problems/order-not-found&quot;,<br>  &quot;title&quot;: &quot;Order Not Found&quot;,<br>  &quot;status&quot;: 404,<br>  &quot;detail&quot;: &quot;No order exists with id 4711&quot;,<br>  &quot;instance&quot;: &quot;/orders/4711&quot;,<br>  &quot;traceId&quot;: &quot;0af7651916cd43dd8448eb211c80319c&quot;<br>}</pre><p>Spring 6 models this with the org.springframework.http.ProblemDetail class — no third-party library required.</p><h3>ProblemDetail by hand</h3><p>ProblemDetail is a plain holder you can build and return directly:</p><pre>import org.springframework.http.HttpStatus;<br>import org.springframework.http.ProblemDetail;<br>import java.net.URI;</pre><pre>ProblemDetail pd = ProblemDetail.forStatusAndDetail(<br>    HttpStatus.NOT_FOUND, &quot;No order exists with id 4711&quot;);<br>pd.setType(URI.create(&quot;https://api.example.com/problems/order-not-found&quot;));<br>pd.setTitle(&quot;Order Not Found&quot;);<br>pd.setProperty(&quot;traceId&quot;, &quot;0af7651916cd43dd8448eb211c80319c&quot;); // extension member</pre><p>setProperty adds arbitrary extension fields, serialized flat alongside the standard ones.</p><h3>Turning it on for built-in exceptions</h3><p>Spring MVC throws a family of internal exceptions (e.g. HttpMessageNotReadableException, MethodArgumentNotValidException, NoResourceFoundException). To make Spring render <em>those</em> as ProblemDetail automatically, enable the property:</p><pre>spring.mvc.problemdetails.enabled=true</pre><p>(WebFlux uses spring.webflux.problemdetails.enabled=true.) With this on, framework-level errors already come back as RFC 7807 documents. Your job is to handle <em>your</em> exceptions consistently and enrich the defaults.</p><h3>Global handling with @RestControllerAdvice</h3><p>@ControllerAdvice (or @RestControllerAdvice, which adds @ResponseBody) registers exception handlers that apply across all controllers. Combined with ProblemDetail returns, it&#39;s the backbone of a clean contract.</p><p>Define domain exceptions first:</p><pre>public class ResourceNotFoundException extends RuntimeException {<br>    public ResourceNotFoundException(String message) { super(message); }<br>}</pre><pre>public class BusinessRuleException extends RuntimeException {<br>    private final String code;<br>    public BusinessRuleException(String code, String message) {<br>        super(message);<br>        this.code = code;<br>    }<br>    public String getCode() { return code; }<br>}</pre><p>Now the central advice:</p><pre>import org.springframework.http.HttpStatus;<br>import org.springframework.http.ProblemDetail;<br>import org.springframework.web.bind.annotation.ExceptionHandler;<br>import org.springframework.web.bind.annotation.RestControllerAdvice;<br>import org.springframework.web.context.request.WebRequest;<br>import org.slf4j.Logger;<br>import org.slf4j.LoggerFactory;<br>import java.net.URI;<br>import java.time.Instant;</pre><pre>@RestControllerAdvice<br>public class GlobalExceptionHandler {</pre><pre>    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);<br>    private static final String BASE = &quot;https://api.example.com/problems/&quot;;</pre><pre>    @ExceptionHandler(ResourceNotFoundException.class)<br>    public ProblemDetail handleNotFound(ResourceNotFoundException ex) {<br>        ProblemDetail pd = ProblemDetail.forStatusAndDetail(<br>            HttpStatus.NOT_FOUND, ex.getMessage());<br>        pd.setType(URI.create(BASE + &quot;resource-not-found&quot;));<br>        pd.setTitle(&quot;Resource Not Found&quot;);<br>        pd.setProperty(&quot;timestamp&quot;, Instant.now());<br>        return pd;<br>    }</pre><pre>    @ExceptionHandler(BusinessRuleException.class)<br>    public ProblemDetail handleBusinessRule(BusinessRuleException ex) {<br>        ProblemDetail pd = ProblemDetail.forStatusAndDetail(<br>            HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());<br>        pd.setType(URI.create(BASE + &quot;business-rule-violation&quot;));<br>        pd.setTitle(&quot;Business Rule Violation&quot;);<br>        pd.setProperty(&quot;code&quot;, ex.getCode());<br>        return pd;<br>    }</pre><pre>    // Catch-all: never leak stack traces to clients<br>    @ExceptionHandler(Exception.class)<br>    public ProblemDetail handleUnexpected(Exception ex) {<br>        log.error(&quot;Unhandled exception&quot;, ex); // log full detail server-side<br>        ProblemDetail pd = ProblemDetail.forStatusAndDetail(<br>            HttpStatus.INTERNAL_SERVER_ERROR,<br>            &quot;An unexpected error occurred. Please contact support.&quot;);<br>        pd.setType(URI.create(BASE + &quot;internal-error&quot;));<br>        pd.setTitle(&quot;Internal Server Error&quot;);<br>        return pd;<br>    }<br>}</pre><p>Two principles already baked in: <strong>never expose internal exception messages or stack traces to clients</strong> (the catch-all returns a generic detail while logging the real cause), and <strong>map each exception type to a meaningful status</strong>.</p><h3>Handling validation errors properly</h3><p>Bean Validation (jakarta.validation) is where consistent error responses pay off most, because clients need field-level detail to fix their input.</p><p>Annotate your request DTO:</p><pre>import jakarta.validation.constraints.*;</pre><pre>public record CreateUserRequest(<br>    @NotBlank String username,<br>    @Email String email,<br>    @Min(18) int age,<br>    @Size(min = 8) String password<br>) {}</pre><p>Trigger validation with @Valid:</p><pre>@PostMapping(&quot;/users&quot;)<br>public ResponseEntity&lt;UserResponse&gt; create(@Valid @RequestBody CreateUserRequest req) {<br>    // if invalid, MethodArgumentNotValidException is thrown before this runs<br>    return ResponseEntity.ok(service.create(req));<br>}</pre><p>When validation fails, Spring throws MethodArgumentNotValidException. To emit a clean RFC 7807 response with per-field errors, <strong>extend </strong><strong>ResponseEntityExceptionHandler</strong> — it already handles the Spring MVC exceptions and lets you customize them, returning ProblemDetail bodies:</p><pre>import org.springframework.http.HttpHeaders;<br>import org.springframework.http.HttpStatusCode;<br>import org.springframework.http.ProblemDetail;<br>import org.springframework.http.ResponseEntity;<br>import org.springframework.web.bind.MethodArgumentNotValidException;<br>import org.springframework.web.bind.annotation.RestControllerAdvice;<br>import org.springframework.web.context.request.WebRequest;<br>import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;<br>import java.net.URI;<br>import java.util.ArrayList;<br>import java.util.List;</pre><pre>@RestControllerAdvice<br>public class ValidationExceptionHandler extends ResponseEntityExceptionHandler {</pre><pre>    @Override<br>    protected ResponseEntity&lt;Object&gt; handleMethodArgumentNotValid(<br>            MethodArgumentNotValidException ex,<br>            HttpHeaders headers,<br>            HttpStatusCode status,<br>            WebRequest request) {</pre><pre>        List&lt;FieldError&gt; errors = new ArrayList&lt;&gt;();<br>        ex.getBindingResult().getFieldErrors().forEach(fe -&gt;<br>            errors.add(new FieldError(fe.getField(), fe.getDefaultMessage())));<br>        ex.getBindingResult().getGlobalErrors().forEach(ge -&gt;<br>            errors.add(new FieldError(ge.getObjectName(), ge.getDefaultMessage())));</pre><pre>        ProblemDetail pd = ProblemDetail.forStatusAndDetail(<br>            org.springframework.http.HttpStatus.BAD_REQUEST,<br>            &quot;Request validation failed&quot;);<br>        pd.setType(URI.create(&quot;https://api.example.com/problems/validation-error&quot;));<br>        pd.setTitle(&quot;Validation Error&quot;);<br>        pd.setProperty(&quot;errors&quot;, errors);</pre><pre>        return ResponseEntity.badRequest().body(pd);<br>    }</pre><pre>    public record FieldError(String field, String message) {}<br>}</pre><p>Resulting payload:</p><pre>{<br>  &quot;type&quot;: &quot;https://api.example.com/problems/validation-error&quot;,<br>  &quot;title&quot;: &quot;Validation Error&quot;,<br>  &quot;status&quot;: 400,<br>  &quot;detail&quot;: &quot;Request validation failed&quot;,<br>  &quot;errors&quot;: [<br>    { &quot;field&quot;: &quot;email&quot;, &quot;message&quot;: &quot;must be a well-formed email address&quot; },<br>    { &quot;field&quot;: &quot;age&quot;, &quot;message&quot;: &quot;must be greater than or equal to 18&quot; }<br>  ]<br>}</pre><h3>Handling constraint violations on path/query params</h3><p>@Valid on a body throws MethodArgumentNotValidException. But validating method parameters directly (e.g. @RequestParam @Min(1) int page with @Validated on the controller) throws jakarta.validation.ConstraintViolationException instead — a different exception. Handle it too:</p><pre>import jakarta.validation.ConstraintViolationException;<br>import org.springframework.web.bind.annotation.ExceptionHandler;</pre><pre>@ExceptionHandler(ConstraintViolationException.class)<br>public ProblemDetail handleConstraintViolation(ConstraintViolationException ex) {<br>    var errors = ex.getConstraintViolations().stream()<br>        .map(v -&gt; new FieldError(v.getPropertyPath().toString(), v.getMessage()))<br>        .toList();<br>    ProblemDetail pd = ProblemDetail.forStatusAndDetail(<br>        org.springframework.http.HttpStatus.BAD_REQUEST, &quot;Constraint violation&quot;);<br>    pd.setProperty(&quot;errors&quot;, errors);<br>    return pd;<br>}</pre><h3>ErrorResponseException and ProblemDetail-aware exceptions</h3><p>Spring 6 introduced ErrorResponse and ErrorResponseException, which carry their own ProblemDetail. You can make domain exceptions self-describing by implementing ErrorResponse, so they render correctly even without a dedicated handler:</p><pre>import org.springframework.http.HttpStatus;<br>import org.springframework.web.ErrorResponseException;<br>import org.springframework.http.ProblemDetail;<br>import java.net.URI;</pre><pre>public class OrderNotFoundException extends ErrorResponseException {<br>    public OrderNotFoundException(Long id) {<br>        super(HttpStatus.NOT_FOUND, problem(id), null);<br>    }<br>    private static ProblemDetail problem(Long id) {<br>        ProblemDetail pd = ProblemDetail.forStatusAndDetail(<br>            HttpStatus.NOT_FOUND, &quot;No order exists with id &quot; + id);<br>        pd.setType(URI.create(&quot;https://api.example.com/problems/order-not-found&quot;));<br>        pd.setTitle(&quot;Order Not Found&quot;);<br>        return pd;<br>    }<br>}</pre><p>Throwing this from a controller produces a correct application/problem+json response with no handler at all — Spring recognizes ErrorResponse and renders it.</p><h3>Enriching every response: instance URI and trace IDs</h3><p>Clients and support teams need to correlate an error to a log line. Add the request path as instance and a trace ID as an extension. A small helper on the advice keeps it DRY:</p><pre>import org.springframework.web.context.request.ServletWebRequest;<br>import org.springframework.web.context.request.WebRequest;<br>import java.net.URI;</pre><pre>private void enrich(ProblemDetail pd, WebRequest request) {<br>    if (request instanceof ServletWebRequest swr) {<br>        pd.setInstance(URI.create(swr.getRequest().getRequestURI()));<br>    }<br>    // From MDC / tracing context (e.g. Micrometer Tracing populates this)<br>    String traceId = org.slf4j.MDC.get(&quot;traceId&quot;);<br>    if (traceId != null) pd.setProperty(&quot;traceId&quot;, traceId);<br>}</pre><p>Call enrich(pd, request) at the end of each handler (add WebRequest request to the handler signatures). Now every error is traceable.</p><h3>Scoping advice and ordering multiple handlers</h3><p>@RestControllerAdvice applies globally by default, but you can scope it — useful in modular applications where different packages need different error contracts (e.g. a public API vs an internal admin API):</p><pre>// Only applies to controllers in the public API package<br>@RestControllerAdvice(basePackages = &quot;com.example.api.public_&quot;)<br>public class PublicApiExceptionHandler { ... }</pre><pre>// Only applies to controllers annotated with @AdminApi<br>@RestControllerAdvice(annotations = AdminApi.class)<br>public class AdminApiExceptionHandler { ... }</pre><p>When more than one advice can handle the same exception type, Spring resolves by @Order (lower value wins) and by handler specificity — a handler for ResourceNotFoundException beats one for RuntimeException. Keep this predictable: prefer a single advice per concern, and only layer them when you have a genuine reason (a fallback advice with @Order(Ordered.LOWEST_PRECEDENCE) catching Exception is a common, legitimate pattern).</p><h3>How handler method resolution works</h3><p>When an exception escapes a controller, Spring’s ExceptionHandlerExceptionResolver walks the advice beans and looks for the <em>most specific</em> @ExceptionHandler whose declared types match the thrown exception (or a cause, when configured). Matching considers the exception hierarchy, so a handler for a superclass catches subclasses unless a more specific handler exists. This is why a catch-all @ExceptionHandler(Exception.class) reliably acts as a backstop: it matches everything, but loses to any narrower handler.</p><pre>exception thrown in controller<br>        |<br>        v<br>ExceptionHandlerExceptionResolver<br>        |<br>        v<br>find most specific @ExceptionHandler across @ControllerAdvice beans<br>        |<br>        +-- match found  -&gt; invoke handler -&gt; render ProblemDetail<br>        |<br>        +-- no match     -&gt; propagate to container -&gt; default error page / 500</pre><h3>Content negotiation and the problem+json media type</h3><p>When you return a ProblemDetail, Spring serializes it as application/problem+json (for JSON) or application/problem+xml (for XML), per the RFC. Clients that inspect the Content-Type can branch on it to detect an error envelope without parsing the body first. This is one of the quiet advantages of the standard: the <em>media type itself</em> signals &quot;this is a problem document,&quot; which generic HTTP clients and gateways can act on.</p><p>If you instead return a hand-rolled Map or custom DTO, you lose that signal and the standard structure, and every consumer must special-case your shape. Returning ProblemDetail is therefore not just cleaner code — it&#39;s a more interoperable contract.</p><h3>Testing your error contract</h3><p>Error handling is exactly the kind of code that rots silently, so test it. A @WebMvcTest slice exercises the advice without booting the full context:</p><pre>import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;<br>import org.springframework.beans.factory.annotation.Autowired;<br>import org.springframework.test.web.servlet.MockMvc;<br>import org.junit.jupiter.api.Test;</pre><pre>import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;<br>import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;</pre><pre>@WebMvcTest(OrderController.class)<br>class OrderErrorContractTest {</pre><pre>    @Autowired MockMvc mvc;</pre><pre>    @Test<br>    void missingOrder_returnsProblemDetail() throws Exception {<br>        mvc.perform(get(&quot;/orders/4711&quot;))<br>           .andExpect(status().isNotFound())<br>           .andExpect(content().contentType(&quot;application/problem+json&quot;))<br>           .andExpect(jsonPath(&quot;$.title&quot;).value(&quot;Order Not Found&quot;))<br>           .andExpect(jsonPath(&quot;$.status&quot;).value(404))<br>           .andExpect(jsonPath(&quot;$.type&quot;).exists());<br>    }<br>}</pre><p>Asserting on Content-Type and the standard fields locks down the contract so a future refactor can&#39;t quietly change the error shape your clients depend on.</p><h3>A summary of the mapping strategy</h3><pre>| Exception | HTTP status | Where handled |<br>|---|---|---|<br>| `ResourceNotFoundException` (yours) | 404 | `@ExceptionHandler` |<br>| `BusinessRuleException` (yours) | 422 | `@ExceptionHandler` |<br>| `MethodArgumentNotValidException` | 400 | Override in `ResponseEntityExceptionHandler` |<br>| `ConstraintViolationException` | 400 | `@ExceptionHandler` |<br>| `HttpMessageNotReadableException` | 400 | Built-in (problemdetails enabled) |<br>| `AccessDeniedException` | 403 | Security layer / advice |<br>| Anything else | 500 | Catch-all `@ExceptionHandler(Exception.class)` |</pre><h3>Common pitfalls</h3><ul><li><strong>Leaking internals</strong>: returning ex.getMessage() from a generic 500 can expose SQL, file paths, or class names. The catch-all must return a generic message and log the real cause.</li><li><strong>Two handlers for the same exception</strong>: if both a plain @RestControllerAdvice and a ResponseEntityExceptionHandler subclass handle the same type, ordering is ambiguous. Keep one source of truth; use @Order on advices if you must layer them.</li><li><strong>Forgetting </strong><strong>@Valid</strong>: validation annotations on a DTO do nothing without @Valid (body) or @Validated (params) at the call site.</li><li><strong>Confusing the two validation exceptions</strong>: body validation throws MethodArgumentNotValidException; param validation throws ConstraintViolationException. Handle both.</li><li><strong>Not enabling </strong><strong>spring.mvc.problemdetails.enabled</strong>: without it, built-in framework errors won&#39;t be RFC 7807 by default.</li><li><strong>Returning the wrong status for business errors</strong>: 422 (Unprocessable Entity) often fits domain-rule failures better than 400 (malformed request). Choose intentionally.</li><li><strong>Self-invocation / exceptions thrown outside the dispatcher</strong>: @ControllerAdvice only catches exceptions propagating out of controller handling. Errors in filters or async threads need separate handling.</li><li><strong>Inconsistent </strong><strong>type URIs</strong>: keep them stable and documented; clients may branch on type, so changing it is a breaking change.</li></ul><h3>Best practices</h3><ul><li>One @RestControllerAdvice per concern (e.g. domain errors, validation), each focused and small.</li><li>Use ProblemDetail everywhere so the contract is uniform and standards-compliant (application/problem+json).</li><li>Make type URIs stable and resolvable to documentation; treat them as part of your API contract.</li><li>Always include instance and a traceId for correlation.</li><li>Map exceptions to <em>semantic</em> status codes; never default everything to 400 or 500.</li><li>Validate at the edge with Bean Validation and surface field-level errors as an errors extension array.</li><li>Log full detail server-side; return only safe, actionable detail to clients.</li><li>Cover the catch-all so an unexpected exception never escapes as a stack trace.</li></ul><h3>Conclusion</h3><p>Consistent error handling is a contract, not an afterthought. Spring Boot 3 makes that contract cheap to honor: @RestControllerAdvice centralizes mapping, @ExceptionHandler ties exceptions to responses, and built-in ProblemDetail gives you RFC 7807 / 9457 compliance without a library. Enable spring.mvc.problemdetails.enabled, extend ResponseEntityExceptionHandler for validation, enrich every response with an instance URI and traceId, and guard against leaks with a generic catch-all. The result is an API where every error — from a missing resource to an invalid field to a catastrophic 500 — speaks the same predictable language.</p><p>If this helped you standardize your API’s errors, <strong>follow devdomain</strong> for more Spring Boot deep dives, and drop a comment with the ugliest error payload you’ve had to consume. Misery loves company.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=54c78245a22a" width="1" height="1" alt=""><hr><p><a href="https://medium.com/devdomain/spring-boot-error-handling-controlleradvice-and-problemdetail-rfc-7807-most-apis-leak-54c78245a22a">Spring Boot Error Handling: @ControllerAdvice and ProblemDetail (RFC 7807)Most APIs leak.</a> was originally published in <a href="https://medium.com/devdomain">devdomain</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Spring Batch: Processing Millions of Records ReliablyIt always starts simple.]]></title>
            <link>https://medium.com/devdomain/spring-batch-processing-millions-of-records-reliablyit-always-starts-simple-5c86937152c1?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/5c86937152c1</guid>
            <category><![CDATA[java]]></category>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[performance]]></category>
            <category><![CDATA[data-engineering]]></category>
            <category><![CDATA[spring-batch]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Tue, 27 Oct 2026 18:06:01 GMT</pubDate>
            <atom:updated>2026-10-27T18:06:01.745Z</atom:updated>
            <content:encoded><![CDATA[<p>Spring Batch: Processing Millions of Records ReliablyIt always starts simple. “Read this 50,000-row CSV and insert it into the database.” So you write a for loop, load the whole file into a List, and call saveAll. It works on your laptop.</p><p>Then the file grows to 12 million rows. The loop runs out of heap. A network blip on row 8,000,000 kills the process — and you have no idea which rows committed, so you can’t safely re-run it. There’s no progress reporting, no retry, no parallelism, and certainly no way to resume from where it died.</p><p>This is exactly the problem <strong>Spring Batch</strong> was built to solve. It provides a battle-tested framework for chunk-oriented processing with transactions, restartability, skip/retry policies, and partitioned parallelism — the unglamorous machinery that turns a fragile script into a reliable production job. This article builds a complete, real example and covers the production concerns that matter.</p><h3>The core model</h3><p>Spring Batch organizes work into a hierarchy:</p><ul><li>A <strong>Job</strong> is the whole unit of work (e.g. &quot;import customers&quot;).</li><li>A Job has one or more <strong>Step</strong>s, run in sequence (or conditionally).</li><li>A chunk-oriented Step is built from three collaborators:</li><li><strong>ItemReader</strong> — reads one item at a time from a source (file, DB, queue).</li><li><strong>ItemProcessor</strong> — transforms/validates/filters one item (optional).</li><li><strong>ItemWriter</strong> — writes items <em>in batches</em> (a chunk) to a destination.</li><li>The <strong>JobRepository</strong> persists execution metadata to the database, which is what makes restart and monitoring possible.</li><li>A <strong>JobLauncher</strong> starts a job with a set of <strong>JobParameters</strong>.</li></ul><p>The key insight is <strong>chunk-oriented processing</strong>: read N items one by one, process each, then hand the whole chunk of N to the writer inside a single transaction. Commit, repeat. This bounds memory to one chunk and gives you natural transactional boundaries.</p><h3>Project setup</h3><pre>&lt;dependency&gt;<br>    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;<br>    &lt;artifactId&gt;spring-boot-starter-batch&lt;/artifactId&gt;<br>&lt;/dependency&gt;<br>&lt;dependency&gt;<br>    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;<br>    &lt;artifactId&gt;spring-boot-starter-data-jpa&lt;/artifactId&gt;<br>&lt;/dependency&gt;<br>&lt;dependency&gt;<br>    &lt;groupId&gt;org.postgresql&lt;/groupId&gt;<br>    &lt;artifactId&gt;postgresql&lt;/artifactId&gt;<br>    &lt;scope&gt;runtime&lt;/scope&gt;<br>&lt;/dependency&gt;</pre><p>Spring Batch needs its metadata tables (BATCH_JOB_INSTANCE, BATCH_STEP_EXECUTION, etc.). Spring Boot creates them automatically:</p><pre>spring.batch.jdbc.initialize-schema=always<br># Don&#39;t run jobs automatically on context startup; we&#39;ll launch explicitly<br>spring.batch.job.enabled=false</pre><p>In Spring Boot 3 / Spring Batch 5, @EnableBatchProcessing is no longer required — auto-configuration wires the JobRepository, JobLauncher, and a JDBC transactionManager for you. (Adding @EnableBatchProcessing actually <em>disables</em> that auto-config, so leave it off unless you need to customize.)</p><h3>A complete worked example</h3><p>The job: read a large CSV of raw customer records, validate and normalize them, and persist valid ones to the database.</p><h3>The domain</h3><pre>public record CustomerCsv(String firstName, String lastName, String email, String country) {}</pre><pre>@Entity<br>@Table(name = &quot;customer&quot;)<br>public class Customer {<br>    @Id<br>    @GeneratedValue(strategy = GenerationType.IDENTITY)<br>    private Long id;<br>    private String firstName;<br>    private String lastName;<br>    private String email;<br>    private String countryCode;<br>    // getters / setters<br>}</pre><h3>The reader</h3><p>FlatFileItemReader streams the file line by line — it never loads the whole file into memory.</p><pre>import org.springframework.batch.item.file.FlatFileItemReader;<br>import org.springframework.batch.item.file.builder.FlatFileItemReaderBuilder;<br>import org.springframework.core.io.FileSystemResource;</pre><pre>@Bean<br>@StepScope<br>public FlatFileItemReader&lt;CustomerCsv&gt; customerReader(<br>        @Value(&quot;#{jobParameters[&#39;inputFile&#39;]}&quot;) String inputFile) {<br>    return new FlatFileItemReaderBuilder&lt;CustomerCsv&gt;()<br>        .name(&quot;customerReader&quot;)<br>        .resource(new FileSystemResource(inputFile))<br>        .linesToSkip(1)                       // header row<br>        .delimited()<br>        .names(&quot;firstName&quot;, &quot;lastName&quot;, &quot;email&quot;, &quot;country&quot;)<br>        .targetType(CustomerCsv.class)<br>        .build();<br>}</pre><p>@StepScope is important: it lets the reader receive a late-bound jobParameters value (the file path) and gives each execution its own reader instance.</p><h3>The processor</h3><p>The processor validates and normalizes. Returning null from a processor <strong>filters</strong> the item — it is silently dropped and never reaches the writer.</p><pre>import org.springframework.batch.item.ItemProcessor;<br>import org.springframework.stereotype.Component;</pre><pre>@Component<br>public class CustomerProcessor implements ItemProcessor&lt;CustomerCsv, Customer&gt; {</pre><pre>    private static final java.util.regex.Pattern EMAIL =<br>        java.util.regex.Pattern.compile(&quot;^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$&quot;);</pre><pre>    @Override<br>    public Customer process(CustomerCsv in) {<br>        if (in.email() == null || !EMAIL.matcher(in.email().trim()).matches()) {<br>            return null; // filter out invalid rows<br>        }<br>        Customer c = new Customer();<br>        c.setFirstName(in.firstName().trim());<br>        c.setLastName(in.lastName().trim());<br>        c.setEmail(in.email().trim().toLowerCase());<br>        c.setCountryCode(normalizeCountry(in.country()));<br>        return c;<br>    }</pre><pre>    private String normalizeCountry(String raw) {<br>        if (raw == null) return &quot;XX&quot;;<br>        return raw.trim().toUpperCase().substring(0, Math.min(2, raw.trim().length()));<br>    }<br>}</pre><h3>The writer</h3><p>JpaItemWriter persists a whole chunk in one transaction.</p><pre>import org.springframework.batch.item.data.JpaItemWriter;<br>import org.springframework.batch.item.data.builder.JpaItemWriterBuilder;<br>import jakarta.persistence.EntityManagerFactory;</pre><pre>@Bean<br>public JpaItemWriter&lt;Customer&gt; customerWriter(EntityManagerFactory emf) {<br>    return new JpaItemWriterBuilder&lt;Customer&gt;()<br>        .entityManagerFactory(emf)<br>        .build();<br>}</pre><p>For Raw throughput, JdbcBatchItemWriter with a parameterized INSERT is faster because it bypasses the persistence context:</p><pre>@Bean<br>public JdbcBatchItemWriter&lt;Customer&gt; jdbcCustomerWriter(DataSource dataSource) {<br>    return new JdbcBatchItemWriterBuilder&lt;Customer&gt;()<br>        .dataSource(dataSource)<br>        .sql(&quot;&quot;&quot;<br>            insert into customer (first_name, last_name, email, country_code)<br>            values (:firstName, :lastName, :email, :countryCode)<br>            &quot;&quot;&quot;)<br>        .beanMapped()<br>        .build();<br>}</pre><h3>Wiring the Step and Job</h3><p>In Spring Batch 5 you build steps and jobs with builders that take the JobRepository and a transaction manager.</p><pre>import org.springframework.batch.core.Job;<br>import org.springframework.batch.core.Step;<br>import org.springframework.batch.core.job.builder.JobBuilder;<br>import org.springframework.batch.core.repository.JobRepository;<br>import org.springframework.batch.core.step.builder.StepBuilder;<br>import org.springframework.transaction.PlatformTransactionManager;<br>import org.springframework.context.annotation.Bean;<br>import org.springframework.context.annotation.Configuration;</pre><pre>@Configuration<br>public class CustomerImportJobConfig {</pre><pre>    @Bean<br>    public Step importStep(JobRepository jobRepository,<br>                           PlatformTransactionManager txManager,<br>                           FlatFileItemReader&lt;CustomerCsv&gt; customerReader,<br>                           CustomerProcessor processor,<br>                           JpaItemWriter&lt;Customer&gt; customerWriter) {<br>        return new StepBuilder(&quot;importStep&quot;, jobRepository)<br>            .&lt;CustomerCsv, Customer&gt;chunk(1000, txManager)<br>            .reader(customerReader)<br>            .processor(processor)<br>            .writer(customerWriter)<br>            .build();<br>    }</pre><pre>    @Bean<br>    public Job importCustomersJob(JobRepository jobRepository, Step importStep) {<br>        return new JobBuilder(&quot;importCustomersJob&quot;, jobRepository)<br>            .start(importStep)<br>            .build();<br>    }<br>}</pre><p>chunk(1000, txManager) means: read up to 1,000 items, process them, write all 1,000, and commit — then start the next chunk. Pick a chunk size that balances transaction overhead against memory and lock duration. 500–2,000 is a sensible starting range; benchmark for your workload.</p><h3>Launching the job</h3><pre>import org.springframework.batch.core.*;<br>import org.springframework.batch.core.launch.JobLauncher;</pre><pre>@Component<br>public class CustomerImportRunner {</pre><pre>    private final JobLauncher jobLauncher;<br>    private final Job importCustomersJob;</pre><pre>    public CustomerImportRunner(JobLauncher jobLauncher, Job importCustomersJob) {<br>        this.jobLauncher = jobLauncher;<br>        this.importCustomersJob = importCustomersJob;<br>    }</pre><pre>    public void run(String inputFile) throws Exception {<br>        JobParameters params = new JobParametersBuilder()<br>            .addString(&quot;inputFile&quot;, inputFile)<br>            .addLong(&quot;run.timestamp&quot;, System.currentTimeMillis()) // makes the instance unique<br>            .toJobParameters();<br>        jobLauncher.run(importCustomersJob, params);<br>    }<br>}</pre><h3>Restartability</h3><p>This is Spring Batch’s superpower. Because the JobRepository persists step execution state — including the read count — a job that <em>failed</em> can be re-launched <strong>with the same identifying job parameters</strong> and it resumes from the last committed chunk rather than starting over.</p><p>A few rules govern this:</p><ul><li>A <strong>JobInstance</strong> is identified by job name + <em>identifying</em> job parameters. Re-running with the same identifying parameters continues that instance; changing them creates a new instance.</li><li>That’s why above we add a run.timestamp — a <em>unique</em> parameter makes each launch a fresh instance. For a <em>restartable</em> job you&#39;d instead keep parameters identical (e.g. just inputFile) so a failed run can resume.</li><li>A COMPLEQED instance cannot be re-run with the same parameters (you&#39;d get JobInstanceAlreadyCompleteException). Only FAILED/STOPPED instances restart.</li><li>Readers like FlatFileItemReader are <strong>restartable</strong>: they persist the line offset in the ExecutionContext and skip already-read lines on restart.</li></ul><p>You can also mark a step non-restartable or allow a completed step to re-run with .allowStartIfComplete(true).</p><h3>Fault tolerance: skip and retry</h3><p>In the real world some records are simply bad and some failures are transient. A fault-tolerant step lets you handle both without aborting the whole job.</p><pre>@Bean<br>public Step faultTolerantStep(JobRepository jobRepository,<br>                              PlatformTransactionManager txManager,<br>                              FlatFileItemReader&lt;CustomerCsv&gt; reader,<br>                              CustomerProcessor processor,<br>                              JpaItemWriter&lt;Customer&gt; writer) {<br>    return new StepBuilder(&quot;faultTolerantStep&quot;, jobRepository)<br>        .&lt;CustomerCsv, Customer&gt;chunk(1000, txManager)<br>        .reader(reader)<br>        .processor(processor)<br>        .writer(writer)<br>        .faultTolerant()<br>        // retry transient failures up to 3 times<br>        .retryLimit(3)<br>        .retry(org.springframework.dao.TransientDataAccessException.class)<br>        // skip bad records, up to 50 of them, then fail the job<br>        .skipLimit(50)<br>        .skip(org.springframework.batch.item.file.FlatFileParseException.class)<br>        .skip(jakarta.validation.ConstraintViolationException.class)<br>        .listener(skipListener())<br>        .build();<br>}</pre><ul><li><strong>Retry</strong> re-attempts an operation that threw a <em>transient</em> exception (a deadlock, a momentary connection drop). Use it only for errors that might succeed on a second try.</li><li><strong>Skip</strong> discards an <em>individual bad item</em> (a malformed CSV line, a validation failure) and continues, up to skipLimit. Exceed the limit and the job fails — that&#39;s a safety valve against silently dropping a corrupt file.</li><li>Capture skips so they aren’t lost:</li></ul><pre>@Bean<br>public org.springframework.batch.core.SkipListener&lt;CustomerCsv, Customer&gt; skipListener() {<br>    return new org.springframework.batch.core.SkipListener&lt;&gt;() {<br>        @Override<br>        public void onSkipInRead(Throwable t) {<br>            // log/persist the unparseable line for later inspection<br>        }<br>        @Override<br>        public void onSkipInProcess(CustomerCsv item, Throwable t) {<br>            // record rejected item to a dead-letter table<br>        }<br>        @Override<br>        public void onSkipInWrite(Customer item, Throwable t) {<br>            // record write failure<br>        }<br>    };<br>}</pre><p><strong>Important caveat:</strong> when a fault-tolerant chunk hits an error during <em>write</em>, Spring Batch rolls back the chunk and re-processes it <strong>item by item</strong> to isolate the offender. This means your ItemProcessor must be <strong>idempotent</strong> — it may be called more than once for the same item.</p><h3>Partitioning for scale</h3><p>A single thread reading and writing in chunks is reliable but sequential. For millions of rows you want parallelism. <strong>Partitioning</strong> splits the input into ranges and runs a <em>separate worker step</em> per partition, each with its own reader/writer, in parallel across a thread pool.</p><pre>import org.springframework.batch.core.partition.support.Partitioner;<br>import org.springframework.batch.item.ExecutionContext;<br>import java.util.HashMap;<br>import java.util.Map;</pre><pre>public class IdRangePartitioner implements Partitioner {</pre><pre>    private final long minId;<br>    private final long maxId;</pre><pre>    public IdRangePartitioner(long minId, long maxId) {<br>        this.minId = minId;<br>        this.maxId = maxId;<br>    }</pre><pre>    @Override<br>    public Map&lt;String, ExecutionContext&gt; partition(int gridSize) {<br>        Map&lt;String, ExecutionContext&gt; partitions = new HashMap&lt;&gt;();<br>        long range = (maxId - minId) / gridSize + 1;<br>        long start = minId;<br>        for (int i = 0; i &lt; gridSize; i++) {<br>            ExecutionContext ctx = new ExecutionContext();<br>            long end = Math.min(start + range - 1, maxId);<br>            ctx.putLong(&quot;minId&quot;, start);<br>            ctx.putLong(&quot;maxId&quot;, end);<br>            partitions.put(&quot;partition&quot; + i, ctx);<br>            start = end + 1;<br>        }<br>        return partitions;<br>    }<br>}</pre><p>Wire a manager step that distributes partitions to a worker step running on a task executor:</p><pre>@Bean<br>public Step partitionedStep(JobRepository jobRepository,<br>                            Step workerStep) {<br>    return new StepBuilder(&quot;partitionedStep&quot;, jobRepository)<br>        .partitioner(workerStep.getName(),<br>                     new IdRangePartitioner(1, 12_000_000))<br>        .step(workerStep)<br>        .gridSize(8)<br>        .taskExecutor(new org.springframework.core.task.SimpleAsyncTaskExecutor())<br>        .build();<br>}</pre><p>The worker step’s reader uses the partition’s minId/maxId from the step ExecutionContext (inject via @Value(&quot;#{stepExecutionContext[&#39;minId&#39;]}&quot;) on a @StepScope bean). For a DB source, a JdbcPagingItemReader with a where id between :minId and :maxId clause keeps each partition&#39;s reads bounded and indexed.</p><p>Spring Batch also supports <strong>remote partitioning</strong> (workers on separate JVMs, coordinated via a message queue) for horizontal scale beyond one machine. Local partitioning across threads is the simplest big win.</p><h3>Listeners and the ExecutionContext</h3><p>Real jobs need hooks for logging, metrics, cleanup, and passing data between steps. Spring Batch provides listeners at every level.</p><pre>import org.springframework.batch.core.JobExecution;<br>import org.springframework.batch.core.JobExecutionListener;</pre><pre>public class ImportJobListener implements JobExecutionListener {</pre><pre>    @Override<br>    public void beforeJob(JobExecution jobExecution) {<br>        // record start time, validate inputs, send a &quot;started&quot; notification<br>    }</pre><pre>    @Override<br>    public void afterJob(JobExecution jobExecution) {<br>        long written = jobExecution.getStepExecutions().stream()<br>            .mapToLong(se -&gt; se.getWriteCount()).sum();<br>        long skipped = jobExecution.getStepExecutions().stream()<br>            .mapToLong(se -&gt; se.getSkipCount()).sum();<br>        // emit metrics, alert if skipped is high, archive the input file<br>    }<br>}</pre><p>Attach it with .listener(new ImportJobListener()) on the JobBuilder. There are matching StepExecutionListener, ChunkListener, and ItemReadListener interfaces for finer-grained hooks.</p><p>To pass data between steps — say, a step that computes an ID range and a later step that consumes it — write to the <strong>job </strong><strong>ExecutionContext</strong> (shared across the job), not the step context (scoped to one step). The execution context is persisted by the JobRepository, so its contents also survive a restart, which is exactly what makes resumable multi-step jobs possible.</p><h3>Composing multiple steps</h3><p>Jobs rarely have one step. You might <strong>stage</strong> raw data, <strong>validate</strong> it, then <strong>load</strong> it. The JobBuilder flow API sequences and branches steps:</p><pre>@Bean<br>public Job pipelineJob(JobRepository repo, Step stageStep,<br>                       Step validateStep, Step loadStep, Step cleanupStep) {<br>    return new JobBuilder(&quot;pipelineJob&quot;, repo)<br>        .start(stageStep)<br>        .next(validateStep)<br>        .on(&quot;FAILED&quot;).to(cleanupStep)          // branch on exit status<br>        .from(validateStep).on(&quot;*&quot;).to(loadStep)<br>        .end()<br>        .build();<br>}</pre><p>If a step fails, the persisted state lets you restart the <em>whole job</em> and it resumes at the failed step — completed steps are skipped (unless marked allowStartIfComplete). This makes long ETL pipelines robust: a failure in the load step doesn&#39;t force you to re-stage gigabytes of data.</p><h3>Tuning and monitoring</h3><pre>| Lever | Effect | Guidance |<br>|-------|--------|----------|<br>| Chunk size | Transaction frequency vs memory | Start 500–2,000; benchmark |<br>| Partition grid size | Parallelism | Match to CPU cores / DB connection pool |<br>| `JdbcBatchItemWriter` | Throughput | Faster than JPA for pure inserts |<br>| Connection pool size | Concurrency ceiling | Pool ≥ grid size + headroom |<br>| `default_batch_fetch_size` | Reader efficiency | Reduce N+1 if reader touches associations |</pre><p>Monitor via the BATCH_STEP_EXECUTION table (read/write/skip/commit counts) or expose Micrometer metrics — Spring Batch publishes spring.batch.job and spring.batch.step timers automatically when Actuator is present.</p><h3>Common pitfalls</h3><ul><li><strong>Loading everything into memory.</strong> Use streaming readers (FlatFileItemReader, JdbcCursorItemReader, JdbcPagingItemReader), never findAll() into a List.</li><li><strong>Non-idempotent processors.</strong> Fault-tolerant retries re-invoke process(). Side effects must be safe to repeat.</li><li><strong>Reusing identifying job parameters on a completed job.</strong> Causes JobInstanceAlreadyCompleteException. Add a unique parameter for genuinely new runs; keep them identical only when you intend to restart.</li><li><strong>Skipping silently.</strong> A high skipLimit with no SkipListener quietly discards data. Always record skipped items to a dead-letter store and alert on volume.</li><li><strong>Huge chunk inside a single transaction.</strong> A 100,000-item chunk holds locks and a giant transaction; a failure rolls back everything. Keep chunks modest.</li><li><strong>@EnableBatchProcessing in Boot 3.</strong> It disables the auto-configuration you actually want. Omit it unless customizing.</li><li><strong>spring.batch.job.enabled=true running jobs on every startup.</strong> Disable auto-run and launch explicitly so you control parameters and timing.</li><li><strong>Partitioning without enough DB connections.</strong> Eight parallel workers need eight connections; size the pool accordingly or workers will block.</li></ul><h3>Wrapping up</h3><p>Spring Batch is not the flashiest part of the Spring ecosystem, but it’s one of the most valuable. It turns “read a file and insert rows” into a job that streams within bounded memory, commits in transactional chunks, resumes after failure, skips bad records into a dead-letter store, retries transient errors, and scales out across partitions.</p><p>Build with ItemReader → ItemProcessor → ItemWriter, choose a sane chunk size, make your processor idempotent, add fault tolerance deliberately, and partition when you need throughput. Your 12-million-row import becomes boring — which, for a batch job, is exactly what you want.</p><p>If this leveled up your batch game, <strong>follow the devdomain publication</strong> for more deep Spring engineering. What’s the largest job you’ve run, and how did you tune it? Share your numbers in the <strong>comments</strong>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5c86937152c1" width="1" height="1" alt=""><hr><p><a href="https://medium.com/devdomain/spring-batch-processing-millions-of-records-reliablyit-always-starts-simple-5c86937152c1">Spring Batch: Processing Millions of Records ReliablyIt always starts simple.</a> was originally published in <a href="https://medium.com/devdomain">devdomain</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Hexagonal Architecture (Ports and Adapters) in Spring BootMost Spring Boot codebases rot in the…]]></title>
            <link>https://medium.com/devdomain/hexagonal-architecture-ports-and-adapters-in-spring-bootmost-spring-boot-codebases-rot-in-the-b41d848960d1?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/b41d848960d1</guid>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[hexagonal-architecture]]></category>
            <category><![CDATA[java]]></category>
            <category><![CDATA[clean-code]]></category>
            <category><![CDATA[software-architecture]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Thu, 22 Oct 2026 16:51:01 GMT</pubDate>
            <atom:updated>2026-10-22T16:51:01.619Z</atom:updated>
            <content:encoded><![CDATA[<p>Hexagonal Architecture (Ports and Adapters) in Spring BootMost Spring Boot codebases rot in the same way. A controller calls a @Service, the service calls a @Repository, the repository is a JpaRepository, and the JPA entity <em>is</em> the domain model. It works on day one. By month six, the business rules are smeared across services, controllers, and entity getters; you cannot test a pricing rule without spinning up an in-memory database; and swapping Postgres for an event stream means touching half the codebase.</p><p>Hexagonal Architecture — Alistair Cockburn’s “Ports and Adapters” — is a direct answer to this decay. The core idea is almost embarrassingly simple: <strong>your business logic sits in the middle and depends on nothing. Everything else — the web, the database, the message broker — plugs into it through interfaces the business logic defines.</strong> The framework serves the domain, not the other way around.</p><p>This article is the practical version. We’ll cover the <em>why</em>, the three layers, what ports and adapters actually are, how to lay out a real Spring Boot 3.x project, which way the dependencies must point, why testing gets dramatically easier, and where the pattern hurts more than it helps.</p><h3>The Problem with the Default Layered Architecture</h3><p>The classic three-tier stack (Controller → Service → Repository) looks clean in a diagram, but the dependency arrows all point <em>downward toward the database</em>:</p><pre>Controller  -&gt;  Service  -&gt;  Repository  -&gt;  Database</pre><p>Your domain logic (in the service) depends on the persistence layer. That means:</p><ul><li>Your business rules import org.springframework.data.jpa.* and jakarta.persistence.*.</li><li>You can’t unit-test a service without mocking a repository that returns JPA-managed entities.</li><li>Lazy-loading exceptions, detached entities, and transaction boundaries leak into business code.</li><li>Changing infrastructure (a new database, a switch to Kafka) ripples <em>upward</em> into the domain.</li></ul><p>Hexagonal architecture inverts this. The domain defines interfaces; infrastructure implements them. The arrows point <em>inward</em>.</p><h3>The Core Principle: The Dependency Rule</h3><p>There is exactly one rule that makes hexagonal architecture work:</p><blockquote><strong><em>Source code dependencies always point inward, toward the domain. The domain depends on nothing external.</em></strong></blockquote><p>The domain doesn’t know about Spring, Hibernate, Jackson, HTTP, or Kafka. It knows about <em>its own</em> interfaces. Infrastructure code depends on the domain, never the reverse.</p><pre>+-------------------------------+<br>                  |      Inbound Adapters          |<br>                  |  (REST, CLI, Kafka consumer)   |<br>                  +---------------+---------------+<br>                                  |  calls<br>                                  v<br>   +------------------------------------------------------------+<br>   |                  APPLICATION LAYER                          |<br>   |   Use cases / input ports (interfaces)                      |<br>   |   Orchestration, transactions, no business invariants       |<br>   |                                                             |<br>   |   +------------------------------------------------+        |<br>   |   |               DOMAIN LAYER                      |        |<br>   |   |  Entities, Value Objects, Domain Services       |        |<br>   |   |  Pure Java. Business invariants. No framework.  |        |<br>   |   +------------------------------------------------+        |<br>   |                                                             |<br>   |   Output ports (interfaces): LoadOrderPort, SaveOrderPort   |<br>   +------------------------------------+------------------------+<br>                                        ^  implements<br>                                        |<br>                  +---------------------+-----------------+<br>                  |        Outbound Adapters               |<br>                  |  (JPA repo, REST client, Kafka prod.)  |<br>                  +----------------------------------------+</pre><h3>The Three Layers</h3><h3>1. Domain Layer — the pure core</h3><p>This is the heart. Entities, value objects, and domain services that encode business rules. <strong>Zero framework dependencies.</strong> No @Entity, no @Component, no Lombok-on-JPA. Plain Java that you could compile without Spring on the classpath.</p><pre>package com.shop.ordering.domain;</pre><pre>import java.util.List;<br>import java.util.UUID;</pre><pre>public class Order {</pre><pre>    private final OrderId id;<br>    private final CustomerId customerId;<br>    private final List&lt;OrderLine&gt; lines;<br>    private OrderStatus status;</pre><pre>    public Order(OrderId id, CustomerId customerId, List&lt;OrderLine&gt; lines) {<br>        if (lines == null || lines.isEmpty()) {<br>            throw new IllegalArgumentException(&quot;An order must have at least one line&quot;);<br>        }<br>        this.id = id;<br>        this.customerId = customerId;<br>        this.lines = List.copyOf(lines);<br>        this.status = OrderStatus.DRAFT;<br>    }</pre><pre>    public void place() {<br>        if (status != OrderStatus.DRAFT) {<br>            throw new IllegalStateException(&quot;Only draft orders can be placed&quot;);<br>        }<br>        this.status = OrderStatus.PLACED;<br>    }</pre><pre>    public Money total() {<br>        return lines.stream()<br>                .map(OrderLine::subtotal)<br>                .reduce(Money.ZERO, Money::add);<br>    }</pre><pre>    public OrderId id() { return id; }<br>    public OrderStatus status() { return status; }<br>}</pre><p>Value objects are a natural fit for Java records:</p><pre>package com.shop.ordering.domain;</pre><pre>import java.math.BigDecimal;</pre><pre>public record Money(BigDecimal amount, String currency) {</pre><pre>    public static final Money ZERO = new Money(BigDecimal.ZERO, &quot;EUR&quot;);</pre><pre>    public Money {<br>        if (amount.signum() &lt; 0) {<br>            throw new IllegalArgumentException(&quot;Money cannot be negative&quot;);<br>        }<br>    }</pre><pre>    public Money add(Money other) {<br>        if (!currency.equals(other.currency)) {<br>            throw new IllegalArgumentException(&quot;Currency mismatch&quot;);<br>        }<br>      2 return new Money(amount.add(other.amount), currency);<br>    }<br>}</pre><p>Notice: this code is testable in microseconds, with no Spring context, no database, no mocks.</p><h3>2. Application Layer — orchestration and ports</h3><p>The application layer defines <strong>use cases</strong> as <em>input ports</em> (interfaces) and implements them in <em>service</em> classes. It orchestrates the domain, manages transactions, and talks to the outside world only through <em>output ports</em> — interfaces it owns.</p><p><strong>Input port</strong> (driving side — what the app can do):</p><pre>package com.shop.ordering.application.port.in;</pre><pre>public interface PlaceOrderUseCase {<br>    OrderId placeOrder(PlaceOrderCommand command);<br>}</pre><p><strong>Output ports</strong> (driven side — what the app needs):</p><pre>package com.shop.ordering.application.port.out;</pre><pre>import com.shop.ordering.domain.Order;<br>import com.shop.ordering.domain.OrderId;<br>import java.util.Optional;</pre><pre>public interface LoadOrderPort {<br>    Optional&lt;Order&gt; load(OrderId id);<br>}</pre><pre>public interface SaveOrderPort {<br>    void save(Order order);<br>}</pre><p><strong>Application service</strong> — the only place Spring lightly touches the core (it’s fine for @Service/@Transactional to live here; this layer is <em>allowed</em> to know about orchestration concerns):</p><pre>package com.shop.ordering.application.service;</pre><pre>import com.shop.ordering.application.port.in.*;<br>import com.shop.ordering.application.port.out.*;<br>import com.shop.ordering.domain.*;<br>import org.springframework.stereotype.Service;<br>import org.springframework.transaction.annotation.Transactional;</pre><pre>@Service<br>public class PlaceOrderService implements PlaceOrderUseCase {</pre><pre>    private final SaveOrderPort saveOrderPort;<br>    private final LoadCustomerPort loadCustomerPort;</pre><pre>    public PlaceOrderService(SaveOrderPort saveOrderPort, LoadCustomerPort loadCustomerPort) {<br>        this.saveOrderPort = saveOrderPort;<br>        this.loadCustomerPort = loadCustomerPort;<br>    }</pre><pre>    @Override<br>    @Transactional<br>    public OrderId placeOrder(PlaceOrderCommand command) {<br>        Customer customer = loadCustomerPort.load(command.customerId())<br>                .orElseThrow(() -&gt; new CustomerNotFoundException(command.customerId()));</pre><pre>        Order order = new Order(OrderId.newId(), customer.id(), command.toLines());<br>        customer.assertCanPlaceOrder(order);   // domain invariant<br>        order.place();</pre><pre>        saveOrderPort.save(order);<br>        return order.id();<br>    }<br>}</pre><blockquote><strong><em>A purist note:</em></strong><em> some teams keep </em><em>@Service/</em><em>@Transactional out of the application service too, declaring beans via </em><em>@Configuration and managing transactions in the adapter. That&#39;s more decoupled but more ceremony. For most teams, allowing Spring stereotypes in the application layer (but </em><strong><em>never</em></strong><em> the domain layer) is a pragmatic, defensible line.</em></blockquote><h3>3. Infrastructure Layer — adapters</h3><p>Adapters are the concrete glue. There are two kinds:</p><ul><li><strong>Inbound (driving) adapters</strong> call input ports. REST controllers, message consumers, CLI handlers, scheduled jobs.</li><li><strong>Outbound (driven) adapters</strong> implement output ports. JPA repositories, HTTP clients, Kafka producers.</li></ul><p><strong>Inbound REST adapter:</strong></p><pre>package com.shop.ordering.infrastructure.web;</pre><pre>import com.shop.ordering.application.port.in.*;<br>import org.springframework.http.ResponseEntity;<br>import org.springframework.web.bind.annotation.*;</pre><pre>@RestController<br>@RequestMapping(&quot;/api/orders&quot;)<br>class OrderController {</pre><pre>    private final PlaceOrderUseCase placeOrderUseCase;</pre><pre>    OrderController(PlaceOrderUseCase placeOrderUseCase) {<br>        this.placeOrderUseCase = placeOrderUseCase;<br>    }</pre><pre>    @PostMapping<br>    ResponseEntity&lt;PlaceOrderResponse&gt; place(@RequestBody PlaceOrderRequest request) {<br>        OrderId id = placeOrderUseCase.placeOrder(request.toCommand());<br>        return ResponseEntity<br>                .created(URI.create(&quot;/api/orders/&quot; + id.value()))<br>                .body(new PlaceOrderResponse(id.value()));<br>    }<br>}</pre><p>The controller depends on the <em>use case interface</em>, not the service implementation. The PlaceOrderRequest/PlaceOrderResponse are web DTOs that never leak into the domain — the mapping happens at the boundary.</p><p><strong>Outbound JPA adapter</strong> — this is where the JPA entity lives, and it is <em>not</em> the domain Order:</p><pre>package com.shop.ordering.infrastructure.persistence;</pre><pre>import com.shop.ordering.application.port.out.*;<br>import com.shop.ordering.domain.*;<br>import org.springframework.stereotype.Component;</pre><pre>@Component<br>class OrderPersistenceAdapter implements SaveOrderPort, LoadOrderPort {</pre><pre>    private final OrderJpaRepository repository;<br>    private final OrderMapper mapper;</pre><pre>    OrderPersistenceAdapter(OrderJpaRepository repository, OrderMapper mapper) {<br>        this.repository = repository;<br>        this.mapper = mapper;<br>    }</pre><pre>    @Override<br>    public void save(Order order) {<br>        repository.save(mapper.toJpaEntity(order));<br>    }</pre><pre>    @Override<br>    public Optional&lt;Order&gt; load(OrderId id) {<br>        return repository.findById(id.value()).map(mapper::toDomain);<br>    }<br>}</pre><pre>@Entity<br>@Table(name = &quot;orders&quot;)<br>class OrderJpaEntity {<br>    @Id private UUID id;<br>    private UUID customerId;<br>    @Enumerated(EnumType.STRING) private String status;<br>    @OneToMany(cascade = CascadeType.ALL, mappedBy = &quot;order&quot;)<br>    private List&lt;OrderLineJpaEntity&gt; lines;<br>    // getters/setters, no-arg constructor for Hibernate<br>}</pre><pre>interface OrderJpaRepository extends JpaRepository&lt;OrderJpaEntity, UUID&gt; {}</pre><p>The mapper translates between the persistence model and the domain model. Yes, this is more code. The payoff: Hibernate’s lazy-loading, dirty-checking, and cascade semantics never touch your business rules.</p><h3>Recommended Project Structure</h3><p>Organize <strong>by feature first, then by layer</strong>. This keeps a bounded context’s code together and makes the dependency direction visible in the package tree.</p><pre>com.shop.ordering<br>├── domain                 // pure business model<br>│   ├── Order.java<br>│   ├── OrderLine.java<br>│   ├── Money.java<br>│   └── OrderStatus.java<br>├── application<br>│   ├── port<br>│   │   ├── in             // input ports (use cases)<br>│   │   │   ├── PlaceOrderUseCase.java<br>│   │   │   └── PlaceOrderCommand.java<br>│   │   └── out            // output ports<br>│   │       ├── SaveOrderPort.java<br>│   │       └── LoadOrderPort.java<br>│   └── service<br>│       └── PlaceOrderService.java<br>└── infrastructure<br>    ├── web                // inbound adapter<br>    │   ├── OrderController.java<br>    │   └── PlaceOrderRequest.java<br>    └── persistence        // outbound adapter<br>        ├── OrderPersistenceAdapter.java<br>        ├── OrderJpaEntity.java<br>        ├── OrderJpaRepository.java<br>        └── OrderMapper.java</pre><p>You can <em>enforce</em> the dependency rule with ArchUnit so it doesn’t erode:</p><pre>@AnalyzeClasses(packages = &quot;com.shop.ordering&quot;)<br>class ArchitectureTest {</pre><pre>    @ArchTest<br>    static final ArchRule domain_depends_on_nothing =<br>        noClasses().that().resideInAPackage(&quot;..domain..&quot;)<br>            .should().dependOnClassesThat()<br>            .resideInAnyPackage(&quot;..application..&quot;, &quot;..infrastructure..&quot;,<br>                                &quot;org.springframework..&quot;, &quot;jakarta.persistence..&quot;);<br>}</pre><h3>Why Dependency Direction Is the Whole Point</h3><p>The magic that makes the database depend on the domain (and not vice versa) is <strong>dependency inversion</strong>. The application layer declares SaveOrderPort. The persistence adapter <em>implements</em> it. At compile time, the adapter package imports the application package; the application package imports nothing from the adapter. Spring&#39;s IoC container wires the implementation in at runtime.</p><pre>| Concern | Classic layered | Hexagonal |<br>|---|---|---|<br>| Domain knows about DB | Yes (JPA entity = domain) | No |<br>| Swap Postgres → Mongo | Touches services + entities | New adapter only |<br>| Unit-test business rule | Needs DB or mock repo | Plain JUnit, no Spring |<br>| Framework lock-in | High | Isolated to adapters |<br>| Onboarding clarity | &quot;Where&#39;s the rule?&quot; | Always in domain |<br>| Initial boilerplate | Low | Higher (mappers, ports) |</pre><h3>Testing Benefits — the Real Payoff</h3><p>This is where hexagonal earns its keep.</p><p><strong>Domain tests</strong> are pure, fast, framework-free:</p><pre>@Test<br>void placing_a_draft_order_marks_it_placed() {<br>    Order order = new Order(OrderId.newId(), CustomerId.newId(),<br>            List.of(new OrderLine(&quot;SKU-1&quot;, 2, new Money(BigDecimal.TEN, &quot;EUR&quot;))));</pre><pre>    order.place();</pre><pre>    assertThat(order.status()).isEqualTo(OrderStatus.PLACED);<br>}</pre><p><strong>Application/use-case tests</strong> use trivial in-memory fakes of the output ports — no Mockito ceremony, no database:</p><pre>@Test<br>void placing_order_persists_it() {<br>    var saveAdapter = new InMemoryOrderStore();          // implements SaveOrderPort<br>    var customers   = new InMemoryCustomerStore(existing); // implements LoadCustomerPort<br>    var service     = new PlaceOrderService(saveAdapter, customers);</pre><pre>    OrderId id = service.placeOrder(validCommand());</pre><pre>    assertThat(saveAdapter.find(id)).isPresent();<br>}</pre><p><strong>Adapter tests</strong> are integration tests scoped to the boundary: @DataJpaTest with Testcontainers for the persistence adapter, @WebMvcTest for the controller. Each test layer has a clear, narrow target. You stop spinning up the full Spring context just to verify a calculation.</p><h3>When NOT to Use Hexagonal Architecture</h3><p>Hexagonal is not free, and treating it as a default for everything is a mistake.</p><ul><li><strong>CRUD-heavy apps with no real domain.</strong> If your “business logic” is save and findAll, the ports, adapters, and mappers are pure overhead. A thin Controller → JpaRepository stack is honest and faster to ship.</li><li><strong>Small or short-lived services.</strong> A throwaway internal tool or a prototype doesn’t need this rigor.</li><li><strong>Tiny teams under deadline pressure</strong> who won’t maintain the discipline — half-applied hexagonal (some ports, some direct JPA leakage) is worse than consistent simplicity.</li></ul><p>The pattern shines when there are <strong>rich business rules, long expected lifespan, multiple integrations, and a need to test logic in isolation.</strong> Order management, pricing, billing, fraud, lending — yes. A reference-data lookup service — probably not.</p><h3>Common Pitfalls</h3><ul><li><strong>The anemic domain.</strong> If Order is just getters/setters and all logic lives in PlaceOrderService, you&#39;ve built layered architecture with extra steps. Push invariants <em>into</em> the entities.</li><li><strong>Letting JPA entities be the domain model.</strong> The single most common shortcut, and it quietly couples your core to Hibernate. Keep the mapper.</li><li><strong>Leaky DTOs.</strong> Don’t let @RequestBody Jackson objects or JPA entities cross into the domain. Map at the boundary, every time.</li><li><strong>Over-porting.</strong> You don’t need a port for every method. Group cohesive operations; a use case can be a single-method interface, and an output port can cover a small set of related operations.</li><li><strong>Putting business logic in adapters.</strong> A REST controller deciding “if status is X, do Y” is a smell. The decision belongs in the domain or application service.</li><li><strong>Ports owned by the wrong side.</strong> Output ports must be defined in the application layer (the consumer), not in the infrastructure layer. If the adapter defines the interface, you’ve lost dependency inversion.</li></ul><h3>A Concrete End-to-End Flow</h3><p>Putting it together, a “place order” request flows like this:</p><pre>HTTP POST /api/orders<br>   -&gt; OrderController            (inbound adapter, maps JSON -&gt; command)<br>   -&gt; PlaceOrderUseCase          (input port)<br>   -&gt; PlaceOrderService          (application: orchestrates, @Transactional)<br>        -&gt; loadCustomerPort.load (output port -&gt; JPA adapter -&gt; DB)<br>        -&gt; new Order(...)        (domain: enforces invariants)<br>        -&gt; order.place()         (domain: state transition)<br>        -&gt; saveOrderPort.save    (output port -&gt; JPA adapter -&gt; DB)<br>   &lt;- OrderId<br>   &lt;- 201 Created</pre><p>At no point does the domain import Spring, JPA, or HTTP. The framework lives entirely at the edges. That’s the whole game: <strong>a core you can reason about, test, and evolve without the infrastructure dictating terms.</strong></p><h3>Closing Thoughts</h3><p>Hexagonal architecture is a discipline, not a library. Spring Boot makes it pleasant — constructor injection wires your ports to adapters with almost no ceremony — but the value comes from the boundaries <em>you</em> enforce: a pure domain, ports owned by the application, adapters that translate at the edge. Done well, you get business logic that reads like the business, tests that run in milliseconds, and infrastructure you can replace without fear. Done as cargo-cult ceremony on a CRUD app, you get mappers nobody asked for. Choose deliberately.</p><p>If this was useful, follow <strong>devdomain</strong> for more deep-dives on architecture, Java, and Spring. What’s your take — do you keep @Transactional in the application service or push it to the adapter? Drop a comment, I read all of them.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b41d848960d1" width="1" height="1" alt=""><hr><p><a href="https://medium.com/devdomain/hexagonal-architecture-ports-and-adapters-in-spring-bootmost-spring-boot-codebases-rot-in-the-b41d848960d1">Hexagonal Architecture (Ports and Adapters) in Spring BootMost Spring Boot codebases rot in the…</a> was originally published in <a href="https://medium.com/devdomain">devdomain</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Spring Boot Actuator: Production-Grade Observability with Micrometer and PrometheusThe moment your…]]></title>
            <link>https://medium.com/devdomain/spring-boot-actuator-production-grade-observability-with-micrometer-and-prometheusthe-moment-your-522408f9db64?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/522408f9db64</guid>
            <category><![CDATA[observability]]></category>
            <category><![CDATA[java]]></category>
            <category><![CDATA[micrometer]]></category>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[prometheus]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Tue, 20 Oct 2026 16:21:01 GMT</pubDate>
            <atom:updated>2026-10-20T16:21:01.698Z</atom:updated>
            <content:encoded><![CDATA[<p>Spring Boot Actuator: Production-Grade Observability with Micrometer and PrometheusThe moment your Spring Boot application leaves your laptop, a hard question starts following it around: <em>is it actually healthy, and how would you know before a customer tells you?</em> Logs answer “what happened.” Observability answers “what is happening right now, and is it normal?” Spring Boot Actuator is the on-ramp to that second question.</p><p>Actuator gives you a set of production-ready HTTP (and JMX) endpoints — health, metrics, environment, thread dumps, and more — plus deep integration with Micrometer, a vendor-neutral metrics facade. Pair that with a Prometheus scrape endpoint and you have a stack that powers dashboards, alerting, and Kubernetes probes without writing a custom monitoring framework.</p><p>This article walks the full path: enabling Actuator safely, designing health/liveness/readiness probes, instrumenting your own metrics with Micrometer, exposing them to Prometheus, writing custom health indicators, and — critically — locking the whole thing down so you don’t accidentally publish your config to the internet.</p><h3>Getting Actuator on the classpath</h3><p>Two dependencies do most of the work. Actuator itself, and the Micrometer Prometheus registry.</p><pre>&lt;!-- pom.xml --&gt;<br>&lt;dependency&gt;<br>    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;<br>    &lt;artifactId&gt;spring-boot-starter-actuator&lt;/artifactId&gt;<br>&lt;/dependency&gt;<br>&lt;dependency&gt;<br>    &lt;groupId&gt;io.micrometer&lt;/groupId&gt;<br>    &lt;artifactId&gt;micrometer-registry-prometheus&lt;/artifactId&gt;<br>    &lt;scope&gt;runtime&lt;/scope&gt;<br>&lt;/dependency&gt;</pre><p>With just these on the classpath, Actuator auto-configures its endpoints, and the presence of the Prometheus registry auto-configures a /actuator/prometheus endpoint that emits metrics in Prometheus exposition format. You write zero glue code.</p><h3>Endpoints: what’s exposed and what isn’t</h3><p>Actuator ships dozens of endpoints. By default, over HTTP, <strong>only </strong><strong>health is exposed</strong>. Everything else is registered but not reachable over the web until you opt in. This default is deliberate — endpoints like env, heapdump, and threaddump leak sensitive information.</p><p>You control exposure with two property families:</p><pre># application.properties</pre><pre># Which endpoints are enabled (default: all enabled except shutdown)<br>management.endpoint.shutdown.enabled=false</pre><pre># Which enabled endpoints are exposed over HTTP<br>management.endpoints.web.exposure.include=health,info,metrics,prometheus,loggers<br># Or explicitly hide some even if included<br>management.endpoints.web.exposure.exclude=env,beans</pre><p>A frequent source of confusion: <strong>enabled</strong> and <strong>exposed</strong> are different switches. An endpoint must be <em>enabled</em> (it exists as a bean) and <em>exposed</em> (reachable over the chosen technology, web or JMX). Disabling beats exposing — a disabled endpoint cannot be exposed.</p><p>Here are the endpoints you’ll touch most:</p><p>Endpoint Path Purpose Safe to expose publicly? health /actuator/health Liveness/readiness, dependency status Yes (with show-details care) info /actuator/info Build/git/app metadata Usually metrics /actuator/metrics Browse individual meters No prometheus /actuator/prometheus Scrape target for Prometheus No (internal network only) loggers /actuator/loggers View/change log levels at runtime No env /actuator/env Full environment + properties Never threaddump /actuator/threaddump JVM thread dump No heapdump /actuator/heapdump Downloads a heap dump Never</p><h3>Moving Actuator to its own port</h3><p>A common production pattern is to serve management endpoints on a separate port that is only reachable inside the cluster, never via the public ingress:</p><pre>management.server.port=8081<br>management.server.address=127.0.0.1</pre><p>Now your app serves business traffic on 8080 and Actuator on 8081, which you bind to localhost or an internal interface and never route externally.</p><h3>Health, liveness, and readiness</h3><p>/actuator/health aggregates the status of every HealthIndicator on the classpath. Spring Boot auto-configures many: db, diskSpace, redis, mongo, rabbit, and so on. The aggregate status is the worst of its parts — one DOWN indicator makes the whole endpoint report DOWN and return HTTP 503.</p><pre>{<br>  &quot;status&quot;: &quot;UP&quot;,<br>  &quot;components&quot;: {<br>    &quot;db&quot;: { &quot;status&quot;: &quot;UP&quot;, &quot;details&quot;: { &quot;database&quot;: &quot;PostgreSQL&quot; } },<br>    &quot;diskSpace&quot;: { &quot;status&quot;: &quot;UP&quot; }<br>  }<br>}</pre><p>By default, health shows only the top-level status. To see component details — extremely useful for debugging — set:</p><pre># never | when-authorized | always<br>management.endpoint.health.show-details=when-authorized<br>management.endpoint.health.show-components=when-authorized</pre><p>when-authorized shows full details only to authenticated users with the right role, which is the sane production setting. always is fine on an internal-only management port.</p><h3>Liveness vs readiness probes</h3><p>Kubernetes asks two different questions, and conflating them causes cascading restarts:</p><ul><li><strong>Liveness</strong>: “Is this process broken beyond recovery?” If it fails, the orchestrator <em>kills and restarts</em> the pod.</li><li><strong>Readiness</strong>: “Can this instance serve traffic right now?” If it fails, the orchestrator <em>removes it from the load balancer</em> but lets it keep running.</li></ul><p>A database outage should fail <em>readiness</em> (stop sending traffic until the DB recovers), not <em>liveness</em> (don’t restart the pod — that won’t fix the database). Getting this wrong turns a transient dependency blip into a restart storm.</p><p>Spring Boot has first-class support. Enable the probes:</p><pre>management.endpoint.health.probes.enabled=true<br>management.health.livenessstate.enabled=true<br>management.health.readinessstate.enabled=true</pre><p>This exposes /actuator/health/liveness and /actuator/health/readiness. Your Kubernetes manifest then points at them:</p><pre>livenessProbe:<br>  httpGet:<br>    path: /actuator/health/liveness<br>    port: 8081<br>  initialDelaySeconds: 10<br>  periodSeconds: 10<br>readinessProbe:<br>  httpGet:<br>    path: /actuator/health/readiness<br>    port: 8081<br>  periodSeconds: 5</pre><p>You can drive these states programmatically. For example, when a background warm-up finishes, mark the app ready; if an unrecoverable state is detected, mark it broken:</p><pre>import org.springframework.boot.availability.AvailabilityChangeEvent;<br>import org.springframework.boot.availability.LivenessState;<br>import org.springframework.boot.availability.ReadinessState;<br>import org.springframework.context.ApplicationEventPublisher;<br>import org.springframework.stereotype.Component;</pre><pre>@Component<br>public class WarmupSignaller {</pre><pre>    private final ApplicationEventPublisher publisher;</pre><pre>    public WarmupSignaller(ApplicationEventPublisher publisher) {<br>        this.publisher = publisher;<br>    }</pre><pre>    public void warmupComplete() {<br>        AvailabilityChangeEvent.publish(publisher, this, ReadinessState.ACCEPTING_TRAFFIC);<br>    }</pre><pre>    public void fatalError() {<br>        AvailabilityChangeEvent.publish(publisher, this, LivenessState.BROKEN);<br>    }<br>}</pre><h3>Writing a custom health indicator</h3><p>The built-in indicators cover infrastructure, but you’ll often need to express <em>business</em> health — “can we reach the payment gateway?” Implement HealthIndicator (or the reactive ReactiveHealthIndicator):</p><pre>import org.springframework.boot.actuate.health.Health;<br>import org.springframework.boot.actuate.health.HealthIndicator;<br>import org.springframework.stereotype.Component;<br>import org.springframework.web.client.RestClient;</pre><pre>@Component<br>public class PaymentGatewayHealthIndicator implements HealthIndicator {</pre><pre>    private final RestClient restClient;</pre><pre>    public PaymentGatewayHealthIndicator(RestClient.Builder builder) {<br>        this.restClient = builder.baseUrl(&quot;https://payments.example.com&quot;).build();<br>    }</pre><pre>    @Override<br>    public Health health() {<br>        try {<br>            long start = System.nanoTime();<br>            restClient.get().uri(&quot;/ping&quot;).retrieve().toBodilessEntity();<br>            long ms = (System.nanoTime() - start) / 1_000_000;<br>            return Health.up()<br>                    .withDetail(&quot;latencyMs&quot;, ms)<br>                    .build();<br>        } catch (Exception ex) {<br>            return Health.down(ex)<br>                    .withDetail(&quot;hint&quot;, &quot;payment gateway unreachable&quot;)<br>                    .build();<br>        }<br>    }<br>}</pre><p>The bean name (paymentGateway, derived from the class minus the HealthIndicator suffix) becomes the component key in the health response.</p><h3>Don’t let one indicator sink readiness blindly</h3><p>By default a custom HealthIndicator is grouped into the overall health but <strong>not</strong> into the readiness group. If a non-critical dependency being down shouldn&#39;t pull you out of the load balancer, keep it out of readiness. If it <em>should</em>, add it explicitly to a health group:</p><pre>management.endpoint.health.group.readiness.include=readinessState,db,paymentGateway<br>management.endpoint.health.group.liveness.include=livenessState</pre><p>Health groups let you compose exactly which indicators contribute to which probe. This is one of the most underused, highest-leverage Actuator features.</p><h3>Micrometer: the metrics facade</h3><p>Micrometer is to metrics what SLF4J is to logging — a vendor-neutral API with pluggable backends (Prometheus, Datadog, CloudWatch, OTLP, and more). You instrument against Micrometer’s MeterRegistry; the registry on the classpath decides the wire format.</p><p>The four meter types you’ll use:</p><ul><li><strong>Counter</strong> — monotonically increasing count (requests served, errors).</li><li><strong>Gauge</strong> — an instantaneous value that can go up or down (queue depth, cache size).</li><li><strong>Timer</strong> — count + total time + max of short-duration events (request latency).</li><li><strong>DistributionSummary</strong> — like a timer but for non-time values (payload sizes).</li></ul><h3>Counters and timers</h3><pre>import io.micrometer.core.instrument.Counter;<br>import io.micrometer.core.instrument.MeterRegistry;<br>import io.micrometer.core.instrument.Timer;<br>import org.springframework.stereotype.Service;</pre><pre>@Service<br>public class OrderService {</pre><pre>    private final Counter ordersPlaced;<br>    private final Timer checkoutTimer;</pre><pre>    public OrderService(MeterRegistry registry) {<br>        this.ordersPlaced = Counter.builder(&quot;orders.placed&quot;)<br>                .description(&quot;Total orders successfully placed&quot;)<br>                .tag(&quot;channel&quot;, &quot;web&quot;)<br>                .register(registry);<br>        this.checkoutTimer = Timer.builder(&quot;orders.checkout.duration&quot;)<br>                .description(&quot;Time to complete checkout&quot;)<br>                .publishPercentiles(0.5, 0.95, 0.99)<br>                .register(registry);<br>    }</pre><pre>    public Order checkout(Cart cart) {<br>        return checkoutTimer.record(() -&gt; {<br>            Order order = process(cart);<br>            ordersPlaced.increment();<br>            return order;<br>        });<br>    }</pre><pre>    private Order process(Cart cart) { /* ... */ return new Order(); }<br>}</pre><p>publishPercentiles computes client-side percentiles. If you want <em>aggregatable</em> percentiles across instances, use publishPercentileHistogram() instead, which emits histogram buckets that Prometheus can aggregate with histogram_quantile(). Client-side percentiles cannot be averaged across pods; histogram buckets can.</p><h3>Gauges</h3><p>Gauges observe a value via a function. Hold a strong reference to the object you’re measuring — Micrometer keeps only a weak reference, and if your object is garbage collected the gauge reports NaN.</p><pre>import io.micrometer.core.instrument.Gauge;<br>import io.micrometer.core.instrument.MeterRegistry;<br>import java.util.concurrent.BlockingQueue;</pre><pre>public class QueueMetrics {<br>    public QueueMetrics(MeterRegistry registry, BlockingQueue&lt;?&gt; queue) {<br>        Gauge.builder(&quot;worker.queue.depth&quot;, queue, BlockingQueue::size)<br>             .description(&quot;Pending work items&quot;)<br>             .register(registry);<br>    }<br>}</pre><h3>Annotation-based timing</h3><p>For method-level timing without boilerplate, use @Timed. It requires a TimedAspect bean:</p><pre>import io.micrometer.core.aop.TimedAspect;<br>import io.micrometer.core.instrument.MeterRegistry;<br>import org.springframework.context.annotation.Bean;<br>import org.springframework.context.annotation.Configuration;</pre><pre>@Configuration<br>public class MetricsConfig {<br>    @Bean<br>    TimedAspect timedAspect(MeterRegistry registry) {<br>        return new TimedAspect(registry);<br>    }<br>}</pre><pre>import io.micrometer.core.annotation.Timed;<br>import org.springframework.web.bind.annotation.GetMapping;<br>import org.springframework.web.bind.annotation.RestController;</pre><pre>@RestController<br>public class ReportController {</pre><pre>    @Timed(value = &quot;reports.generate&quot;, percentiles = {0.95, 0.99})<br>    @GetMapping(&quot;/reports/monthly&quot;)<br>    public Report monthly() { /* ... */ return new Report(); }<br>}</pre><h3>Tags, cardinality, and the trap that kills Prometheus</h3><p>Tags (Prometheus calls them labels) are dimensions you slice metrics by — status, endpoint, region. Every unique combination of tag values creates a new time series. <strong>High-cardinality tags are the single most common way teams blow up their metrics backend.</strong></p><p>Never use unbounded values as tags: user IDs, order IDs, full URLs with path variables, email addresses, request bodies. A counter tagged with userId becomes millions of time series and will OOM your Prometheus.</p><pre>// BAD: cardinality explosion<br>Counter.builder(&quot;api.calls&quot;).tag(&quot;userId&quot;, userId).register(registry);</pre><pre>// GOOD: bounded set of values<br>Counter.builder(&quot;api.calls&quot;).tag(&quot;plan&quot;, user.plan().name()).register(registry);</pre><p>Spring already templatizes HTTP metrics — /orders/42 and /orders/99 both report under uri=&quot;/orders/{id}&quot;, keeping cardinality bounded.</p><h3>Common tags for every metric</h3><p>Stamp every metric with environment metadata so dashboards can filter across services. Use a MeterRegistryCustomizer:</p><pre>import io.micrometer.core.instrument.MeterRegistry;<br>import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer;<br>import org.springframework.context.annotation.Bean;<br>import org.springframework.context.annotation.Configuration;</pre><pre>@Configuration<br>public class CommonSagsConfig {<br>    @Bean<br>    MeterRegistryCustomizer&lt;MeterRegistry&gt; commonTags(<br>            @org.springframework.beans.factory.annotation.Value(&quot;${spring.application.name}&quot;) String app) {<br>        return registry -&gt; registry.config().commonTags(<br>                &quot;application&quot;, app,<br>                &quot;region&quot;, System.getenv().getOrDefault(&quot;REGION&quot;, &quot;local&quot;));<br>    }<br>}</pre><h3>Exposing to Prometheus</h3><p>With the registry on the classpath and prometheus exposed, scraping is just configuration on the Prometheus side:</p><pre># prometheus.yml<br>scrape_configs:<br>  - job_name: &quot;orders-service&quot;<br>    metrics_path: /actuator/prometheus<br>    scrape_interval: 15s<br>    static_configs:<br>      - targets: [&quot;orders-service:8081&quot;]</pre><p>A sample of the exposition output:</p><pre># HELP orders_placed_total Total orders successfully placed<br># TYPE orders_placed_total counter<br>orders_placed_total{application=&quot;orders&quot;,channel=&quot;web&quot;,region=&quot;eu&quot;} 4821.0</pre><pre># HELP orders_checkout_duration_seconds<br># TYPE orders_checkout_duration_seconds summary<br>orders_checkout_duration_seconds{quantile=&quot;0.95&quot;} 0.182<br>orders_checkout_duration_seconds_count 4821.0<br>orders_checkout_duration_seconds_sum 712.4</pre><pre>Spring Boot app                Prometheus               Grafana<br> ┌────────────────┐   scrape    ┌────────────┐  query   ┌──────────┐<br> │ /actuator/     │ &lt;────────── │ TSDB       │ &lt;─────── │ dashboards│<br> │   prometheus   │   15s pull  │ + alerting │  PromQL  │ + alerts  │<br> └────────────────┘             └────────────┘          └──────────┘</pre><p>Note the pull model: Prometheus scrapes you. Your app does not push. This means a dead app simply stops appearing — Prometheus’s up metric goes to 0, which you alert on.</p><h3>Securing Actuator with Spring Security</h3><p>If Spring Security is on the classpath, you must explicitly authorize Actuator endpoints or they’ll be locked behind your default rules. Use EndpointRequest matchers rather than hardcoding paths — they respect a custom management.endpoints.web.base-path:</p><pre>import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;<br>import org.springframework.boot.actuate.health.HealthEndpoint;<br>import org.springframework.boot.actuate.info.InfoEndpoint;<br>import org.springframework.context.annotation.Bean;<br>import org.springframework.context.annotation.Configuration;<br>import org.springframework.security.config.annotation.web.builders.HttpSecurity;<br>import org.springframework.security.web.SecurityFilterChain;</pre><pre>@Configuration<br>public class ActuatorSecurityConfig {</pre><pre>    @Bean<br>    SecurityFilterChain actuatorChain(HttpSecurity http) throws Exception {<br>        http.securityMatcher(EndpointRequest.toAnyEndpoint())<br>            .authorizeHttpRequests(auth -&gt; auth<br>                .requestMatchers(EndpointRequest.to(HealthEndpoint.class, InfoEndpoint.class)).permitAll()<br>                .anyRequest().hasRole(&quot;ACTUATOR_ADMIN&quot;))<br>            .httpBasic(org.springframework.security.config.Customizer.withDefaults());<br>        return http.build();<br>    }<br>}</pre><p>This permits health and info for everyone (probes need them) while requiring the ACTUATOR_ADMIN role for everything else. Combine with show-details=when-authorized and full health details appear only to admins.</p><h3>The info endpoint and build metadata</h3><p>Wire build and git info into /actuator/info so you can verify exactly which commit is running:</p><pre>management.info.env.enabled=true<br>management.info.git.mode=full<br>info.app.team=payments</pre><p>Add the Spring Boot Maven plugin’s build-info goal and the git-commit-id-maven-plugin, and /actuator/info will report version, build time, and commit hash — invaluable during incident triage.</p><h3>Common pitfalls</h3><ul><li><strong>Exposing everything.</strong> management.endpoints.web.exposure.include=* plus no security plus a public ingress is a data breach. Always scope exposure and bind management to an internal port.</li><li><strong>Liveness/readiness confusion.</strong> Failing liveness on a DB outage restarts healthy pods. Map dependency failures to readiness.</li><li><strong>Cardinality explosions.</strong> Tagging metrics with IDs, raw URIs, or user data. Keep tag value sets small and bounded.</li><li><strong>Weakly-referenced gauges.</strong> Forgetting to hold a reference to the gauged object; it gets GC’d and reports NaN.</li><li><strong>Client-side percentiles you try to average.</strong> Use publishPercentileHistogram() when you need cross-instance percentiles via histogram_quantile().</li><li><strong>show-details=always in public deployments.</strong> Leaks dependency topology, JDBC URLs, and validation messages.</li><li><strong>Forgetting the </strong><strong>TimedAspect bean.</strong> @Timed silently does nothing without it.</li><li><strong>Slow custom health indicators.</strong> Probes run frequently; a 5-second gateway check on every scrape can hammer a dependency. Add timeouts and consider caching.</li></ul><h3>Wrapping up</h3><p>Actuator plus Micrometer plus Prometheus is the default observability stack for Spring Boot for good reason: a couple of dependencies give you health probes that Kubernetes understands, a metrics facade that doesn’t lock you to a vendor, and a scrape endpoint that drops straight into Prometheus and Grafana. The work that remains is <em>design</em> work — choosing the right probes, instrumenting the metrics that matter, keeping cardinality sane, and locking the endpoints down. Do that, and your service stops being a black box.</p><p>If this helped, follow <strong>devdomain</strong> for more deep Spring dives, and drop a comment with the metrics you wish Spring exposed out of the box — I read every one.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=522408f9db64" width="1" height="1" alt=""><hr><p><a href="https://medium.com/devdomain/spring-boot-actuator-production-grade-observability-with-micrometer-and-prometheusthe-moment-your-522408f9db64">Spring Boot Actuator: Production-Grade Observability with Micrometer and PrometheusThe moment your…</a> was originally published in <a href="https://medium.com/devdomain">devdomain</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Optimistic vs Pessimistic Locking in JPATwo users open the same order.]]></title>
            <link>https://medium.com/devdomain/optimistic-vs-pessimistic-locking-in-jpatwo-users-open-the-same-order-f113f36c0592?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/f113f36c0592</guid>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[java]]></category>
            <category><![CDATA[jpa]]></category>
            <category><![CDATA[concurrency]]></category>
            <category><![CDATA[hibernate]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Thu, 15 Oct 2026 16:01:04 GMT</pubDate>
            <atom:updated>2026-10-15T16:01:03.831Z</atom:updated>
            <content:encoded><![CDATA[<p>Optimistic vs Pessimistic Locking in JPATwo users open the same order. Both edit the shipping address. Both hit Save. One of those updates is about to vanish without a trace, and your database won’t say a word about it.</p><p>This is the <strong>lost update</strong> problem, and it’s the reason concurrency control exists. JPA gives you two strategies to deal with it: <strong>optimistic locking</strong>, which assumes conflicts are rare and detects them after the fact, and <strong>pessimistic locking</strong>, which assumes conflicts are likely and prevents them up front by locking rows in the database. Picking the wrong one — or worse, neither — leads to corrupted data, deadlocks, or throughput that falls off a cliff.</p><p>Let’s go deep on both, with real Spring Boot 3.x code, retry logic, and the trade-offs that actually matter in production.</p><h3>The lost update, demonstrated</h3><p>Without locking, two concurrent transactions read the same row, each modifies it, and the second commit silently overwrites the first.</p><pre>T1: read account(balance=100)<br>T2: read account(balance=100)<br>T1: balance = 100 - 30  -&gt; write 70<br>T2: balance = 100 - 50  -&gt; write 50   &lt;-- T1&#39;s withdrawal is gone</pre><p>The final balance is 50, but two withdrawals totaling 80 happened. The database is internally consistent and completely wrong. Neither READ COMMITTED nor even REPEATABLE READ saves you here in the general case — you need an explicit locking strategy.</p><h3>Optimistic locking with @Version</h3><p>Optimistic locking adds a version column. Every update increments it, and every update is guarded by a WHERE version = ? clause. If someone changed the row since you read it, the version no longer matches, zero rows update, and JPA throws.</p><pre>import jakarta.persistence.Entity;<br>import jakarta.persistence.Id;<br>import jakarta.persistence.Version;</pre><pre>@Entity<br>public class Account {</pre><pre>    @Id<br>    private Long id;</pre><pre>    private java.math.BigDecimal balance;</pre><pre>    @Version<br>    private long version; // managed entirely by JPA — never set it yourself</pre><pre>    // getters/setters<br>}</pre><p>When you load this account at version 7 and modify it, Hibernate generates:</p><pre>UPDATE account SET balance = ?, version = 8 WHERE id = ? AND version = 7</pre><p>If another transaction already bumped it to 8, the WHERE matches nothing, Hibernate sees rowcount = 0, and throws OptimisticLockException (Spring wraps it as ObjectOptimisticLockingFailureException).</p><p>The @Version field can be a long/int/Integer/Long, a short, or a java.time.Instant/Timestamp. Prefer a numeric version — timestamps have clock-resolution problems and can collide under high concurrency.</p><h3>Handling the conflict: retry</h3><p>An OptimisticLockException isn&#39;t a bug, it&#39;s information: &quot;someone beat you to it.&quot; The correct response is usually to re-read and retry. Spring Retry makes this clean:</p><pre>import org.springframework.dao.OptimisticLockingFailureException;<br>import org.springframework.retry.annotation.Backoff;<br>import org.springframework.retry.annotation.Retryable;<br>import org.springframework.stereotype.Service;<br>import org.springframework.transaction.annotation.Transactional;</pre><pre>@Service<br>public class AccountService {</pre><pre>    private final AccountRepository repo;</pre><pre>    public AccountService(AccountRepository repo) {<br>        this.repo = repo;<br>    }</pre><pre>    @Retryable(<br>        retryFor = OptimisticLockingFailureException.class,<br>        maxAttempts = 4,<br>        backoff = @Backoff(delay = 50, multiplier = 2.0))<br>    @Transactional<br>    public void withdraw(Long id, java.math.BigDecimal amount) {<br>        Account a = repo.findById(id).orElseThrow();<br>        if (a.getBalance().compareTo(amount) &lt; 0) {<br>            throw new InsufficientFundsException(id);<br>        }<br>        a.setBalance(a.getBalance().subtract(amount));<br>        // dirty checking flushes the versioned UPDATE at commit<br>    }<br>}</pre><p>Critical detail: <strong>the retry must start a fresh transaction and re-read the entity.</strong> @Retryable on a method whose @Transactional boundary is the method itself works because each attempt is a new method invocation → new transaction → fresh persistence context. If you retry <em>inside</em> one transaction with a stale entity, you&#39;ll just fail again forever.</p><blockquote><em>Note on proxies: </em><em>@Retryable and </em><em>@Transactional are AOP proxies. Calling </em><em>withdraw from within the same bean bypasses the proxy. Call across beans, or self-inject the proxy.</em></blockquote><h3>Forcing a version bump on a parent</h3><p>Sometimes you modify a child entity but want to detect concurrent changes against the parent aggregate (classic DDD aggregate-root protection). Use an explicit lock mode:</p><pre>import jakarta.persistence.LockModeType;</pre><pre>@Transactional<br>public void addLineItem(Long orderId, LineItem item) {<br>    Order order = em.find(Order.class, orderId,<br>            LockModeType.OPTIMISTIC_FORCE_INCREMENT);<br>    order.getItems().add(item);<br>    // Order.version is incremented even though only the collection changed.<br>}</pre><p>OPTIMISTIC checks the version at flush; OPTIMISTIC_FORCE_INCREMENT always bumps it, serializing logical changes to the aggregate.</p><h3>Pessimistic locking</h3><p>Pessimistic locking takes the opposite bet: conflicts are likely, so lock the row in the database <em>before</em> reading it. Other transactions that want the same lock block (or fail fast) until you commit. This is SELECT ... FOR UPDATE territory.</p><pre>import jakarta.persistence.LockModeType;<br>import org.springframework.data.jpa.repository.Lock;<br>import org.springframework.data.jpa.repository.Query;<br>import org.springframework.data.repository.query.Param;</pre><pre>public interface AccountRepository extends JpaRepository&lt;Account, Long&gt; {</pre><pre>    @Lock(LockModeType.PESSIMISTIC_WRITE)<br>    @Query(&quot;select a from Account a where a.id = :id&quot;)<br>    Optional&lt;Account&gt; findByIdForUpdate(@Param(&quot;id&quot;) Long id);<br>}</pre><pre>@Transactional<br>public void transfer(Long from, Long to, java.math.BigDecimal amount) {<br>    // Lock both rows up front. Other transfers touching these accounts block.<br>    Account src = repo.findByIdForUpdate(from).orElseThrow();<br>    Account dst = repo.findByIdForUpdate(to).orElseThrow();</pre><pre>    src.setBalance(src.getBalance().subtract(amount));<br>    dst.setBalance(dst.getBalance().add(amount));<br>}</pre><p>Hibernate emits SELECT ... FOR UPDATE, holding row locks until the transaction commits. No other transaction can modify (or, depending on mode, read-for-update) those rows in the meantime — the lost update is impossible by construction.</p><h3>Pessimistic lock modes</h3><p>LockModeType SQL Semantics PESSIMISTIC_READ SELECT ... FOR SHARE (DB-dependent) Shared lock: others can read but not write PESSIMISTIC_WRITE SELECT ... FOR UPDATE Exclusive lock: others cannot read-for-update or write PESSIMISTIC_FORCE_INCREMENT FOR UPDATE + version bump Exclusive lock <em>and</em> increments @Version</p><p>PESSIMISTIC_WRITE is what you reach for 95% of the time. PESSIMISTIC_READ is rarely supported cleanly across databases (many map it to the same exclusive lock).</p><h3>Lock timeouts: don’t block forever</h3><p>A blocked transaction holding connections is a recipe for thread-pool exhaustion. Bound the wait:</p><pre>import jakarta.persistence.LockModeType;<br>import jakarta.persistence.Map;<br>import org.hibernate.LockOptions;</pre><pre>java.util.Map&lt;String, Object&gt; hints = new java.util.HashMap&lt;&gt;();<br>hints.put(&quot;jakarta.persistence.lock.timeout&quot;, 3000); // milliseconds</pre><pre>Account a = em.find(Account.class, id, LockModeType.PESSIMISTIC_WRITE, hints);</pre><p>If the lock can’t be acquired within the timeout, you get LockTimeoutException (or PessimisticLockException) instead of hanging. Support varies by database; PostgreSQL honors it via lock_timeout, and you can also use NOWAIT/SKIP LOCKED semantics through provider hints for queue-style workloads.</p><h3>Deadlocks</h3><p>Pessimistic locking introduces a hazard optimistic locking doesn’t: <strong>deadlocks</strong>. Two transactions each hold a lock the other wants.</p><pre>T1: lock account A      T2: lock account B<br>T1: wants account B     T2: wants account A   --&gt; deadlock</pre><p>The database detects this and kills one transaction (DeadlockLoserDataAccessException in Spring). Two defenses:</p><ol><li><strong>Always acquire locks in a consistent order.</strong> In the transfer above, lock accounts sorted by ID, never in the arbitrary from→to order:</li></ol><pre>@Transactional<br>public void transfer(Long from, Long to, java.math.BigDecimal amount) {<br>    Long first = Math.min(from, to);<br>    Long second = Math.max(from, to);<br>    Account a1 = repo.findByIdForUpdate(first).orElseThrow();<br>    Account a2 = repo.findByIdForUpdate(second).orElseThrow();<br>    // ... resolve which is source/dest, then update<br>}</pre><ol><li><strong>Retry on deadlock</strong>, exactly like optimistic conflicts, since a deadlock victim’s transaction is rolled back and can safely re-run.</li></ol><h3>Where the version check actually happens</h3><p>A subtle but important point: with plain @Version, Hibernate only checks the version on entities it <strong>dirties</strong>. If you load an entity, read it, and never modify it, no UPDATE is generated and no version check occurs — Hibernate had nothing to flush. That&#39;s usually fine, but it means &quot;I read it, so I&#39;m protected against concurrent changes&quot; is false. Reading is not locking.</p><p>If you genuinely need to assert “nothing changed under me” even on a read-only access, request an explicit optimistic lock:</p><pre>Account a = em.find(Account.class, id, LockModeType.OPTIMISTIC);<br>// At flush, Hibernate verifies a.version still matches the DB,<br>// even though you only read the entity.</pre><p>LockModeType.OPTIMISTIC schedules a SELECT ... WHERE version = ? check at flush time. If the row&#39;s version moved, you get an OptimisticLockException even though you never wrote to the entity. This matters for read-then-decide flows where the decision must be based on data that&#39;s still current at commit.</p><h3>Optimistic locking across detached entities</h3><p>The reason optimistic locking fits stateless web applications is that the version travels with the data. When you serialize an entity (or a DTO carrying its version) to a client, edit it over minutes, and post it back, the version comes along. On merge, Hibernate compares the incoming version against the database:</p><pre>@Transactional<br>public Order saveEdited(OrderDto dto) {<br>    Order order = new Order();<br>    order.setId(dto.id());<br>    order.setVersion(dto.version()); // the version the client started from<br>    order.setShippingAddress(dto.address());</pre><pre>    // merge compares dto.version() against the current DB row.<br>    // If someone else saved in the meantime, OptimisticLockException.<br>    return repo.save(order); // save() -&gt; merge() for a detached entity<br>}</pre><p>This is impossible with pessimistic locking — a database row lock cannot survive across HTTP requests, because it dies with the transaction that took it. Any “lock while the user edits” requirement must be solved with optimistic versioning (or an application-level lease), never with FOR UPDATE.</p><h3>Choosing between them</h3><p>Dimension Optimistic Pessimistic Assumption Conflicts rare Conflicts common When detected At flush/commit At read DB cost None until commit Holds row locks for the txn Scalability High (no blocking) Lower (blocking, lock contention) Failure mode OptimisticLockException → retry Blocking, timeout, deadlock Cross-request safe Yes (version stored) No (locks die with the txn) Best for Web apps, REST, long think-time Short hot-row transactions, queues</p><p><strong>Default to optimistic.</strong> It scales, it doesn’t hold database locks, and it works across stateless HTTP requests (a user can sit on an edit form for 10 minutes; the version travels in the payload and the conflict is caught on submit). Reach for pessimistic locking only when:</p><ul><li>Contention on the same rows is genuinely high and retries would storm.</li><li>The work is short and inside a single transaction (you can’t hold a DB lock across user think-time anyway).</li><li>You’re implementing a job queue and want FOR UPDATE SKIP LOCKED to hand out distinct rows to workers.</li></ul><p>A useful hybrid: keep @Version on every entity as a cheap safety net, and add pessimistic locks only on the specific hot paths that need them.</p><h3>Queue patterns: SKIP LOCKED</h3><p>A specialized but increasingly common use of pessimistic locking is building a job queue directly on a relational table. Multiple workers poll the same table, and you want each worker to grab a <em>different</em> unclaimed row without blocking on its peers. Plain FOR UPDATE would make workers queue behind each other; FOR UPDATE SKIP LOCKED tells the database to skip rows another transaction already locked and return the next available ones.</p><pre>@Query(value = &quot;&quot;&quot;<br>        SELECT * FROM job<br>        WHERE status = &#39;PENDING&#39;<br>        ORDER BY created_at<br>        LIMIT :batch<br>        FOR UPDATE SKIP LOCKED<br>        &quot;&quot;&quot;, nativeQuery = true)<br>List&lt;Job&gt; claimBatch(@Param(&quot;batch&quot;) int batch);</pre><pre>@Transactional<br>public void process() {<br>    List&lt;Job&gt; jobs = repo.claimBatch(10); // each worker gets distinct rows<br>    for (Job j : jobs) {<br>        j.setStatus(&quot;RUNNING&quot;);<br>        // ... do the work; commit releases the locks and persists status<br>    }<br>}</pre><p>SKIP LOCKED is supported by PostgreSQL, MySQL 8+, and Oracle. It gives you concurrent, contention-free work distribution without a dedicated message broker for modest volumes — a genuinely useful pattern that&#39;s pure pessimistic locking applied well. The trade-off is that you&#39;re now running a queue on your primary database; once throughput grows, a purpose-built broker is the better tool.</p><h3>Common pitfalls</h3><ul><li><strong>Setting the </strong><strong>@Version field manually.</strong> It&#39;s owned by the provider. Touching it breaks the optimism check. Treat it as read-only.</li><li><strong>Retrying without re-reading.</strong> Catching OptimisticLockException and retrying with the same stale, detached entity fails forever. Each retry needs a new transaction and a fresh load.</li><li><strong>Self-invocation defeating </strong><strong>@Retryable/</strong><strong>@Transactional.</strong> Calling the annotated method from the same class bypasses the Spring proxy — no retry, no transaction. Call across beans.</li><li><strong>Holding pessimistic locks across user interaction.</strong> A FOR UPDATE lock lives only inside one transaction. You cannot lock a row while a user fills out a form. Use optimistic locking for that.</li><li><strong>Ignoring lock ordering → deadlocks.</strong> Any code path that locks more than one row must lock in a deterministic global order.</li><li><strong>No lock timeout.</strong> Unbounded FOR UPDATE waits exhaust the connection pool under load. Always set jakarta.persistence.lock.timeout.</li><li><strong>Assuming </strong><strong>PESSIMISTIC_READ is portable.</strong> Many databases promote it to an exclusive lock. Test against your actual DB.</li><li><strong>Optimistic version not propagated to the client.</strong> For stateless edit flows the version must round-trip to the browser and back; otherwise you can’t detect the conflict you designed for.</li><li><strong>Catching the wrong exception.</strong> Hibernate throws OptimisticLockException; Spring repositories surface ObjectOptimisticLockingFailureException (a subtype of OptimisticLockingFailureException). Catch the Spring type in service code.</li></ul><h3>Wrapping up</h3><p>Concurrency control isn’t optional the moment two requests can touch the same row. Optimistic locking with @Version is the right default for almost every web application: it&#39;s lock-free, scales beautifully, survives stateless requests, and turns conflicts into a clean retry. Pessimistic locking is the heavier instrument — reserve it for short, hot-row transactions where contention is real, and respect its hazards: timeouts and deadlocks.</p><p>The best systems use both: @Version everywhere as a safety net, PESSIMISTIC_WRITE surgically where the data tells you to. Measure your conflict rates, and let the numbers — not the demo — decide.</p><p>Found this useful? Follow <strong>devdomain</strong> for more deep dives on JPA, Hibernate, and Spring Boot in production. Have a deadlock horror story or a retry pattern you swear by? Tell me in the comments.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f113f36c0592" width="1" height="1" alt=""><hr><p><a href="https://medium.com/devdomain/optimistic-vs-pessimistic-locking-in-jpatwo-users-open-the-same-order-f113f36c0592">Optimistic vs Pessimistic Locking in JPATwo users open the same order.</a> was originally published in <a href="https://medium.com/devdomain">devdomain</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Domain-Driven Design in Practice with Java and SpringDomain-Driven Design has a reputation problem.]]></title>
            <link>https://medium.com/devdomain/domain-driven-design-in-practice-with-java-and-springdomain-driven-design-has-a-reputation-problem-ee83e972fb2d?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/ee83e972fb2d</guid>
            <category><![CDATA[domain-driven-design]]></category>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[ddd]]></category>
            <category><![CDATA[java]]></category>
            <category><![CDATA[spring-boot]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Tue, 13 Oct 2026 15:51:01 GMT</pubDate>
            <atom:updated>2026-10-13T15:51:01.497Z</atom:updated>
            <content:encoded><![CDATA[<p>Domain-Driven Design in Practice with Java and SpringDomain-Driven Design has a reputation problem. To some it’s a 500-page Eric Evans book they never finished; to others it’s an excuse to add the word “aggregate” to standup. The truth is more useful: DDD is a set of techniques for <strong>building software whose structure mirrors the business, so that complexity stays manageable as the system grows.</strong></p><p>This article skips the philosophy and gets practical. We’ll build the tactical patterns — entities, value objects, aggregates, repositories, domain services — in real Java 21 / Spring Boot 3.x code, then zoom out to the strategic side: bounded contexts, the ubiquitous language, and anti-corruption layers. We’ll be honest about when DDD is overkill, and the traps that turn “DDD” into expensive ceremony.</p><h3>The Real Problem DDD Solves</h3><p>Software complexity comes in two flavors. <em>Technical</em> complexity (concurrency, caching, scaling) we have good tools for. <em>Domain</em> complexity — the tangle of rules about how a loan gets approved, when an order can be cancelled, how a subscription prorates — is what actually sinks most enterprise projects.</p><p>The failure mode is familiar: the code and the conversation drift apart. The business says “fulfillment,” the code says OrderProcessorServiceImpl. A rule that &quot;a shipment can&#39;t leave without a customs declaration for cross-border orders&quot; is buried in a 600-line service behind three boolean flags. DDD&#39;s core bet is that <strong>if the code speaks the same language as the domain experts, and is organized around domain concepts rather than technical layers, you can keep the complexity from compounding.</strong></p><h3>The Ubiquitous Language</h3><p>Everything in DDD starts here. The <em>ubiquitous language</em> is a shared, precise vocabulary used by developers and domain experts alike — in conversation, in documentation, and <em>in the code itself</em>. If the underwriters say “policy,” “premium,” and “endorsement,” then your classes are Policy, Premium, and Endorsement — not InsuranceRecord, Cost, and Amendment.</p><p>This sounds trivial. It is not. The language is built through conversation with domain experts and refined constantly. When a developer renames endorsement to update because it&#39;s &quot;simpler,&quot; they&#39;ve introduced a translation gap that every future conversation has to pay for.</p><p>The language is <strong>bounded</strong> — it’s only consistent <em>within a context</em>. “Customer” in the Sales context (a lead with a pipeline stage) is a different thing from “Customer” in the Billing context (an account with payment terms). That observation leads directly to bounded contexts, which we’ll get to.</p><h3>Tactical Patterns</h3><h3>Value Objects</h3><p>A <em>value object</em> has no identity — it’s defined entirely by its attributes. Two Money instances of €10 are interchangeable. Value objects should be <strong>immutable</strong> and <strong>self-validating</strong>. Java records are made for this:</p><pre>public record Money(BigDecimal amount, Currency currency) {</pre><pre>    public Money {<br>        Objects.requireNonNull(amount);<br>        Objects.requireNonNull(currency);<br>        if (amount.scale() &gt; currency.getDefaultFractionDigits()) {<br>            throw new IllegalArgumentException(&quot;Too many decimal places for &quot; + currency);<br>        }<br>    }</pre><pre>    public Money add(Money other) {<br>        requireSameCurrency(other);<br>        return new Money(amount.add(other.amount), currency);<br>    }</pre><pre>    public Money multiply(int qty) {<br>        return new Money(amount.multiply(BigDecimal.valueOf(qty)), currency);<br>    }</pre><pre>    private void requireSameCurrency(Money other) {<br>        if (!currency.equals(other.currency)) {<br>            throw new IllegalArgumentException(&quot;Currency mismatch: &quot; + currency + &quot; vs &quot; + other.currency);<br>        }<br>    }<br>}</pre><p>Value objects are where a huge amount of domain logic <em>should</em> live but usually doesn’t. An EmailAddress, a PhoneNumber, a DateRange, a Quantity — each can validate itself and expose meaningful operations, replacing a swamp of primitive Strings and ints. This is the antidote to <em>primitive obsession</em>.</p><pre>public record DateRange(LocalDate start, LocalDate end) {<br>    public DateRange {<br>        if (end.isBefore(start)) {<br>            throw new IllegalArgumentException(&quot;end before start&quot;);<br>        }<br>    }<br>    public boolean overlaps(DateRange other) {<br>        return !start.isAfter(other.end) &amp;&amp; !other.start.isAfter(end);<br>    }<br>    public long days() { return ChronoUnit.DAYS.between(start, end); }<br>}</pre><h3>Entities</h3><p>An <em>entity</em> has identity that persists through changes of state. A Customer whose email changes is still the same customer. Entities are identified by an ID, not by their attributes, and equality is based on that ID:</p><pre>public class Customer {</pre><pre>    private final CustomerId id;        // identity<br>    private CustomerName name;          // value object<br>    private EmailAddress email;         // value object<br>    private CustomerStatus status;</pre><pre>    public Customer(CustomerId id, CustomerName name, EmailAddress email) {<br>        this.id = Objects.requireNonNull(id);<br>        this.name = Objects.requireNonNull(name);<br>        this.email = Objects.requireNonNull(email);<br>        this.status = CustomerStatus.ACTIVE;<br>    }</pre><pre>    public void changeEmail(EmailAddress newEmail) {<br>        if (status == CustomerStatus.CLOSED) {<br>            throw new IllegalStateException(&quot;Cannot change email on a closed account&quot;);<br>        }<br>        this.email = newEmail;<br>    }</pre><pre>    @Override public boolean equals(Object o) {<br>        return o instanceof Customer c &amp;&amp; id.equals(c.id);<br>    }<br>    @Override public int hashCode() { return id.hashCode(); }<br>}</pre><p>The strongly-typed CustomerId (a record wrapping a UUID) is deliberate — it makes transfer(fromId, toId) impossible to call with the arguments swapped, a class of bug raw UUID parameters invite.</p><h3>Aggregates — the most important and most misunderstood pattern</h3><p>An <em>aggregate</em> is a cluster of entities and value objects treated as a single unit for data changes. It has one <strong>aggregate root</strong> — the only member outside code is allowed to hold a reference to. All modifications go <em>through</em> the root, which enforces the aggregate’s invariants.</p><p>Consider an Order with OrderLines. The rule &quot;an order&#39;s total cannot exceed the customer&#39;s credit limit&quot; is an invariant that spans lines. The root enforces it:</p><pre>public class Order {                          // aggregate root</pre><pre>    private final OrderId id;<br>    private final CustomerId customerId;<br>    private final List&lt;OrderLine&gt; lines = new ArrayList&lt;&gt;();<br>    private OrderStatus status = OrderStatus.DRAFT;</pre><pre>    public Order(OrderId id, CustomerId customerId) {<br>        this.id = id;<br>        this.customerId = customerId;<br>    }</pre><pre>    public void addLine(ProductId product, Money unitPrice, int quantity) {<br>        if (status != OrderStatus.DRAFT) {<br>            throw new IllegalStateException(&quot;Cannot modify a placed order&quot;);<br>        }<br>        lines.add(new OrderLine(product, unitPrice, quantity));<br>    }</pre><pre>    public void place(CreditLimit limit) {<br>        if (lines.isEmpty()) throw new IllegalStateException(&quot;Empty order&quot;);<br>        if (total().isGreaterThan(limit.available())) {<br>            throw new CreditLimitExceededException(id, total());<br>        }<br>        this.status = OrderStatus.PLACED;<br>    }</pre><pre>    public Money total() {<br>        return lines.stream().map(OrderLine::subtotal)<br>                    .reduce(Money.zero(), Money::add);<br>    }</pre><pre>    // No public getter exposing the mutable list:<br>    public List&lt;OrderLine&gt; lines() { return List.copyOf(lines); }<br>}</pre><p>Two rules of thumb that prevent most aggregate mistakes:</p><ol><li><strong>Reference other aggregates by ID, not by object.</strong> Order holds a CustomerId, not a Customer. This keeps aggregates small, transaction boundaries clean, and prevents loading half the database.</li><li><strong>One transaction = one aggregate.</strong> A single use case should modify one aggregate. Need to update two? Use eventual consistency (a domain event) rather than spanning both in one transaction.</li></ol><p>Entity Value Object Aggregate Has identity? Yes No Root does Mutable? Yes (controlled) No Via root only Equality by ID Attributes Root ID Lifespan Tracked Replaced wholesale Tracked as a unit Java fit Class record Class (root) + members</p><h3>Repositories</h3><p>A <em>repository</em> provides collection-like access to aggregates. Crucially, <strong>one repository per aggregate root</strong> — you do not get a repository for OrderLine, because lines only exist within an order. The repository interface belongs to the <strong>domain</strong>; its implementation lives in infrastructure.</p><pre>// domain layer — the contract, in domain terms<br>public interface OrderRepository {<br>    Optional&lt;Order&gt; findById(OrderId id);<br>    void save(Order order);<br>    List&lt;Order&gt; findPlacedOrdersFor(CustomerId customerId);<br>}</pre><pre>// infrastructure layer — Spring Data JPA backed implementation<br>@Repository<br>class JpaOrderRepository implements OrderRepository {</pre><pre>    private final SpringDataOrderRepository jpa;<br>    private final OrderMapper mapper;</pre><pre>    JpaOrderRepository(SpringDataOrderRepository jpa, OrderMapper mapper) {<br>        this.jpa = jpa; this.mapper = mapper;<br>    }</pre><pre>    @Override public Optional&lt;Order&gt; findById(OrderId id) {<br>        return jpa.findById(id.value()).map(mapper::toDomain);<br>    }<br>    @Override public void save(Order order) {<br>        jpa.save(mapper.toEntity(order));<br>    }<br>    @Override public List&lt;Order&gt; findPlacedOrdersFor(CustomerId c) {<br>        return jpa.findByCustomerIdAndStatus(c.value(), &quot;PLACED&quot;)<br>                  .stream().map(mapper::toDomain).toList();<br>    }<br>}</pre><p>Yes, you can let a JPA entity <em>be</em> the domain object and skip the mapper — Spring Data makes it tempting. But the moment Hibernate’s lazy loading, dirty checking, or @Version semantics dictate how you model behavior, the domain is no longer in charge. For rich domains, keep them separate.</p><h3>Domain Services</h3><p>Some logic doesn’t belong to any single entity or Value object. A funds transfer involves two accounts; a “find the best shipping rate” decision spans carriers and rules. That’s a <em>domain service</em> — stateless, named in the ubiquitous language, operating on domain objects:</p><pre>public class MoneyTransferService {   // domain service, pure, no @Service needed</pre><pre>    public void transfer(Account from, Account to, Money amount) {<br>        from.withdraw(amount);   // entities still enforce their own invariants<br>        to.deposit(amount);<br>    }<br>}</pre><p>Don’t confuse a <em>domain service</em> (business logic that doesn’t fit one entity, framework-free) with an <em>application service</em> (Spring @Service that orchestrates a use case, handles transactions, and calls repositories). The application service is the conductor; the domain service is one of the instruments.</p><pre>@Service<br>public class TransferFundsApplicationService {</pre><pre>    private final AccountRepository accounts;<br>    private final MoneyTransferService transferService;</pre><pre>    // constructor omitted</pre><pre>    @Transactional<br>    public void execute(TransferCommand cmd) {<br>        Account from = accounts.findById(cmd.from()).orElseThrow();<br>        Account to   = accounts.findById(cmd.to()).orElseThrow();<br>        transferService.transfer(from, to, cmd.amount());<br>        accounts.save(from);<br>        accounts.save(to);<br>    }<br>}</pre><h3>Domain Events</h3><p>When something significant happens in the domain, raise an event. It decouples side effects and is the mechanism for keeping aggregates eventually consistent. Spring’s ApplicationEventPublisher works well, and Spring Data&#39;s AbstractAggregateRoot publishes events on save:</p><pre>public record OrderPlaced(OrderId orderId, CustomerId customerId, Money total, Instant occurredAt) {}</pre><pre>@Component<br>class OrderPlacedListener {<br>    @EventListener<br>    @Async<br>    void on(OrderPlaced event) {<br>        // send confirmation email, update read model, notify warehouse...<br>    }<br>}</pre><h3>Strategic Design</h3><p>Tactical patterns make a single model clean. Strategic design keeps you from trying to build <em>one model for the whole company</em> — which always fails.</p><h3>Bounded Contexts</h3><p>A <em>bounded context</em> is an explicit boundary within which a model and its ubiquitous language are consistent. “Product” in the Catalog context (name, images, description) is a different model from “Product” in the Inventory context (SKU, stock level, location). Forcing them into one shared Product class produces a god object that nobody owns and everybody fights over.</p><pre>┌──────────────────┐   OrderPlaced   ┌────────────────────┐<br>   │  SALES context    │ ──────event───▶ │  FULFILLMENT       │<br>   │  Order, Customer  │                 │  Shipment, Picking │<br>   │  (pipeline view)  │                 │  (logistics view)  │<br>   └──────────────────┘                 └────────────────────┘<br>            │                                      │<br>      2     │  CustomerRegistered                   │<br>            ▼                                      ▼<br>   ┌──────────────────┐                 ┌────────────────────┐<br>   │  BILLING context  │                 │  INVENTORY         │<br>   │  Account, Invoice │                 │  StockItem         │<br>   └──────────────────┘                 └────────────────────┘</pre><p>In Spring, a bounded context typically maps to a module (Gradle subproject or Maven module), and often — but not always — to a microservice. The <em>context map</em> documents how they relate: shared kernel, customer/supplier, conformist, or (the one you’ll reach for most) an anti-corruption layer.</p><h3>Anti-Corruption Layer (ACL)</h3><p>When your context integrates with a legacy system or a third-party API whose model is messy or just <em>different</em>, you build an <strong>anti-corruption layer</strong>: a translation boundary that converts the foreign model into <em>your</em> ubiquitous language, so the outside mess never leaks into your domain.</p><pre>// Your domain speaks &quot;Customer&quot;. The legacy CRM speaks &quot;CUST_REC&quot;.<br>public interface CustomerLookup {                 // port, in your language<br>    Optional&lt;Customer&gt; findByEmail(EmailAddress email);<br>}</pre><pre>@Component<br>class LegacyCrmAdapter implements CustomerLookup { // the ACL</pre><pre>    private final LegacyCrmClient client;</pre><pre>    @Override public Optional&lt;Customer&gt; findByEmail(EmailAddress email) {<br>        CustRec rec = client.lookup(email.value());          // foreign model<br>        if (rec == null) return Optional.empty();<br>        return Optional.of(translate(rec));                  // -&gt; your model<br>    }</pre><pre>    private Customer translate(CustRec rec) {                // the corruption stops here<br>        return new Customer(<br>            new CustomerId(UUID.fromString(rec.getGuid())),<br>            new CustomerName(rec.getFName(), rec.getLName()),<br>            new EmailAddress(rec.getEmailAddr())<br>        );<br>    }<br>}</pre><p>The ACL is one of the highest-leverage patterns in DDD: it lets a clean new context coexist with a legacy system without inheriting its sins.</p><h3>When DDD Is Overkill</h3><p>DDD has real costs — more code, more abstraction, more upfront modeling and conversation. Skip it when:</p><ul><li><strong>The domain is simple.</strong> A CRUD app over a few tables with no interesting rules doesn’t need aggregates. Spring Data over JPA entities is the right amount of architecture.</li><li><strong>Logic is mostly technical, not domain.</strong> A file-conversion pipeline or a metrics collector has little “business” to model.</li><li><strong>The team won’t invest in the language.</strong> DDD without ongoing domain-expert collaboration is just unusual package names. The strategic value evaporates.</li><li><strong>Short-lived projects.</strong> The payoff is long-term maintainability; a three-week prototype won’t live long enough to collect it.</li></ul><p>Apply DDD where complexity lives — the <em>core domain</em> that differentiates the business — and use simpler approaches for <em>supporting</em> and <em>generic</em> subdomains (like authentication or PDF generation, which you might buy or build trivially).</p><h3>Common Pitfalls</h3><ul><li><strong>Anemic domain model.</strong> Entities with only getters/setters and all logic in services. This is the most common “DDD” anti-pattern — it has the package names but none of the benefits. Behavior belongs <em>with</em> data.</li><li><strong>Aggregates that are too big.</strong> Loading a Customer that drags in every order, payment, and address makes transactions slow and contention high. Keep aggregates small; reference by ID.</li><li><strong>One model to rule them all.</strong> Refusing to split bounded contexts leads to a shared Customer god class. Embrace duplication across contexts — it&#39;s healthier than coupling.</li><li><strong>Tactical patterns without strategic design.</strong> Sprinkling value objects everywhere while ignoring context boundaries gets you tidy classes inside one giant tangled model.</li><li><strong>Letting persistence dictate the model.</strong> When @Entity, @ManyToOne, and fetch strategies drive your design, JPA is in charge, not the domain.</li><li><strong>DDD on the wrong subdomain.</strong> Lavishing modeling effort on a generic subdomain (auth, logging) while neglecting the core domain inverts the value.</li></ul><h3>Closing Thoughts</h3><p>DDD is not a framework you install; it’s a way of letting the business shape the software. Start with the ubiquitous language and a clear bounded context. Make your value objects immutable and your aggregates small. Keep the domain free of framework concerns, and wrap legacy integrations in an anti-corruption layer. Apply it where complexity earns it, and stay pragmatic everywhere else. Get this right and your code becomes a model of the business that both engineers and experts can read — which is the entire point.</p><p>Enjoyed this? Follow <strong>devdomain</strong> for more practical architecture and Java deep-dives. How do you draw the line between a domain service and an application service in your projects? Let me know in the comments.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ee83e972fb2d" width="1" height="1" alt=""><hr><p><a href="https://medium.com/devdomain/domain-driven-design-in-practice-with-java-and-springdomain-driven-design-has-a-reputation-problem-ee83e972fb2d">Domain-Driven Design in Practice with Java and SpringDomain-Driven Design has a reputation problem.</a> was originally published in <a href="https://medium.com/devdomain">devdomain</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Spring AOP Explained: Aspects, Pointcuts, and How Proxies Really WorkYou add @Transactional to a…]]></title>
            <link>https://medium.com/devdomain/spring-aop-explained-aspects-pointcuts-and-how-proxies-really-workyou-add-transactional-to-a-16d09b80ca1c?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/16d09b80ca1c</guid>
            <category><![CDATA[java]]></category>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[proxy]]></category>
            <category><![CDATA[spring-aop]]></category>
            <category><![CDATA[aspectj]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Thu, 08 Oct 2026 15:26:01 GMT</pubDate>
            <atom:updated>2026-10-08T15:26:01.394Z</atom:updated>
            <content:encoded><![CDATA[<p>Spring AOP Explained: Aspects, Pointcuts, and How Proxies Really WorkYou add @Transactional to a method. Sometimes the transaction works perfectly. Sometimes — apparently identical code — it silently does nothing and you spend an afternoon staring at it. The difference is almost never the annotation. It&#39;s the <strong>proxy</strong>.</p><p>Spring AOP (Aspect-Oriented Programming) lets you inject cross-cutting behavior — transactions, security, logging, metrics, retries — <em>around</em> your business methods without polluting them. @Transactional, @Cacheable, @Async, @PreAuthorize, and @Retryable are all built on it. Understanding how it works under the hood is the single highest-leverage piece of Spring knowledge, because it explains an entire class of &quot;why didn&#39;t my annotation fire?&quot; bugs.</p><p>This article covers the proxy mechanism (JDK dynamic proxies vs CGLIB), the @Aspect programming model, pointcut expressions, the notorious self-invocation limitation, and production-ready use cases.</p><h3>The core idea: behavior wrapped around a join point</h3><p>A few terms, precisely:</p><p>Term Meaning <strong>Join point</strong> A point in execution where advice can be applied. In Spring AOP, always a <em>method execution</em>. <strong>Pointcut</strong> An expression selecting which join points match. <strong>Advice</strong> The code that runs at a matched join point (@Before, @After, @Around, etc.). <strong>Aspect</strong> A class bundling pointcuts and advice (@Aspect). <strong>Weaving</strong> Linking aspects into target objects. Spring does this at runtime via proxies. <strong>Target</strong> The original, un-proxied bean.</p><p>The key constraint: <strong>Spring AOP only intercepts method calls that go through the proxy</strong>. It does not rewrite your bytecode (that’s full AspectJ). It wraps your bean in a proxy object and intercepts calls that arrive <em>from outside</em> through that proxy.</p><pre>caller --&gt; [ PROXY ] --&gt; advice (around) --&gt; [ TARGET method ]<br>                              |<br>                              +--&gt; proceed() invokes the real method</pre><h3>Two proxy strategies: JDK dynamic vs CGLIB</h3><p>When Spring decides a bean needs AOP, it creates a proxy. There are two mechanisms:</p><h3>JDK dynamic proxies</h3><p>Used when the bean <strong>implements at least one interface</strong> (historically Spring’s default in that case). The proxy is a runtime-generated class that implements the same interfaces and delegates to an InvocationHandler.</p><ul><li>The proxy is type-compatible with the <em>interface</em>, not the concrete class.</li><li>You must inject the <strong>interface</strong>, not the implementation, or injection fails with a ClassCastException-style error.</li></ul><h3>CGLIB proxies</h3><p>Used when there is <strong>no interface</strong>, or when forced. CGLIB generates a <strong>subclass</strong> of your target class at runtime and overrides methods to insert advice.</p><ul><li>Works on concrete classes.</li><li>Cannot proxy final classes or final/private/static methods (it can&#39;t override them).</li><li>Requires a non-arg constructor reachable by the subclass (modern Spring uses Objenesis to sidestep this in most cases).</li></ul><p>Spring Boot <strong>defaults to CGLIB</strong> (spring.aop.proxy-target-class=true) so behavior is consistent whether or not your bean has an interface. You can flip to JDK proxies with proxy-target-class=false.</p><pre>+-------------------+-----------------------------+--------------------------+<br>| Aspect            | JDK Dynamic Proxy           | CGLIB Proxy              |<br>+-------------------+-----------------------------+--------------------------+<br>| Requires interface| Yes                         | No                       |<br>| Proxy is a...     | new class impl. interface   | subclass of target       |<br>| final classes     | N/A                         | Cannot proxy             |<br>| final/private mtd | N/A                         | Not intercepted          |<br>| Inject as         | interface type              | class or interface type  |<br>+-------------------+-----------------------------+--------------------------+</pre><p>The practical takeaway: with CGLIB, a final method silently won&#39;t be advised — no error, just no transaction/log/retry. That alone explains many &quot;annotation does nothing&quot; reports.</p><h3>Writing your first aspect</h3><p>Enable AOP (Spring Boot auto-enables it when spring-boot-starter-aop is on the classpath; otherwise add @EnableAspectJAutoProxy). Then declare an aspect bean:</p><pre>import org.aspectj.lang.ProceedingJoinPoint;<br>import org.aspectj.lang.annotation.Around;<br>import org.aspectj.lang.annotation.Aspect;<br>import org.aspectj.lang.annotation.Pointcut;<br>import org.slf4j.Logger;<br>import org.slf4j.LoggerFactory;<br>import org.springframework.stereotype.Component;</pre><pre>@Aspect<br>@Component<br>public class LoggingAspect {</pre><pre>    private static final Logger log = LoggerFactory.getLogger(LoggingAspect.class);</pre><pre>    // Reusable named pointcut: all public methods in the service package<br>    @Pointcut(&quot;execution(public * com.example.app.service..*(..))&quot;)<br>    void serviceMethods() {}</pre><pre>    @Around(&quot;serviceMethods()&quot;)<br>    public Object logExecution(ProceedingJoinPoint pjp) throws Throwable {<br>        long start = System.nanoTime();<br>        String sig = pjp.getSignature().toShortString();<br>        try {<br>            Object result = pjp.proceed(); // invokes the target method<br>            long ms = (System.nanoTime() - start) / 1_000_000;<br>            log.info(&quot;{} completed in {} ms&quot;, sig, ms);<br>            return result;<br>        } catch (Throwable ex) {<br>            log.warn(&quot;{} threw {}&quot;, sig, ex.getClass().getSimpleName());<br>            throw ex; // never swallow unless intentional<br>        }<br>    }<br>}</pre><p>ProceedingJoinPoint.proceed() is the linchpin: nothing downstream runs unless you call it. Forgetting proceed() in an @Around advice silently disables the entire target method — a brutal bug.</p><h3>The advice types</h3><p>Advice When it runs Can change result? Can prevent target? @Before Before the method No No (only by throwing) @After After (finally semantics) No No @AfterReturning After normal return Can read return value No @AfterThrowing After an exception No (can rethrow) No @Around Wraps the call Yes (controls proceed) Yes</p><p>@Around is the most powerful — it can short-circuit, retry, transform arguments and results — but also the easiest to misuse. Prefer the narrower advice types when they suffice.</p><pre>@AfterReturning(pointcut = &quot;serviceMethods()&quot;, returning = &quot;result&quot;)<br>public void onReturn(Object result) {<br>    log.debug(&quot;returned: {}&quot;, result);<br>}</pre><pre>@AfterThrowing(pointcut = &quot;serviceMethods()&quot;, throwing = &quot;ex&quot;)<br>public void onError(Exception ex) {<br>    log.error(&quot;failure&quot;, ex);<br>}</pre><h3>Pointcut expressions: the AspectJ mini-language</h3><p>Spring uses a subset of AspectJ’s pointcut syntax. The most common designators:</p><ul><li><strong>execution(...)</strong> — match by method signature. The workhorse.</li><li><strong>within(...)</strong> — match all join points within certain types.</li><li><strong>@annotation(...)</strong> — match methods carrying a specific annotation.</li><li><strong>@within(...)</strong> — match methods of classes carrying an annotation.</li><li><strong>args(...)</strong> / <strong>@args(...)</strong> — match by argument types/annotations.</li><li><strong>bean(name)</strong> — match by Spring bean name (Spring-specific).</li></ul><p>execution syntax:</p><pre>execution(modifiers? return-type declaring-type?.method-name(params) throws?)</pre><p>Examples:</p><pre>// any return type, any class, method named save, any args<br>@Pointcut(&quot;execution(* *..save(..))&quot;)</pre><pre>// public methods returning String in OrderService<br>@Pointcut(&quot;execution(public String com.example.OrderService.*(..))&quot;)</pre><pre>// methods taking a Long as the first arg<br>@Pointcut(&quot;execution(* com.example..*(Long, ..))&quot;)</pre><pre>// combine: service methods that are NOT in the audit package<br>@Pointcut(&quot;serviceMethods() &amp;&amp; !within(com.example.app.service.audit..*)&quot;)</pre><h3>Annotation-driven pointcuts (the practical pattern)</h3><p>For reusable cross-cutting behavior, define a custom annotation and match on it. This is exactly how Spring’s own @Transactional is wired.</p><pre>import java.lang.annotation.*;</pre><pre>@Target(ElementType.METHOD)<br>@Retention(RetentionPolicy.RUNTIME)<br>public @interface Timed {<br>    String value() default &quot;&quot;;<br>}</pre><pre>@Around(&quot;@annotation(timed)&quot;)<br>public Object measure(ProceedingJoinPoint pjp, Timed timed) throws Throwable {<br>    String name = timed.value().isBlank()<br>        ? pjp.getSignature().toShortString() : timed.value();<br>    long start = System.nanoTime();<br>    try {<br>        return pjp.proceed();<br>    } finally {<br>        long ms = (System.nanoTime() - start) / 1_000_000;<br>        log.info(&quot;metric[{}] = {} ms&quot;, name, ms);<br>    }<br>}</pre><p>Binding timed as a parameter both filters the pointcut and gives the advice access to the annotation&#39;s attributes.</p><h3>The self-invocation limitation (the #1 gotcha)</h3><p>Because Spring AOP works through a wrapping proxy, <strong>only calls that arrive through the proxy are intercepted</strong>. A method calling another method <em>on the same object</em> uses the raw this reference — which is the target, not the proxy. The advice is bypassed.</p><pre>@Service<br>public class ReportService {</pre><pre>    @Transactional<br>    public void generateAll() {<br>        // BUG: this.generateOne(...) bypasses the proxy.<br>        // The @Transactional below does NOT start a new transaction.<br>        generateOne(1L);<br>    }</pre><pre>    @Transactional(propagation = Propagation.REQUIRES_NEW)<br>    public void generateOne(Long id) {<br>        // ...<br>    }<br>}</pre><p>generateAll calls generateOne via this, so REQUIRES_NEW is ignored. The same trap kills @Async, @Cacheable, @Retryable, and @PreAuthorize when invoked internally.</p><h3>Fixes</h3><ol><li><strong>Move the annotated method to a separate bean</strong> (cleanest). Inject the other bean and call through it.</li><li><strong>Self-inject the proxy</strong> and call through it:</li></ol><pre>@Service<br>public class ReportService {</pre><pre>    private final ReportService self; // the proxy</pre><pre>    public ReportService(@Lazy ReportService self) {<br>        this.self = self; // @Lazy breaks the circular dependency<br>    }</pre><pre>    @Transactional<br>    public void generateAll() {<br>        self.generateOne(1L); // goes through the proxy -&gt; advice applies<br>    }</pre><pre>    @Transactional(propagation = Propagation.REQUIRES_NEW)<br>    public void generateOne(Long id) { ... }<br>}</pre><ol><li><strong>Use </strong><strong>AopContext.currentProxy()</strong> (requires @EnableAspectJAutoProxy(exposeProxy = true)):</li></ol><pre>((ReportService) AopContext.currentProxy()).generateOne(1L);</pre><p>Option 1 is almost always the right answer. Options 2 and 3 are escape hatches that signal a design that wants splitting.</p><h3>Aspect ordering</h3><p>When multiple aspects match the same join point, ordering matters (e.g. you want logging <em>outside</em> the transaction, or retry <em>outside</em> metrics). Control it with @Order or Ordered:</p><pre>@Aspect<br>@Component<br>@Order(1) // lower value = higher priority = outermost<br>public class RetryAspect { ... }</pre><pre>@Aspect<br>@Component<br>@Order(2)<br>public class MetricsAspect { ... }</pre><p>The wrapping forms nested layers: Order(1) wraps Order(2) wraps the target. Note that Spring&#39;s transaction advice has a default order of Ordered.LOWEST_PRECEDENCE, so your custom aspects typically run <em>outside</em> the transaction unless you say otherwise.</p><h3>A real, production-grade retry aspect</h3><p>Cross-cutting retry is a textbook AOP use case. Here is a self-contained implementation (no Resilience4j dependency):</p><pre>import java.lang.annotation.*;</pre><pre>@Target(ElementType.METHOD)<br>@Retention(RetentionPolicy.RUNTIME)<br>public @interface Retry {<br>    int maxAttempts() default 3;<br>    long backoffMs() default 200;<br>    Class&lt;? extends Throwable&gt;[] on() default { Exception.class };<br>}</pre><pre>import org.aspectj.lang.ProceedingJoinPoint;<br>import org.aspectj.lang.annotation.Around;<br>import org.aspectj.lang.annotation.Aspect;<br>import org.springframework.core.annotation.Order;<br>import org.springframework.stereotype.Component;</pre><pre>@Aspect<br>@Component<br>@Order(0) // outermost: retry wraps everything else<br>public class RetryAspect {</pre><pre>    @Around(&quot;@annotation(retry)&quot;)<br>    public Object retry(ProceedingJoinPoint pjp, Retry retry) throws Throwable {<br>        int attempts = 0;<br>        Throwable last = null;<br>        while (attempts &lt; retry.maxAttempts()) {<br>            attempts++;<br>            try {<br>                return pjp.proceed();<br>            } catch (Throwable ex) {<br>                if (!isRetryable(ex, retry.on()) || attempts &gt;= retry.maxAttempts()) {<br>                    throw ex;<br>                }<br>                last = ex;<br>                Thread.sleep(retry.backoffMs() * attempts); // linear backoff<br>            }<br>        }<br>        throw last; // unreachable, but satisfies the compiler<br>    }</pre><pre>    private boolean isRetryable(Throwable ex, Class&lt;? extends Throwable&gt;[] types) {<br>        for (Class&lt;? extends Throwable&gt; t : types) {<br>            if (t.isInstance(ex)) return true;<br>        }<br>        return false;<br>    }<br>}</pre><p>Usage:</p><pre>@Retry(maxAttempts = 4, backoffMs = 250, on = { IOException.class })<br>public PaymentResult charge(PaymentRequest req) { ... }</pre><p>Remember: this only fires when charge is called from <em>another</em> bean. Internal self-calls bypass it — the lesson repeats because the bug repeats.</p><h3>Accessing context inside advice</h3><p>Advice often needs to inspect the method, its arguments, or its annotations. The JoinPoint API exposes all of it:</p><pre>@Before(&quot;serviceMethods()&quot;)<br>public void audit(org.aspectj.lang.JoinPoint jp) {<br>    String method = jp.getSignature().getName();          // method name<br>    Object target = jp.getTarget();                        // the un-proxied bean<br>    Object[] args = jp.getArgs();                          // actual arguments<br>    Class&lt;?&gt; type = jp.getSignature().getDeclaringType();  // declaring class<br>    log.debug(&quot;calling {}.{} with {} args&quot;, type.getSimpleName(), method, args.length);<br>}</pre><p>For @Around, ProceedingJoinPoint extends JoinPoint and adds proceed() plus proceed(Object[] newArgs) — the latter lets you <em>rewrite</em> arguments before the target runs, which is how features like input sanitization or masking aspects work:</p><pre>@Around(&quot;@annotation(MaskPii)&quot;)<br>public Object mask(ProceedingJoinPoint pjp) throws Throwable {<br>    Object[] args = pjp.getArgs();<br>    if (args.length &gt; 0 &amp;&amp; args[0] instanceof String s) {<br>        args[0] = s.replaceAll(&quot;(?&lt;=.{2}).(?=.*@)&quot;, &quot;*&quot;); // crude email mask<br>    }<br>    return pjp.proceed(args); // proceed with modified args<br>}</pre><p>This power is also a warning: an aspect that silently rewrites arguments or results is hard to debug because the behavior lives outside the method. Use argument rewriting sparingly and document it loudly.</p><h3>A metrics aspect with Micrometer</h3><p>A second canonical use case is timing. Rather than littering Timer.Sample calls through your services, capture them in one aspect bound to a custom annotation:</p><pre>import io.micrometer.core.instrument.MeterRegistry;<br>import io.micrometer.core.instrument.Timer;<br>import org.aspectj.lang.ProceedingJoinPoint;<br>import org.aspectj.lang.annotation.Around;<br>import org.aspectj.lang.annotation.Aspect;<br>import org.springframework.stereotype.Component;</pre><pre>@Aspect<br>@Component<br>public class MetricsAspect {</pre><pre>    private final MeterRegistry registry;</pre><pre>    public MetricsAspect(MeterRegistry registry) {<br>        this.registry = registry;<br>    }</pre><pre>    @Around(&quot;@annotation(Timed)&quot;)<br>    public Object record(ProceedingJoinPoint pjp) throws Throwable {<br>        Timer.Sample sample = Timer.start(registry);<br>        String name = pjp.getSignature().toShortString();<br>        String outcome = &quot;success&quot;;<br>        try {<br>            return pjp.proceed();<br>        } catch (Throwable t) {<br>            outcome = &quot;error&quot;;<br>            throw t;<br>        } finally {<br>            sample.stop(registry.timer(&quot;method.timed&quot;,<br>                &quot;method&quot;, name, &quot;outcome&quot;, outcome));<br>        }<br>    }<br>}</pre><p>The same pattern underpins Spring’s own @Timed support in spring-boot-actuator — it&#39;s aspects all the way down.</p><h3>Spring AOP vs full AspectJ</h3><p>Spring AOP is proxy-based and limited to Spring beans and method execution. <strong>AspectJ</strong> (load-time or compile-time weaving) modifies bytecode and can intercept constructors, field access, and private/internal calls — including self-invocation — because there is no proxy involved.</p><p>Reach for AspectJ only when you genuinely need those capabilities. For 95% of applications, Spring AOP is simpler, has no build-time weaving step, and is plenty. Enable AspectJ weaving via @EnableLoadTimeWeaving and an agent only when the proxy model truly can&#39;t express your need.</p><h3>Common pitfalls and gotchas</h3><ul><li><strong>Self-invocation</strong>: internal calls bypass the proxy. The root cause of most “annotation does nothing” issues.</li><li><strong>final methods/classes with CGLIB</strong>: silently not advised; CGLIB can&#39;t override them.</li><li><strong>private methods are never advised</strong>: pointcuts apply to proxied (effectively public/protected) methods only.</li><li><strong>Forgetting </strong><strong>proceed()</strong> in @Around: the target never runs.</li><li><strong>Swallowing exceptions</strong> in advice: rethrow unless you mean to suppress. Catching and not rethrowing changes method semantics invisibly.</li><li><strong>Injecting the concrete class under JDK proxies</strong>: causes injection failures; inject the interface or force CGLIB.</li><li><strong>Calling AOP-annotated methods from constructors/</strong><strong>@PostConstruct</strong>: the proxy may not be fully wired yet.</li><li><strong>Ordering assumptions</strong>: without explicit @Order, the relative order of multiple aspects is undefined.</li><li><strong>Performance of broad pointcuts</strong>: execution(* *(..)) matches everything and adds overhead to every call. Scope pointcuts tightly.</li></ul><h3>Best practices</h3><ul><li>Prefer custom annotations + @annotation(...) pointcuts over broad execution expressions — they&#39;re explicit and self-documenting.</li><li>Use the narrowest advice type that works; reserve @Around for when you must control proceed().</li><li>Keep aspects free of business logic. They are infrastructure.</li><li>Always set @Order when more than one aspect can match.</li><li>Split classes so annotated methods are called externally, sidestepping self-invocation entirely.</li><li>For resilience features, prefer battle-tested libraries (Resilience4j) over hand-rolled aspects in production; build your own only for simple, well-understood concerns.</li></ul><h3>Conclusion</h3><p>Spring AOP is “just” a proxy wrapping your bean and intercepting external method calls. Every confusing behavior — the dead @Transactional, the ignored @Async, the unhelpful @Cacheable — traces back to that single fact: <strong>if the call doesn&#39;t go through the proxy, the advice doesn&#39;t run.</strong> Master the proxy model, choose CGLIB vs JDK proxies deliberately, write tight annotation-driven pointcuts, and design your beans so cross-cutting methods are always invoked from the outside. Do that, and AOP becomes a precise tool instead of an occasional mystery.</p><p>Found this useful? <strong>Follow devdomain</strong> for more under-the-hood Spring deep dives, and comment with the AOP bug that cost you the most time — I bet it was self-invocation.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=16d09b80ca1c" width="1" height="1" alt=""><hr><p><a href="https://medium.com/devdomain/spring-aop-explained-aspects-pointcuts-and-how-proxies-really-workyou-add-transactional-to-a-16d09b80ca1c">Spring AOP Explained: Aspects, Pointcuts, and How Proxies Really WorkYou add @Transactional to a…</a> was originally published in <a href="https://medium.com/devdomain">devdomain</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[HikariCP Deep Dive: Tuning Your Connection Pool for ProductionThe connection pool is the most…]]></title>
            <description><![CDATA[<div class="medium-feed-item"><p class="medium-feed-snippet">HikariCP is the default connection pool in Spring Boot, and it&#x2019;s fast and reliable &#x2014; but &#x201C;default&#x201D; is not &#x201C;tuned.&#x201D; This deep dive walks&#x2026;</p><p class="medium-feed-link"><a href="https://medium.com/devdomain/hikaricp-deep-dive-tuning-your-connection-pool-for-productionthe-connection-pool-is-the-most-f39b16788836?source=rss----026d10b274d0---4">Continue reading on devdomain »</a></p></div>]]></description>
            <link>https://medium.com/devdomain/hikaricp-deep-dive-tuning-your-connection-pool-for-productionthe-connection-pool-is-the-most-f39b16788836?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/f39b16788836</guid>
            <category><![CDATA[java]]></category>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[hikaricp]]></category>
            <category><![CDATA[performance]]></category>
            <category><![CDATA[database]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Tue, 06 Oct 2026 15:11:01 GMT</pubDate>
            <atom:updated>2026-10-06T15:11:01.692Z</atom:updated>
        </item>
    </channel>
</rss>