<?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>Wed, 07 Oct 2026 19:45:10 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[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>
        <item>
            <title><![CDATA[Designing Idempotent REST APIs: Safe Retries and Exactly-Once EffectsThe network is not your friend.]]></title>
            <link>https://medium.com/devdomain/designing-idempotent-rest-apis-safe-retries-and-exactly-once-effectsthe-network-is-not-your-friend-6ff9106011ec?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/6ff9106011ec</guid>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[distributed-systems]]></category>
            <category><![CDATA[redis]]></category>
            <category><![CDATA[rest-api]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Thu, 01 Oct 2026 14:56:01 GMT</pubDate>
            <atom:updated>2026-10-01T14:56:01.607Z</atom:updated>
            <content:encoded><![CDATA[<p>Designing Idempotent REST APIs: Safe Retries and Exactly-Once EffectsThe network is not your friend. Every HTTP call that crosses a wire can succeed on the server and still fail on the client. A request times out, a load balancer drops a connection, a mobile client loses signal halfway through a TLS handshake — and now your caller has no idea whether the payment went through, the order was placed, or nothing happened at all.</p><p>The naive answer is “retry.” The dangerous truth is that retrying a non-idempotent operation can charge a card twice, create duplicate orders, or send the same email three times. This article walks through <em>why</em> retries break things, what an idempotency key actually buys you, and how to build a production-grade idempotency layer in Spring Boot 3.x backed by Redis — handling concurrent duplicates, conflicting bodies, and key expiry along the way.</p><h3>Why retries break things</h3><p>Consider a textbook failure. A client calls POST /payments. The server receives the request, charges the card, commits the transaction, and starts streaming the 201 Created response. Then the client&#39;s socket times out before the last byte arrives.</p><p>From the server’s perspective: success. From the client’s perspective: unknown. The HTTP spec gives the client no way to distinguish “the server never saw my request” from “the server processed it but I lost the reply.” This is the classic two-generals problem dressed up in TCP.</p><p>The client’s retry library — Resilience4j, an Axios interceptor, a mobile SDK with exponential backoff — does the only sensible thing it can: it sends the request again. Without server-side protection, the second request charges the card a second time.</p><p>The failure modes that produce duplicate effects:</p><ul><li><strong>Read timeouts</strong>: the response is slow, the client gives up and retries while the original is still completing.</li><li><strong>Connection resets</strong>: a proxy or load balancer recycles a connection mid-flight.</li><li><strong>Client crashes and restarts</strong>: an at-least-once job queue redelivers a message.</li><li><strong>User double-clicks</strong>: the most boring and most common cause of duplicate orders.</li></ul><p>The goal is not to prevent retries — retries are <em>good</em>, they are how distributed systems achieve reliability. The goal is to make retries <em>safe</em> by guaranteeing that the same logical operation, repeated any number of times, produces exactly one effect and returns the same response.</p><h3>Idempotency, safety, and HTTP methods</h3><p>Two related but distinct properties matter here.</p><ul><li><strong>Safe</strong>: the method has no side effects on server state. Reading is safe; writing is not.</li><li><strong>Idempotent</strong>: making the request N times has the same effect on server state as making it once. The <em>response</em> may differ, but the resulting state does not.</li></ul><p>HTTP gives us baseline guarantees per method:</p><p>Method Safe Idempotent Typical effect Naturally retry-safe? GET Yes Yes Read a resource Yes HEAD Yes Yes Read headers only Yes PUT No Yes Replace a resource at a known URI Yes DELETE No Yes Remove a resource Yes (second is no-op) POST No <strong>No</strong> Create a subordinate / trigger an effect <strong>No</strong> PATCH No No* Partial update Depends on payload</p><p>PUT is idempotent because it sets a resource to a fully specified state: PUT /users/42 with the same body twice leaves user 42 in the same state. DELETE /users/42 is idempotent because once it&#39;s gone, deleting it again changes nothing (you may return 404 the second time, but the <em>state</em> is identical).</p><p>POST is the problem child. POST /payments <em>creates a new thing each time</em>. There is no URI for the not-yet-created resource, so the method itself can&#39;t be idempotent. PATCH is conditional: PATCH {balance: 100} is idempotent, but PATCH {balance: balance + 10} is not.</p><p>The strategy, then, is to make the naturally-idempotent methods safe by design, and to add an <strong>explicit idempotency mechanism on top of </strong><strong>POST</strong> (and any non-idempotent PATCH).</p><h3>The idempotency key concept</h3><p>The pattern, popularized by Stripe and now adopted across the industry, is simple: the client generates a unique key for each logical operation and sends it in a header.</p><pre>POST /payments HTTP/1.1<br>Content-Type: application/json<br>Idempotency-Key: 8f14e45f-ceea-467f-9b3a-1d2f0e9c7a55</pre><pre>{ &quot;amount&quot;: 4999, &quot;currency&quot;: &quot;eur&quot;, &quot;source&quot;: &quot;card_abc&quot; }</pre><p>The key is a client-generated, opaque, unique string — usually a UUID v4. The contract is:</p><ul><li>The client uses the <strong>same key</strong> for all retries of the <strong>same logical operation</strong>.</li><li>The client uses a <strong>fresh key</strong> for each new operation, even if the body is identical.</li></ul><p>The server’s job is to recognize repeated keys and ensure the underlying effect happens exactly once, replaying the original response for every retry.</p><h3>Storing the request fingerprint and result</h3><p>When the server sees an idempotency key for the first time, it:</p><ol><li>Records the key together with a <strong>fingerprint</strong> of the request (a hash of method + path + body). The fingerprint guards against a client reusing a key with a <em>different</em> request — almost always a client bug, and one we must reject rather than silently corrupt.</li><li>Executes the operation.</li><li>Stores the resulting response (status, headers, body) against the key.</li></ol><p>On any subsequent request with the same key:</p><ul><li>If the fingerprint matches and a stored response exists, <strong>replay the stored response</strong> without re-executing.</li><li>If the fingerprint differs, return <strong>409 Conflict</strong> — the key was reused for a different request.</li><li>If the operation is still <strong>in flight</strong> (a concurrent duplicate arrived before the first finished), return <strong>409 Conflict</strong> with a “retry later” semantic, so the caller doesn’t kick off a parallel charge.</li></ul><pre>┌────────────────────────────────────────────────────────┐<br>            │                    Idempotency Store (Redis)            │<br>            │   key -&gt; { state, fingerprint, status, headers, body }  │<br>            └────────────────────────────────────────────────────────┘<br>                          ⚯                ▲                  ▲<br>        (1) reserve key  │   (3) save      │   (replay)       │<br>            SETNX        │   response      │   GET            │<br>                          │                │                  │<br>   client ──POST──▶ ┌──────────────────────────────────────────────┐<br>        retry ────▶ │  IdempotencyFilter (OncePerRequestFilter)      │<br>                    │                                                │<br>                    │  no key?  ──────────────▶ pass through         │<br>                    │  new key  ──reserve──▶ run controller ──save──▶ │<br>                    │  in-flight ─────────────▶ 409 (retry later)    │<br>                    │  completed + same body ─▶ replay stored 2xx    │<br>                    │  completed + diff body ─▶ 409 Conflict         │<br>                    └──────────────────────────────────────────────┘<br>                                       │<br>                                       ▼<br>                              ┌─────────────────┐<br>                              │  PaymentService │  (executed at most once)<br>                              └─────────────────┘</pre><h3>Why Redis</h3><p>The idempotency store has demanding requirements: extremely low latency on the hot path, atomic “reserve-if-absent” semantics for concurrency control, and automatic expiry so keys don’t accumulate forever. Redis fits all three:</p><ul><li>SET key value NX PX ttl gives us atomic reservation in a single round trip.</li><li>TTLs are first-class, so keys self-expire (typically 24h — long enough to cover any reasonable client retry window, short enough to bound storage).</li><li>It’s shared across all application instances, so the guarantee holds in a horizontally-scaled deployment where retries may hit different pods.</li></ul><p>A relational table works too, but you then need a unique constraint plus careful transaction isolation to avoid races, and a scheduled job to purge expired rows. Redis collapses that into the data model itself.</p><h3>Implementation: Spring Boot 3.x + Redis</h3><p>We’ll build the layer as an OncePerRequestFilter. A filter (rather than a HandlerInterceptor) lets us wrap the response and capture the bytes the controller writes, which we need in order to replay them later. We need three pieces: a record describing stored state, a repository over StringRedisTemplate, and the filter itself.</p><h3>1. The stored record</h3><p>We model two states explicitly: IN_PROGRESS (reserved, not yet completed) and COMPLETED (response captured).</p><pre>package com.devdomain.idempotency;</pre><pre>import com.fasterxml.jackson.annotation.JsonCreator;<br>import com.fasterxml.jackson.annotation.JsonProperty;</pre><pre>import java.util.Map;</pre><pre>public record IdempotencyRecord(<br>        @JsonProperty(&quot;state&quot;) State state,<br>        @JsonProperty(&quot;fingerprint&quot;) String fingerprint,<br>        @JsonProperty(&quot;status&quot;) int status,<br>        @JsonProperty(&quot;headers&quot;) Map&lt;String, String&gt; headers,<br>        @JsonProperty(&quot;body&quot;) String body) {</pre><pre>    public enum State { IN_PROGRESS, COMPLETED }</pre><pre>    @JsonCreator<br>    public IdempotencyRecord {<br>        // Jackson canonical constructor; nothing extra to validate here.<br>    }</pre><pre>    static IdempotencyRecord inProgress(String fingerprint) {<br>        return new IdempotencyRecord(State.IN_PROGRESS, fingerprint, 0, Map.of(), null);<br>    }</pre><pre>    IdempotencyRecord completed(int status, Map&lt;String, String&gt; headers, String body) {<br>        return new IdempotencyRecord(State.COMPLETED, this.fingerprint, status, headers, body);<br>    }<br>}</pre><h3>2. The Redis repository</h3><p>The repository owns serialization and the atomic reservation primitive. The key insight is setIfAbsent (which maps to Redis SET ... NX): it returns true only for the caller that wins the race to create the key. Everyone else gets false and must treat the request as a duplicate.</p><pre>package com.devdomain.idempotency;</pre><pre>import com.fasterxml.jackson.core.JsonProcessingException;<br>import com.fasterxml.jackson.databind.ObjectMapper;<br>import org.springframework.data.redis.core.StringRedisTemplate;<br>import org.springframework.stereotype.Repository;</pre><pre>import java.time.Duration;<br>import java.util.Optional;</pre><pre>@Repository<br>public class IdempotencyRepository {</pre><pre>    private static final String KEY_PREFIX = &quot;idem:&quot;;<br>    private static final Duration TTL = Duration.ofHours(24);</pre><pre>    private final StringRedisTemplate redis;<br>    private final ObjectMapper objectMapper;</pre><pre>    public IdempotencyRepository(StringRedisTemplate redis, ObjectMapper objectMapper) {<br>        this.redis = redis;<br>        this.objectMapper = objectMapper;<br>    }</pre><pre>    /**<br>     * Atomically reserve the key. Returns true only if no record existed.<br>     * Backed by Redis SET key value NX PX ttl.<br>     */<br>    public boolean tryReserve(String key, IdempotencyRecord record) {<br>        Boolean reserved = redis.opsForValue()<br>                .setIfAbsent(redisKey(key), serialize(record), TTL);<br>        return Boolean.TRUE.equals(reserved);<br>    }</pre><pre>    public Optional&lt;IdempotencyRecord&gt; find(String key) {<br>        String raw = redis.opsForValue().get(redisKey(key));<br>        if (raw == null) {<br>            return Optional.empty();<br>        }<br>        return Optional.of(deserialize(raw));<br>    }</pre><pre>    /**<br>     * Overwrite the in-progress record with the completed response,<br>     * preserving the remaining TTL window for replay.<br>     */<br>    public void complete(String key, IdempotencyRecord record) {<br>        redis.opsForValue().set(redisKey(key), serialize(record), TTL);<br>    }</pre><pre>    /**<br>     * Release a reservation if the operation failed before completing,<br>     * so a genuine retry is allowed to proceed.<br>     */<br>    public void release(String key) {<br>        redis.delete(redisKey(key));<br>    }</pre><pre>    private String redisKey(String key) {<br>        return KEY_PREFIX + key;<br>    }</pre><pre>    private String serialize(IdempotencyRecord record) {<br>        try {<br>            return objectMapper.writeValueAsString(record);<br>        } catch (JsonProcessingException e) {<br>            throw new IllegalStateException(&quot;Failed to serialize idempotency record&quot;, e);<br>        }<br>    }</pre><pre>    private IdempotencyRecord deserialize(String raw) {<br>        try {<br>            return objectMapper.readValue(raw, IdempotencyRecord.class);<br>        } catch (JsonProcessingException e) {<br>            throw new IllegalStateException(&quot;Corrupt idempotency record in Redis&quot;, e);<br>        }<br>    }<br>}</pre><h3>3. The filter</h3><p>The filter is where the state machine lives. It only engages for unsafe methods that carry an Idempotency-Key. For everything else it gets out of the way.</p><pre>package com.devdomain.idempotency;</pre><pre>import com.fasterxml.jackson.databind.ObjectMapper;<br>import jakarta.servlet.FilterChain;<br>import jakarta.servlet.ServletException;<br>import jakarta.servlet.http.HttpServletRequest;<br>import jakarta.servlet.http.HttpServletResponse;<br>import org.springframework.http.HttpHeaders;<br>import org.springframework.http.HttpMethod;<br>import org.springframework.http.MediaType;<br>import org.springframework.stereotype.Component;<br>import org.springframework.util.DigestUtils;<br>import org.springframework.web.filter.OncePerRequestFilter;<br>import org.springframework.web.util.ContentCachingRequestWrapper;<br>import org.springframework.web.util.ContentCachingResponseWrapper;</pre><pre>import java.io.IOException;<br>import java.nio.charset.StandardCharsets;<br>import java.util.LinkedHashMap;<br>import java.util.Map;<br>import java.util.Optional;<br>import java.util.Set;</pre><pre>@Component<br>public class IdempotencyFilter extends OncePerRequestFilter {</pre><pre>    private static final String HEADER = &quot;Idempotency-Key&quot;;<br>    private static final String REPLAYED_HEADER = &quot;Idempotent-Replayed&quot;;<br>    private static final Set&lt;String&gt; GUARDED_METHODS = Set.of(<br>            HttpMethod.POST.name(), HttpMethod.PATCH.name());</pre><pre>    private final IdempotencyRepository repository;<br>    private final ObjectMapper objectMapper;</pre><pre>    public IdempotencyFilter(IdempotencyRepository repository, ObjectMapper objectMapper) {<br>        this.repository = repository;<br>        this.objectMapper = objectMapper;<br>    }</pre><pre>    @Override<br>    protected boolean shouldNotFilter(HttpServletRequest request) {<br>        boolean guardedMethod = GUARDED_METHODS.contains(request.getMethod());<br>        boolean hasKey = request.getHeader(HEADER) != null;<br>        return !(guardedMethod &amp;&amp; hasKey);<br>    }</pre><pre>    @Override<br>    protected void doFilterInternal(HttpServletRequest request,<br>                                    HttpServletResponse response,<br>                                    FilterChain chain)<br>            throws ServletException, IOException {</pre><pre>        String key = request.getHeader(HEADER);<br>        if (key.isBlank() || key.length() &gt; 200) {<br>            sendProblem(response, HttpServletResponse.SC_BAD_REQUEST,<br>                    &quot;Idempotency-Key must be a non-empty string of at most 200 characters.&quot;);<br>            return;<br>        }</pre><pre>        // Wrap the request so we can read the body and still hand it to the controller.<br>        ContentCachingRequestWrapper req = new ContentCachingRequestWrapper(request);<br>        // Force the body to be read into the cache for fingerprinting.<br>        req.getInputStream().readAllBytes();<br>        String fingerprint = fingerprint(req);</pre><pre>        Optional&lt;IdempotencyRecord&gt; existing = repository.find(key);<br>        if (existing.isPresent()) {<br>            handleExisting(existing.get(), fingerprint, response);<br>            return;<br>        }</pre><pre>        // No record yet: try to atomically reserve the key.<br>        boolean reserved = repository.tryReserve(key, IdempotencyRecord.inProgress(fingerprint));<br>        if (!reserved) {<br>            // Lost the race to a concurrent duplicate; re-read and handle.<br>            Optional&lt;IdempotencyRecord&gt; raced = repository.find(key);<br>            if (raced.isPresent()) {<br>                handleExisting(raced.get(), fingerprint, response);<br>            } else {<br>                // Reserved-then-expired in a tiny window; treat as transient.<br>                sendProblem(response, HttpServletResponse.SC_CONFLICT,<br>                        &quot;Concurrent request with the same Idempotency-Key; retry shortly.&quot;);<br>            }<br>            return;<br>        }</pre><pre>        // We own the key. Execute the operation and capture the response.<br>        executeAndStore(req, response, chain, key, fingerprint);<br>    }</pre><pre>    private void handleExisting(IdempotencyRecord record,<br>                                String fingerprint,<br>                                HttpServletResponse response) throws IOException {<br>        if (!record.fingerprint().equals(fingerprint)) {<br>            sendProblem(response, HttpServletResponse.SC_CONFLICT,<br>                    &quot;Idempotency-Key was already used with a different request payload.&quot;);<br>            return;<br>        }<br>        if (record.state() == IdempotencyRecord.State.IN_PROGRESS) {<br>            response.setHeader(HttpHeaders.RETRY_AFTER, &quot;1&quot;);<br>            sendProblem(response, HttpServletResponse.SC_CONFLICT,<br>                    &quot;A request with this Idempotency-Key is still being processed.&quot;);<br>            return;<br>        }<br>        replay(record, response);<br>    }</pre><pre>    private void executeAndStore(ContentCachingRequestWrapper req,<br>                                 HttpServletResponse response,<br>                                 FilterChain chain,<br>                                 String key,<br>                                 String fingerprint) throws ServletException, IOException {<br>        ContentCachingResponseWrapper res = new ContentCachingResponseWrapper(response);<br>        boolean completed = false;<br>        try {<br>            chain.doFilter(req, res);</pre><pre>            int status = res.getStatus();<br>            // Only persist successful, deterministic responses for replay.<br>            if (status &gt;= 200 &amp;&amp; status &lt; 300) {<br>                Map&lt;String, String&gt; headers = captureHeaders(res);<br>                String body = new String(res.getContentAsByteArray(), responseCharset(res));<br>                IdempotencyRecord done = IdempotencyRecord<br>                        .inProgress(fingerprint)<br>                        .completed(status, headers, body);<br>      2         repository.complete(key, done);<br>                completed = true;<br>            }<br>        } finally {<br>            if (!completed) {<br>                // 4xx/5xx or an exception: release so the client can retry cleanly.<br>                repository.release(key);<br>            }<br>            res.copyBodyToResponse();<br>        }<br>    }</pre><pre>    private void replay(IdempotencyRecord record, HttpServletResponse response) throws IOException {<br>        response.setStatus(record.status());<br>        record.headers().forEach(response::setHeader);<br>        response.setHeader(REPLAYED_HEADER, &quot;true&quot;);<br>        if (record.body() != null) {<br>            byte[] bytes = record.body().getBytes(StandardCharsets.UTF_8);<br>            response.setContentLength(bytes.length);<br>            response.getOutputStream().write(bytes);<br>        }<br>        response.flushBuffer();<br>    }</pre><pre>    private String fingerprint(ContentCachingRequestWrapper req) {<br>        byte[] body = req.getContentAsByteArray();<br>        String material = req.getMethod() + &#39;\n&#39;<br>                + req.getRequestURI() + &#39;\n&#39;<br>                + (req.getQueryString() == null ? &quot;&quot; : req.getQueryString()) + &#39;\n&#39;<br>                + new String(body, StandardCharsets.UTF_8);<br>        return DigestUtils.md5DigestAsHex(material.getBytes(StandardCharsets.UTF_8));<br>    }</pre><pre>    private Map&lt;String, String&gt; captureHeaders(ContentCachingResponseWrapper res) {<br>        Map&lt;String, String&gt; headers = new LinkedHashMap&lt;&gt;();<br>        for (String name : res.getHeaderNames()) {<br>            // Skip hop-by-hop and length headers we recompute on replay.<br>            if (HttpHeaders.CONTENT_LENGTH.equalsIgnoreCase(name)) {<br>                continue;<br>            }<br>            headers.put(name, res.getHeader(name));<br>        }<br>        return headers;<br>    }</pre><pre>    private String responseCharset(ContentCachingResponseWrapper res) {<br>        return res.getCharacterEncoding() != null<br>                ? res.getCharacterEncoding()<br>                : StandardCharsets.UTF_8.name();<br>    }</pre><pre>    private void sendProblem(HttpServletResponse response, int status, String detail)<br>            throws IOException {<br>        response.setStatus(status);<br>        response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);<br>        String body = &quot;&quot;&quot;<br>                {&quot;type&quot;:&quot;about:blank&quot;,&quot;status&quot;:%d,&quot;title&quot;:&quot;%s&quot;,&quot;detail&quot;:&quot;%s&quot;}&quot;&quot;&quot;<br>                .formatted(status, reason(status), detail);<br>        response.getWriter().write(body);<br>    }</pre><pre>    private String reason(int status) {<br>        return switch (status) {<br>            case 400 -&gt; &quot;Bad Request&quot;;<br>            case 409 -&gt; &quot;Conflict&quot;;<br>            default -&gt; &quot;Error&quot;;<br>        };<br>    }<br>}</pre><h3>4. Wiring and the protected endpoint</h3><p>There’s nothing exotic to configure. Spring Boot autoconfigures StringRedisTemplate from spring-boot-starter-data-redis, and component-scanning registers the filter. The controller is written <em>as if idempotency didn&#39;t exist</em> — that separation of concerns is the whole point.</p><pre>package com.devdomain.payments;</pre><pre>import org.springframework.http.HttpStatus;<br>import org.springframework.http.ResponseEntity;<br>import org.springframework.web.bind.annotation.PostMapping;<br>import org.springframework.web.bind.annotation.RequestBody;<br>import org.springframework.web.bind.annotation.RequestMapping;<br>import org.springframework.web.bind.annotation.RestController;</pre><pre>@RestController<br>@RequestMapping(&quot;/payments&quot;)<br>public class PaymentController {</pre><pre>    private final PaymentService paymentService;</pre><pre>    public PaymentController(PaymentService paymentService) {<br>        this.paymentService = paymentService;<br>    }</pre><pre>    @PostMapping<br>    public ResponseEntity&lt;PaymentResponse&gt; charge(@RequestBody ChargeRequest request) {<br>        Payment payment = paymentService.charge(<br>                request.amount(), request.currency(), request.source());<br>        return ResponseEntity<br>                .status(HttpStatus.CREATED)<br>                .body(PaymentResponse.from(payment));<br>    }<br>}</pre><pre># application.properties<br>spring.data.redis.host=localhost<br>spring.data.redis.port=6379<br>spring.data.redis.timeout=200ms</pre><p>To make the filter run before Spring Security and other filters that read the body, register its order explicitly if needed:</p><pre>package com.devdomain.idempotency;</pre><pre>import org.springframework.boot.web.servlet.FilterRegistrationBean;<br>import org.springframework.context.annotation.Bean;<br>import org.springframework.context.annotation.Configuration;<br>import org.springframework.core.Ordered;</pre><pre>@Configuration<br>public class IdempotencyConfig {</pre><pre>    @Bean<br>    public FilterRegistrationBean&lt;IdempotencyFilter&gt; idempotencyRegistration(<br>            IdempotencyFilter filter) {<br>        FilterRegistrationBean&lt;IdempotencyFilter&gt; reg = new FilterRegistrationBean&lt;&gt;(filter);<br>        reg.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);<br>        reg.addUrlPatterns(&quot;/payments/*&quot;, &quot;/orders/*&quot;);<br>        return reg;<br>    }<br>}</pre><blockquote><em>Note: because the filter is also a </em><em>@Component, you may want to mark the bean so Spring doesn&#39;t auto-register it twice. Annotate the filter class with </em><em>@Component </em>or<em> register it via </em><em>FilterRegistrationBean, not both — pick one. If you use </em><em>FilterRegistrationBean, drop the </em><em>@Component and inject dependencies through the config class.</em></blockquote><h3>The concurrency story</h3><p>The subtle part is two duplicate requests arriving at nearly the same instant, possibly on different pods. The state machine handles it cleanly:</p><ul><li>Both call find(key) and see nothing.</li><li>Both call tryReserve(key, IN_PROGRESS). Redis SET NX guarantees exactly one wins (true); the other gets false.</li><li>The winner runs the controller. The loser re-reads the record, sees IN_PROGRESS, and returns 409 with Retry-After: 1.</li><li>The client retries after the window; by then the record is COMPLETED and the stored response is replayed.</li></ul><p>This is a deliberate design choice: rather than blocking the duplicate (holding a thread while the first request finishes), we fail fast with a retryable conflict. Blocking is also valid and feels nicer to callers, but it ties up server threads and risks cascading timeouts under load. Fail-fast-and-retry composes better with the backoff logic the client already has.</p><h3>Failure handling and the “poison key” trap</h3><p>A critical subtlety: <strong>what happens when the operation fails after the key is reserved?</strong> If we leave the IN_PROGRESS record in place, every retry sees an in-flight state forever (until TTL) and the client can never succeed — a &quot;poison key.&quot;</p><p>The filter above handles this by only persisting 2xx responses. On a 4xx/5xx or a thrown exception, the finally block calls release(key), deleting the reservation so a genuine retry starts fresh.</p><p>There is a residual risk: if the process <em>crashes</em> between charging the card and writing the COMPLETED record, the reservation lingers until TTL expires and the response is lost. That window is unavoidable with a non-transactional store; the mitigation is to keep the operation and the store-write as close together as possible, and to make the underlying business operation itself idempotent at the domain level (e.g., the payment provider also accepts an idempotency key). Defense in depth — the API-layer key and a domain-layer key — is the gold standard for money movement.</p><h3>Key expiry</h3><p>TTL is a balance:</p><ul><li><strong>Too short</strong> and a legitimate slow retry (a mobile client that comes back online an hour later) misses the window and re-executes.</li><li><strong>Too long</strong> and Redis fills with keys that will never be retried.</li></ul><p>24 hours is the common default and matches Stripe’s behavior. Crucially, document it: tell clients that keys are remembered for 24 hours, so they understand that reusing a key the next day will <em>not</em> dedupe. The TTL is refreshed on complete() in the implementation above so the replay window starts from completion rather than reservation.</p><h3>When to use / pitfalls</h3><p><strong>Use idempotency keys when:</strong></p><ul><li>The endpoint is a non-idempotent POST/PATCH with real side effects: payments, orders, transfers, sending notifications.</li><li>Clients are untrusted networks (mobile, third-party integrations) where retries are inevitable.</li><li>The cost of a duplicate effect is high — money, inventory, customer trust.</li></ul><p><strong>Don’t bother when:</strong></p><ul><li>The operation is already naturally idempotent (PUT, DELETE) — the method contract covers you.</li><li>The endpoint is a pure read (GET).</li><li>The “duplicate” is harmless (e.g., recording an analytics event where a few dupes don’t matter and the overhead isn’t worth it).</li></ul><p><strong>Pitfalls to watch:</strong></p><ul><li><strong>Caching error responses.</strong> Never store 4xx/5xx for replay. A transient 503 cached for 24h would block all retries. Only 2xx is replay-safe.</li><li><strong>Fingerprint scope.</strong> Hash method + path + body. Don’t include volatile headers (timestamps, auth tokens) or the fingerprint will never match on retry. Be deliberate about whether the query string is part of identity.</li><li><strong>Reading the body twice.</strong> A raw HttpServletRequest body is a one-shot stream. You must wrap it (ContentCachingRequestWrapper) so both the filter and the controller can read it.</li><li><strong>Streaming/large responses.</strong> Capturing the full response body to replay it doesn’t suit large downloads or streamed content — exclude those endpoints.</li><li><strong>Trusting the client to scope keys.</strong> A buggy client that reuses one key for different operations is caught by the fingerprint mismatch returning 409, but make sure your error message points to the bug.</li><li><strong>Non-deterministic responses.</strong> If the response embeds a server timestamp or a freshly generated ID, the replayed response will show the <em>original</em> values. That’s usually correct (it reflects the one effect that happened) — just be aware that “now” in a replayed response is the original “now.”</li><li><strong>Clock and TTL coupling.</strong> Don’t tie business logic to the key’s TTL. The TTL is a storage-cleanup concern, not a domain invariant.</li></ul><h3>Wrapping up</h3><p>Idempotency is not a feature you bolt on after the first double-charge incident — it’s an architectural property you design in from the start. The recipe is small and durable: let clients carry an Idempotency-Key, reserve it atomically, fingerprint the request to catch misuse, execute the effect exactly once, and replay the stored response for every retry. Redis gives you the atomic reservation and free expiry; an OncePerRequestFilter keeps the concern out of your controllers entirely.</p><p>Get this right and retries stop being a liability and become what they were always meant to be: the mechanism that makes your system reliable.</p><p>If this helped you reason about safe retries, follow the <strong>devdomain</strong> publication for more deep dives on distributed systems and API design. Have you implemented idempotency differently — blocking instead of fail-fast, or a relational store instead of Redis? Leave a comment and tell us what worked (�nd what bit you) in production.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6ff9106011ec" width="1" height="1" alt=""><hr><p><a href="https://medium.com/devdomain/designing-idempotent-rest-apis-safe-retries-and-exactly-once-effectsthe-network-is-not-your-friend-6ff9106011ec">Designing Idempotent REST APIs: Safe Retries and Exactly-Once EffectsThe network is not your friend.</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[Mastering Spring Transactions: Propagation, Isolation, and the Pitfalls@Transactional is the…]]></title>
            <link>https://medium.com/devdomain/mastering-spring-transactions-propagation-isolation-and-the-pitfalls-transactional-is-the-0fdc3e6a23ae?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/0fdc3e6a23ae</guid>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[jpa]]></category>
            <category><![CDATA[transactions]]></category>
            <category><![CDATA[java]]></category>
            <category><![CDATA[spring-framework]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Tue, 29 Sep 2026 14:31:02 GMT</pubDate>
            <atom:updated>2026-09-29T14:31:02.304Z</atom:updated>
            <content:encoded><![CDATA[<p>Mastering Spring Transactions: Propagation, Isolation, and the Pitfalls@Transactional is the most-used and least-understood annotation in Spring. People sprinkle it on a method, see their data commit, and move on — until the day a rollback doesn&#39;t roll back, a transaction mysteriously doesn&#39;t start, or two services deadlock under load. Almost every one of those incidents traces back to not understanding <em>how</em> @Transactional actually works.</p><p>Spring transactions are declarative: you annotate, Spring weaves the begin/commit/rollback around your method. But “around your method” hides a proxy, a transaction manager, a thread-bound connection, and a set of rules about propagation, isolation, and rollback that you must know to use it correctly. This article goes under the hood: the proxy mechanism and its infamous self-invocation trap, every propagation level with a concrete use case, isolation levels and the anomalies they prevent, rollback rules, readOnly, and the bugs that catch even experienced engineers.</p><h3>How @Transactional actually works: proxies</h3><p>When you annotate a bean method with @Transactional, Spring does <strong>not</strong> modify your method&#39;s bytecode. Instead, it wraps your bean in a <strong>proxy</strong>. Callers get the proxy; the proxy starts a transaction, calls your real method, and commits or rolls back.</p><pre>caller ──► [ Spring proxy ] ──► your bean<br>                  │  begin tx<br>                  │  invoke real method ─────► business logic<br>                  │  commit / rollback<br>                  ▼<br>              returns</pre><p>Two proxy strategies:</p><ul><li><strong>JDK dynamic proxies</strong> — used when your bean implements an interface. The proxy implements the same interface.</li><li><strong>CGLIB proxies</strong> — used when there’s no interface (Spring Boot’s default). The proxy is a runtime subclass of your class.</li></ul><p>The transaction logic lives in a TransactionInterceptor that the proxy invokes. It asks a PlatformTransactionManager (e.g. JpaTransactionManager, DataSourceTransactionManager) to begin/commit/rollback. The active transaction&#39;s resources (the JDBC connection / JPA EntityManager) are bound to the <strong>current thread</strong> via TransactionSynchronizationManager. This thread-binding is why the same connection is reused across all DAO calls within one transactional method.</p><p>This proxy model has two enormous consequences that cause most transaction bugs.</p><h3>Pitfall #1: the self-invocation trap</h3><p>Because the transaction is applied by the <em>proxy</em>, it only takes effect when the call goes <em>through</em> the proxy. A call from one method to another method <strong>of the same bean</strong> does not go through the proxy — it’s a plain this.method() call on the target object. The annotation is ignored.</p><pre>@Service<br>public class OrderService {</pre><pre>    public void placeOrder(Order order) {<br>        // BUG: this.save(...) bypasses the proxy — NO transaction starts<br>        save(order);<br>    }</pre><pre>    @Transactional<br>    public void save(Order order) {<br>        repository.persist(order);<br>        repository.persist(order.lineItems()); // not atomic with the above!<br>    }<br>}</pre><p>save is annotated, but placeOrder calls it directly on this. The proxy never sees the call, so no transaction begins. If the second persist fails, the first is <strong>not</strong> rolled back.</p><p>The same trap applies to @Transactional propagation: an inner method requesting REQUIRES_NEW via self-invocation will <em>not</em> get a new transaction.</p><h3>Fixes for self-invocation</h3><ol><li><strong>Move the transactional method to another bean.</strong> The cleanest fix — the call then crosses a proxy boundary.</li></ol><pre>@Service<br>public class OrderService {<br>    private final OrderPersister persister;<br>    public OrderService(OrderPersister persister) { this.persister = persister; }</pre><pre>    public void placeOrder(Order order) {<br>        persister.save(order); // goes through OrderPersister&#39;s proxy<br>    }<br>}</pre><pre>@Service<br>public class OrderPersister {<br>    @Transactional<br>    public void save(Order order) { /* ... */ }<br>}</pre><ol><li><strong>Self-inject the proxy</strong> (a code smell, but sometimes pragmatic):</li></ol><pre>@Service<br>public class OrderService {<br>    @org.springframework.beans.factory.annotation.Autowired<br>    private OrderService self;</pre><pre>    public void placeOrder(Order order) { self.save(order); }</pre><pre>    @Transactional<br>    public void save(Order order) { /* ... */ }<br>}</pre><ol><li><strong>Annotate the public entry point</strong> instead of the inner method, so the transaction starts at the proxy boundary you actually call.</li></ol><h3>Pitfall #2: @Transactional only works on public methods</h3><p>With Spring’s default proxy-based AOP, @Transactional on private, protected, or package-private methods is silently ignored — the proxy can&#39;t intercept them. Always put @Transactional on public methods. (AspectJ load-time weaving can transactionalize non-public methods, but that&#39;s a different, less common setup.)</p><h3>Propagation: how transactions compose</h3><p>Propagation answers: <em>when a transactional method is called, what happens relative to any transaction already in progress?</em> Set it with @Transactional(propagation = ...).</p><p>Propagation If a transaction exists If none exists REQUIRED (default) Join it Start a new one REQUIRES_NEW Suspend it, start a new independent one Start a new one NESTED Create a savepoint within it Start a new one SUPPORTS Join it Run non-transactionally NOT_SUPPORTED Suspend it, run non-transactionally Run non-transactionally MANDATORY Join it Throw exception NEVER Throw exception Run non-transactionally</p><h3>REQUIRED — the default you’ll use 90% of the time</h3><p>The caller and callee share one transaction. If either fails, the whole thing rolls back. This is almost always what you want for a business operation that should be atomic.</p><h3>REQUIRES_NEW — independent commit</h3><p>The current transaction is <strong>suspended</strong>, a brand-new one starts (with its own connection), commits or rolls back independently, then the original resumes. Use it when an action must persist <em>regardless</em> of the outer transaction’s fate — the canonical example is an audit log:</p><pre>@Service<br>public class AuditService {<br>    @Transactional(propagation = Propagation.REQUIRES_NEW)<br>    public void record(String event) {<br>        auditRepository.save(new AuditEntry(event));<br>    }<br>}</pre><p>Even if the outer business transaction rolls back, the audit record stays. Caution: REQUIRES_NEW holds <strong>two connections at once</strong> (outer suspended, inner active). Under load this can exhaust the connection pool and even self-deadlock if the pool is too small. Use it deliberately.</p><h3>NESTED — savepoints</h3><p>NESTED runs inside the existing transaction but creates a JDBC <strong>savepoint</strong>. If the nested part fails, only its work rolls back (to the savepoint); the outer transaction can continue and still commit. Unlike REQUIRES_NEW, there&#39;s only one physical transaction and one connection. It requires a transaction manager and driver that support savepoints (most do for JDBC; JPA support is limited).</p><pre>@Transactional(propagation = Propagation.NESTED)<br>public void importRow(Row row) {<br>    // failure here rolls back to the savepoint, not the whole batch<br>}</pre><h3>MANDATORY / NEVER / NOT_SUPPORTED</h3><ul><li>MANDATORY — &quot;I must run inside someone else&#39;s transaction; if there isn&#39;t one, that&#39;s a bug.&quot; Great for low-level helpers that should never start their own transaction.</li><li>NEVER — &quot;I must never run in a transaction.&quot; Throws if one exists.</li><li>NOT_SUPPORTED — suspend any transaction and run without one. Useful for long read-only reporting that shouldn&#39;t hold a transaction open.</li></ul><h3>Isolation: which concurrency anomalies you tolerate</h3><p>Isolation controls what one transaction can see of another’s uncommitted (or concurrently committing) work. The SQL standard defines three anomalies and four levels that prevent progressively more of them.</p><p>The anomalies:</p><ul><li><strong>Dirty read</strong> — you read another transaction’s <em>uncommitted</em> change. If it rolls back, you read data that never existed.</li><li><strong>Non-repeatable read</strong> — you read a row twice in one transaction and get different values because another transaction committed an update in between.</li><li><strong>Phantom read</strong> — you run the same range query twice and get different <em>rows</em> because another transaction inserted/deleted matching rows.</li></ul><p>Isolation level Dirty read Non-repeatable read Phantom read READ_UNCOMMITTED Possible Possible Possible READ_COMMITTED Prevented Possible Possible REPEATABLE_READ Prevented Prevented Possible* SERIALIZABLE Prevented Prevented Prevented</p><p>* Some databases (notably MySQL InnoDB) prevent phantoms at REPEATABLE_READ via next-key locks; the standard does not require it.</p><pre>@Transactional(isolation = Isolation.REPEATABLE_READ)<br>public BigDecimal computeStatement(String accountId) { /* ... */ }</pre><p>Practical notes:</p><ul><li><strong>DEFAULT</strong> (the Spring default) defers to the database. PostgreSQL and Oracle default to READ_COMMITTED; MySQL InnoDB to REPEATABLE_READ. Know your DB&#39;s default rather than assuming.</li><li><strong>Higher isolation costs concurrency.</strong> SERIALIZABLE can serialize transactions with extra locking or optimistic abort-and-retry (PostgreSQL uses serializable snapshot isolation that throws serialization failures you must retry). Don&#39;t reach for it reflexively.</li><li><strong>REQUIRES_NEW is the only way to change isolation mid-flow.</strong> A REQUIRED method that <em>joins</em> an existing transaction <strong>cannot</strong> change its isolation level — Spring throws if you try, because the physical transaction already started. The isolation is fixed by whichever method opened the transaction.</li></ul><h3>Rollback rules: the most surprising default</h3><p>This is where teams lose data. By default, <strong>Spring rolls back only on unchecked exceptions</strong> (RuntimeException and Error). It <strong>commits</strong> if a <em>checked</em> exception is thrown.</p><pre>@Transactional<br>public void process() throws IOException {<br>    repository.save(entity);<br>    throw new IOException(&quot;boom&quot;); // checked → transaction COMMITS the save!<br>}</pre><p>That IOException does <strong>not</strong> trigger a rollback. The save is committed. This trips up everyone at least once.</p><p>Control it explicitly:</p><pre>// Roll back on a checked exception too<br>@Transactional(rollbackFor = IOException.class)<br>public void process() throws IOException { /* ... */ }</pre><pre>// Or never roll back on a specific runtime exception<br>@Transactional(noRollbackFor = BusinessValidationException.class)<br>public void validate() { /* ... */ }</pre><p>Many teams standardize on @Transactional(rollbackFor = Exception.class) to make rollback the default for <em>any</em> exception, eliminating the checked/unchecked surprise.</p><h3>The “marked rollback-only” trap</h3><p>A second rollback gotcha: when an inner REQUIRED method catches an exception thrown by a deeper call, the transaction may <em>already</em> be marked rollback-only. Spring marks the shared transaction for rollback as soon as an exception escapes a transactional boundary. If the outer method then catches it and tries to commit, you get:</p><pre>org.springframework.transaction.UnexpectedRollbackException:<br>   Transaction silently rolled back because it has been marked as rollback-only</pre><p>The fix is usually to use REQUIRES_NEW for the inner operation (so its failure doesn&#39;t poison the outer transaction) or to not swallow the exception. Understand that a REQUIRED inner failure dooms the <em>entire</em> shared transaction even if you catch the exception.</p><h3>readOnly: more than a hint</h3><p>@Transactional(readOnly = true) declares the transaction won&#39;t modify data. It&#39;s not just documentation:</p><pre>@Transactional(readOnly = true)<br>public List&lt;Order&gt; recentOrders(String customerId) { /* queries only */ }</pre><p>What it actually does:</p><ul><li><strong>Hibernate sets </strong><strong>FlushMode.MANUAL</strong>, skipping dirty-checking and the automatic flush before queries. This is a real performance win for read-heavy methods — Hibernate doesn&#39;t scan the persistence context for changes.</li><li><strong>The connection is marked read-only</strong>, which some drivers and databases use to route to read replicas or to optimize.</li><li>It signals intent: accidental writes in a read-only method may fail or be silently discarded (because there’s no flush), which surfaces design mistakes.</li></ul><p>Use readOnly = true on all query-only service methods. It&#39;s free performance and clearer intent. But remember: it does <em>not</em> make the method side-effect-free — it only governs the persistence layer.</p><h3>The “no open EntityManager” / LazyInitializationException angle</h3><p>Because the persistence context lives for the duration of the transaction (thread-bound), accessing a lazy association <em>after</em> the transactional method returns throws LazyInitializationException — the session is closed. This is the most common JPA-with-Spring error.</p><p>Don’t fix it by widening transaction boundaries into the web layer (the “Open Session in View” anti-pattern, on by default in Spring Boot — consider setting spring.jpa.open-in-view=false). Instead, fetch what you need inside the transaction: use JOIN FETCH, entity graphs, or map to a DTO before returning.</p><pre>@Transactional(readOnly = true)<br>public OrderDto load(String id) {<br>    Order order = repository.findWithItems(id); // JOIN FETCH inside the tx<br>    return OrderDto.from(order);               // map while session is open<br>}</pre><h3>Transaction timeout</h3><p>Long-running transactions hold connections and locks. Bound them:</p><pre>@Transactional(timeout = 10) // seconds<br>public void reconcile() { /* ... */ }</pre><p>If the method exceeds the timeout, Spring rolls back and throws. Set sensible timeouts on anything that touches external systems or large datasets so a stuck transaction can’t pin a connection forever.</p><h3>Putting it together: a worked service</h3><pre>import org.springframework.transaction.annotation.*;<br>import org.springframework.stereotype.Service;</pre><pre>@Service<br>public class CheckoutService {</pre><pre>    private final OrderRepository orders;<br>    private final InventoryService inventory;<br>    private final AuditService audit;</pre><pre>    public CheckoutService(OrderRepository orders, InventoryService inventory,<br>                           AuditService audit) {<br>        this.orders = orders;<br>        this.inventory = inventory;<br>        this.audit = audit;<br>    }</pre><pre>    @Transactional(rollbackFor = Exception.class, timeout = 15)<br>    public Order checkout(Cart cart) throws InsufficientStockException {<br>        Order order = orders.save(Order.from(cart)); // REQUIRED: shares this tx<br>        inventory.reserve(cart);                     // REQUIRED: same tx, atomic<br>        audit.record(&quot;checkout:&quot; + order.id());      // REQUIRES_NEW: survives rollback<br>        return order;<br>    }<br>}</pre><p>If inventory.reserve throws InsufficientStockException (checked), rollbackFor = Exception.class ensures the order save rolls back — but the audit record, written in its own REQUIRES_NEW transaction, persists for forensics.</p><h3>Common pitfalls</h3><ul><li><strong>Self-invocation.</strong> Calling a @Transactional method from the same class bypasses the proxy. Move it to another bean or self-inject.</li><li><strong>Non-public methods.</strong> Proxy-based transactions ignore non-public methods entirely.</li><li><strong>Checked exceptions don’t roll back by default.</strong> A thrown checked exception commits your changes. Use rollbackFor = Exception.class.</li><li><strong>UnexpectedRollbackException.</strong> Catching an exception from an inner REQUIRED call doesn&#39;t save the shared transaction — it&#39;s already marked rollback-only. Use REQUIRES_NEW for isolation.</li><li><strong>Changing isolation on a joined transaction.</strong> Only the method that <em>starts</em> the physical transaction sets isolation; REQUIRED joins can&#39;t override it.</li><li><strong>REQUIRES_NEW connection exhaustion.</strong> It holds two connections; overuse plus a small pool can self-deadlock.</li><li><strong>LazyInitializationException.</strong> Accessing lazy fields after the transaction closed. Fetch eagerly inside the transaction or map to DTOs; prefer disabling Open Session in View.</li><li><strong>No timeout.</strong> Unbounded transactions pin connections and locks under failure.</li><li><strong>Assuming </strong><strong>readOnly prevents writes.</strong> It changes flush behavior and connection hints; it&#39;s not a security boundary.</li></ul><h3>Wrapping up</h3><p>@Transactional is declarative on the surface and mechanical underneath: a proxy, a thread-bound connection, and a transaction manager applying begin/commit/rollback. Almost every transaction bug comes from forgetting the proxy (self-invocation, non-public methods), misunderstanding rollback rules (checked exceptions commit by default), or composing propagation incorrectly (REQUIRES_NEW vs NESTED, joined-transaction isolation). Internalize the proxy model, choose propagation and isolation deliberately, make rollback explicit, and use readOnly for queries — and your data integrity stops depending on luck.</p><p>If this saved you a future incident, follow <strong>devdomain</strong> for more deep Spring internals, and drop your worst @Transactional debugging war story in the comments — the self-invocation ones are always painfully relatable.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0fdc3e6a23ae" width="1" height="1" alt=""><hr><p><a href="https://medium.com/devdomain/mastering-spring-transactions-propagation-isolation-and-the-pitfalls-transactional-is-the-0fdc3e6a23ae">Mastering Spring Transactions: Propagation, Isolation, and the Pitfalls@Transactional is 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[Integration Testing with Testcontainers: Real Databases in Your Test SuiteYou wrote a repository…]]></title>
            <link>https://medium.com/devdomain/integration-testing-with-testcontainers-real-databases-in-your-test-suiteyou-wrote-a-repository-a9a78386e4f8?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/a9a78386e4f8</guid>
            <category><![CDATA[testing]]></category>
            <category><![CDATA[postgresql]]></category>
            <category><![CDATA[java]]></category>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[testcontainer]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Thu, 24 Sep 2026 14:21:01 GMT</pubDate>
            <atom:updated>2026-09-24T14:21:01.879Z</atom:updated>
            <content:encoded><![CDATA[<p>Integration Testing with Testcontainers: Real Databases in Your Test SuiteYou wrote a repository test. It passes. You deploy. Production blows up because your INSERT ... ON CONFLICT clause is PostgreSQL-specific and your tests ran against H2, which silently accepted a slightly different dialect. Sound familiar?</p><p>This is the central lie of in-memory database testing: you are not testing your application against the thing it runs against in production. You are testing it against an emulator that is <em>close enough</em> to give you confidence — right up until it isn’t.</p><p>Testcontainers fixes this by spinning up real databases, message brokers, and any other infrastructure inside throwaway Docker containers, scoped to your test lifecycle. In this article we’ll go deep: why mocks and H2 fail, the core Testcontainers programming model, a complete PostgreSQL + Spring Boot test, the magic of @ServiceConnection in Spring Boot 3.1+, container reuse for fast feedback, and how to extend the same pattern to Kafka and Redis.</p><h3>The Problem with H2 and Mocks</h3><p>Let’s be precise about what goes wrong, because “use a real database” is easy advice that needs justification.</p><p><strong>H2 (and other in-memory DBs) lie about dialect.</strong> Even in “PostgreSQL compatibility mode,” H2 does not implement the full PostgreSQL feature set. Things that silently differ or break:</p><ul><li>JSON/JSONB columns and operators (-&gt;, -&gt;&gt;, @&gt;)</li><li>Array column types (text[], int[])</li><li>ON CONFLICT (...) DO UPDATE upsert semantics</li><li>Window functions, LATERAL joins, full-text search (tsvector)</li><li>Vendor-specific functions, sequences, and RETURNING behavior</li><li>Lock semantics, isolation levels, and SELECT ... FOR UPDATE SKIP LOCKED</li></ul><p><strong>Mocks test your assumptions, not reality.</strong> When you mock a repository, you assert that <em>you think</em> it returns a List&lt;Order&gt;. You never verify the SQL is valid, the mapping is correct, the migration applies, or the transaction boundary behaves. A mock-based repository test is a tautology: it passes because you told it to.</p><p>The result is a test suite that is green and worthless for the layer that talks to the database. Here’s the comparison that matters:</p><p>Concern Mocks H2 (compat mode) Testcontainers (real PG) SQL dialect fidelity None Partial Full Migrations actually run No Sometimes Yes JSONB / arrays / upserts No No Yes Isolation &amp; locking behavior No No Yes Startup cost ~0 ms ~50 ms ~1–3 s (cached image) Requires Docker No No Yes</p><p>The startup cost is the only real tradeoff — and as we’ll see, container reuse and singleton patterns shrink it dramatically.</p><h3>What Testcontainers Actually Is</h3><p>Testcontainers is a Java library (with ports for many languages) that provides a programmatic API to define, start, and stop Docker containers as part of your tests. It talks to a Docker daemon (local Docker Desktop, Colima, Podman, or a remote daemon) via the Docker API.</p><p>The lifecycle is simple:</p><ol><li>Before your test (class or method), Testcontainers pulls the image if needed and starts a container.</li><li>It waits — using a configurable <em>wait strategy</em> — until the container is genuinely ready (port listening, log line printed, healthcheck passing).</li><li>It exposes the container’s internal port on a random free host port, so parallel runs never collide.</li><li>After your test, it stops and removes the container.</li></ol><p>Cleanup is guaranteed even if your JVM crashes, thanks to a companion container called <strong>Ryuk</strong> (the “reaper”) that watches the test session and kills orphaned containers.</p><p>Add the BOM and dependencies (Maven):</p><pre>&lt;dependencyManagement&gt;<br>  &lt;dependencies&gt;<br>    &lt;dependency&gt;<br>      &lt;groupId&gt;org.testcontainers&lt;/groupId&gt;<br>      &lt;artifactId&gt;testcontainers-bom&lt;/artifactId&gt;<br>      &lt;version&gt;1.20.4&lt;/version&gt;<br>      &lt;type&gt;pom&lt;/type&gt;<br>      &lt;scope&gt;import&lt;/scope&gt;<br>    &lt;/dependency&gt;<br>  &lt;/dependencies&gt;<br>&lt;/dependencyManagement&gt;</pre><pre>&lt;dependencies&gt;<br>  &lt;dependency&gt;<br>    &lt;groupId&gt;org.testcontainers&lt;/groupId&gt;<br>    &lt;artifactId&gt;junit-jupiter&lt;/artifactId&gt;<br>    &lt;scope&gt;test&lt;/scope&gt;<br>  &lt;/dependency&gt;<br>  &lt;dependency&gt;<br>    &lt;groupId&gt;org.testcontainers&lt;/groupId&gt;<br>    &lt;artifactId&gt;postgresql&lt;/artifactId&gt;<br>    &lt;scope&gt;test&lt;/scope&gt;<br>  &lt;/dependency&gt;<br>  &lt;!-- Spring Boot brings its own testcontainers integration --&gt;<br>  &lt;dependency&gt;<br>    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;<br>    &lt;artifactId&gt;spring-boot-testcontainers&lt;/artifactId&gt;<br>    &lt;scope&gt;test&lt;/scope&gt;<br>  &lt;/dependency&gt;<br>&lt;/dependencies&gt;</pre><h3>The Core Annotations: @Testcontainers and @Container</h3><p>The JUnit 5 integration centers on two annotations from org.testcontainers.junit.jupiter:</p><ul><li><strong>@Testcontainers</strong> — a class-level annotation that activates the Testcontainers JUnit 5 extension. It scans the class for @Container fields and manages their lifecycle.</li><li><strong>@Container</strong> — marks a container field. A <strong>static</strong> @Container field is started once before all tests in the class and stopped after all of them (container-per-class). A <strong>non-static</strong> (instance) field is started and stopped around <em>each</em> test method (container-per-method).</li></ul><p>In practice you almost always want <strong>static</strong> containers: a fresh container per method is slow, and you can isolate tests via transactions or truncation instead.</p><p>Here is the minimal shape:</p><pre>import org.testcontainers.containers.PostgreSQLContainer;<br>import org.testcontainers.junit.jupiter.Container;<br>import org.testcontainers.junit.jupiter.Testcontainers;</pre><pre>@Testcontainers<br>class RawContainerTest {</pre><pre>    @Container<br>    static PostgreSQLContainer&lt;?&gt; postgres =<br>            new PostgreSQLContainer&lt;&gt;(&quot;postgres:16-alpine&quot;)<br>                    .withDatabaseName(&quot;shop&quot;)<br>                    .withUsername(&quot;test&quot;)<br>                    .withPassword(&quot;test&quot;);</pre><pre>    @Test<br>    void containerIsRunning() {<br>        assertThat(postgres.isRunning()).isTrue();<br>        String jdbcUrl = postgres.getJdbcUrl(); // jdbc:postgresql://localhost:54213/shop<br>        assertThat(jdbcUrl).contains(&quot;postgresql&quot;);<br>    }<br>}</pre><p>Note the version pin: postgres:16-alpine. <strong>Never use </strong><strong>latest</strong> — it makes your tests non-reproducible and can break overnight when the upstream tag moves.</p><h3>A Real PostgreSQL Test with Spring Boot (the old way)</h3><p>Before @ServiceConnection existed, you wired the container&#39;s connection details into Spring&#39;s Environment using @DynamicPropertySource. This is still worth understanding because it works for <em>any</em> property, not just well-known ones.</p><p>Suppose we have an entity, a Spring Data repository, and a Flyway migration that creates a products table with a JSONB column.</p><pre>@Entity<br>@Table(name = &quot;products&quot;)<br>public class Product {</pre><pre>    @Id<br>    @GeneratedValue(strategy = GenerationType.IDENTITY)<br>    private Long id;</pre><pre>    private String name;</pre><pre>    @Column(columnDefinition = &quot;jsonb&quot;)<br>    @JdbcTypeCode(SqlTypes.JSON)<br>    private Map&lt;String, Object&gt; attributes;</pre><pre>    // getters / setters omitted<br>}</pre><pre>public interface ProductRepository extends JpaRepository&lt;Product, Long&gt; {</pre><pre>    @Query(value = &quot;&quot;&quot;<br>            SELECT * FROM products<br>            WHERE attributes -&gt;&gt; &#39;color&#39; = :color<br>            &quot;&quot;&quot;, nativeQuery = true)<br>    List&lt;Product&gt; findByColor(@Param(&quot;color&quot;) String color);<br>}</pre><p>That -&gt;&gt; &#39;color&#39; operator is pure PostgreSQL — H2 cannot run this query. Here&#39;s the test:</p><pre>import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;<br>import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;<br>import org.springframework.test.context.DynamicPropertyRegistry;<br>import org.springframework.test.context.DynamicPropertySource;<br>import org.testcontainers.containers.PostgreSQLContainer;<br>import org.testcontainers.junit.jupiter.Container;<br>import org.testcontainers.junit.jupiter.Testcontainers;</pre><pre>import static org.assertj.core.api.Assertions.assertThat;</pre><pre>@DataJpaTest<br>@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)<br>@Testcontainers<br>class ProductRepositoryTest {</pre><pre>    @Container<br>    static PostgreSQLContainer&lt;?&gt; postgres =<br>            new PostgreSQLContainer&lt;&gt;(&quot;postgres:16-alpine&quot;);</pre><pre>    @DynamicPropertySource<br>    static void datasourceProps(DynamicPropertyRegistry registry) {<br>        registry.add(&quot;spring.datasource.url&quot;, postgres::getJdbcUrl);<br>        registry.add(&quot;spring.datasource.username&quot;, postgres::getUsername);<br>        registry.add(&quot;spring.datasource.password&quot;, postgres::getPassword);<br>    }</pre><pre>    @Autowired<br>    private ProductRepository repository;</pre><pre>    @Test<br>    void findsProductsByJsonbAttribute() {<br>        Product red = new Product();<br>        red.setName(&quot;Red Mug&quot;);<br>        red.setAttributes(Map.of(&quot;color&quot;, &quot;red&quot;, &quot;size&quot;, &quot;M&quot;));</pre><pre>        Product blue = new Product();<br>        blue.setName(&quot;Blue Mug&quot;);<br>        blue.setAttributes(Map.of(&quot;color&quot;, &quot;blue&quot;));</pre><pre>        repository.saveAll(List.of(red, blue));</pre><pre>        List&lt;Product&gt; result = repository.findByColor(&quot;red&quot;);</pre><pre>        assertThat(result).hasSize(1);<br>        assertThat(result.get(0).getName()).isEqualTo(&quot;Red Mug&quot;);<br>    }<br>}</pre><p>Two critical details:</p><ul><li><strong>@AutoConfigureTestDatabase(replace = Replace.NONE)</strong> — by default @DataJpaTest tries to swap in an embedded database. This annotation tells it to keep the real datasource. Forget this and Spring quietly replaces your PostgreSQL with H2 and the JSONB query explodes.</li><li>The lambdas in @DynamicPropertySource are <em>suppliers</em> (postgres::getJdbcUrl), evaluated lazily after the container starts. Pass the value eagerly and you&#39;ll capture a null URL.</li></ul><h3>@ServiceConnection: The Spring Boot 3.1+ Way</h3><p>Spring Boot 3.1 introduced @ServiceConnection, which eliminates the @DynamicPropertySource boilerplate entirely. Spring recognizes the container type (PostgreSQLContainer, KafkaContainer, GenericContainer with a hint, etc.) and auto-configures the matching connection details.</p><p>The same test becomes:</p><pre>import org.springframework.boot.testcontainers.service.connection.ServiceConnection;</pre><pre>@DataJpaTest<br>@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)<br>@Testcontainers<br>class ProductRepositoryServiceConnectionTest {</pre><pre>    @Container<br>    @ServiceConnection<br>    static PostgreSQLContainer&lt;?&gt; postgres =<br>            new PostgreSQLContainer&lt;&gt;(&quot;postgres:16-alpine&quot;);</pre><pre>    @Autowired<br>    private ProductRepository repository;</pre><pre>    @Test<br>    void contextLoadsAgainstRealPostgres() {<br>        assertThat(repository.count()).isZero();<br>    }<br>}</pre><p>No URL wiring. No username/password plumbing. @ServiceConnection reads the container metadata and registers a JdbcConnectionDetails bean. It supports PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Kafka, RabbitMQ, Elasticsearch, Cassandra, and more out of the box.</p><h3>Sharing containers across many test classes</h3><p>For an integration suite, you don’t want each test class to start its own database. Define a base class with a <strong>shared static container</strong> and have your tests extend it. Spring’s test context caching keeps the application context warm too.</p><pre>@SpringBootTest<br>@Testcontainers<br>public abstract class AbstractIntegrationTest {</pre><pre>    @Container<br>    @ServiceConnection<br>    static PostgreSQLContainer&lt;?&gt; postgres =<br>            new PostgreSQLContainer&lt;&gt;(&quot;postgres:16-alpine&quot;);<br>}</pre><p>Because the field is static and lives on the shared base class, the container is started exactly once across all subclasses in the same JVM run.</p><h3>The @TestConfiguration / @Bean style</h3><p>There’s a third, increasingly popular idiom: define containers as Spring beans annotated with @ServiceConnection. This integrates with Spring Boot&#39;s Testcontainers support and even lets you run your main() application locally against containers via SpringApplication.from(...).with(...).</p><pre>@TestConfiguration(proxyBeanMethods = false)<br>public class ContainersConfig {</pre><pre>    @Bean<br>    @ServiceConnection<br>    PostgreSQLContainer&lt;?&gt; postgresContainer() {<br>        return new PostgreSQLContainer&lt;&gt;(&quot;postgres:16-alpine&quot;);<br>    }<br>}</pre><pre>@SpringBootTest<br>@Import(ContainersConfig.class)<br>class OrderServiceIntegrationTest {<br>    // containers managed as beans, started with the context<br>}</pre><h3>Reusing Containers for Fast Feedback</h3><p>The container start cost is paid once per test run. For tight local TDD loops you can do better with <strong>reusable containers</strong>, a Testcontainers feature that keeps the container alive <em>between test runs</em> on your machine.</p><p>Enable it on the container and opt in globally:</p><pre>@Container<br>static PostgreSQLContainer&lt;?&gt; postgres =<br>        new PostgreSQLContainer&lt;&gt;(&quot;postgres:16-alpine&quot;)<br>                .withReuse(true);</pre><p>Then create ~/.testcontainers.properties with:</p><pre>testcontainers.reuse.enable=true</pre><p>With reuse on, Testcontainers computes a hash of the container configuration; if a matching container is already running, it attaches to it instead of starting a new one. Ryuk is skipped for reusable containers (otherwise the reaper would kill them). The tradeoff: you are responsible for state — reuse a database long enough and old test data accumulates. Use transactional rollback or TRUNCATE between tests rather than relying on a fresh container.</p><p>A note on <strong>CI</strong>: reuse is a developer-machine optimization. On CI, each run is a fresh agent, so reuse buys nothing; rely on the Docker layer cache for the image pull instead. Don’t enable reuse expecting CI speedups.</p><h3>Singleton container pattern</h3><p>If you want a single container shared across the <em>entire</em> test suite without inheritance, start it manually in a static initializer and never stop it (the JVM exit cleans it up via Ryuk):</p><pre>public abstract class DatabaseSuite {</pre><pre>    static final PostgreSQLContainer&lt;?&gt; POSTGRES =<br>            new PostgreSQLContainer&lt;&gt;(&quot;postgres:16-alpine&quot;);</pre><pre>    static {<br>        POSTGRES.start(); // started once, on first class load<br>    }</pre><pre>    @DynamicPropertySource<br>    static void props(DynamicPropertyRegistry registry) {<br>        registry.add(&quot;spring.datasource.url&quot;, POSTGRES::getJdbcUrl);<br>        registry.add(&quot;spring.datasource.username&quot;, POSTGRES::getUsername);<br>        registry.add(&quot;spring.datasource.password&quot;, POSTGRES::getPassword);<br>    }<br>}</pre><p>Notice there is <strong>no </strong><strong>@Container and no </strong><strong>@Testcontainers</strong> here — we manage the lifecycle ourselves precisely so JUnit doesn&#39;t stop it after each class.</p><h3>Testing Kafka and Redis Too</h3><p>The same model extends to any infrastructure. The point of Testcontainers is that <em>all</em> your external dependencies become first-class, real test fixtures.</p><h3>Kafka</h3><p>Use org.testcontainers:kafka with the org.testcontainers.kafka.KafkaContainer (the modern Apache-Kafka-native image, no ZooKeeper):</p><pre>import org.testcontainers.kafka.KafkaContainer;</pre><pre>@SpringBootTest<br>@Testcontainers<br>class OrderEventsTest {</pre><pre>    @Container<br>    @ServiceConnection<br>    static KafkaContainer kafka =<br>            new KafkaContainer(&quot;apache/kafka-native:3.8.0&quot;);</pre><pre>    @Autowired<br>    private KafkaTemplate&lt;String, String&gt; kafkaTemplate;</pre><pre>    @Autowired<br>    private OrderEventListener listener; // accumulates received events</pre><pre>    @Test<br>    void publishesAndConsumesOrderEvent() {<br>        kafkaTemplate.send(&quot;orders&quot;, &quot;order-1&quot;, &quot;{\&quot;id\&quot;:1}&quot;);</pre><pre>        await().atMost(Duration.ofSeconds(10))<br>               .untilAsserted(() -&gt;<br>                   assertThat(listener.received()).contains(&quot;order-1&quot;));<br>    }<br>}</pre><p>@ServiceConnection auto-configures spring.kafka.bootstrap-servers. Use Awaitility (org.awaitility:awaitility) for the async assertion — never Thread.sleep.</p><h3>Redis</h3><p>Redis has no dedicated module, so use a GenericContainer and tell @ServiceConnection what it is via the name attribute (matching the redis connection detail), or wire properties manually:</p><pre>import org.testcontainers.containers.GenericContainer;</pre><pre>@SpringBootTest<br>@Testcontainers<br>class CacheIntegrationTest {</pre><pre>    @Container<br>    @ServiceConnection(name = &quot;redis&quot;)<br>    static GenericContainer&lt;?&gt; redis =<br>            new GenericContainer&lt;&gt;(&quot;redis:7-alpine&quot;).withExposedPorts(6379);</pre><pre>    @Autowired<br>    private StringRedisTemplate redisTemplate;</pre><pre>    @Test<br>    void readsBackWhatItWrote() {<br>        redisTemplate.opsForValue().set(&quot;k&quot;, &quot;v&quot;);<br>        assertThat(redisTemplate.opsForValue().get(&quot;k&quot;)).isEqualTo(&quot;v&quot;);<br>    }<br>}</pre><p>The name = &quot;redis&quot; hint matches Spring Boot&#39;s RedisConnectionDetails factory. For containers Spring doesn&#39;t recognize by image, this hint is how you bridge the gap.</p><h3>Pitfalls</h3><p>Real-world Testcontainers usage has sharp edges. Here are the ones that bite teams repeatedly.</p><ul><li><strong>Instance vs static </strong><strong>@Container.</strong> A non-static @Container field restarts the container for <em>every</em> test method. On a class with 30 tests, that&#39;s 30 PostgreSQL boots. Make it static unless you have a specific isolation reason.</li><li><strong>Forgetting </strong><strong>@AutoConfigureTestDatabase(replace = NONE).</strong> With @DataJpaTest, Spring replaces your datasource with an embedded one by default, silently bypassing your container. Your &quot;PostgreSQL test&quot; secretly runs on H2.</li><li><strong>Using </strong><strong>:latest image tags.</strong> Non-reproducible builds. A new upstream release can break your suite with zero code changes. Pin exact versions.</li><li><strong>No Docker in CI.</strong> Testcontainers needs a Docker daemon. On CI you must provide one (Docker-in-Docker, a mounted socket, or a Testcontainers Cloud token). A suite that’s green locally and red in CI is usually a missing daemon.</li><li><strong>Thread.sleep for async assertions.</strong> Kafka/Redis tests are inherently asynchronous. Sleeping is flaky and slow. Use Awaitility&#39;s await().untilAsserted(...).</li><li><strong>State leaking between tests.</strong> With shared/reused containers, data persists across tests. Wrap each test in a transaction that rolls back (@DataJpaTest does this automatically), or TRUNCATE in a @BeforeEach/@AfterEach. Order-dependent tests are a symptom of leaked state.</li><li><strong>Weak wait strategies.</strong> A container reporting “running” isn’t the same as “ready.” The bundled modules ship sensible wait strategies, but for a raw GenericContainer add .waitingFor(Wait.forLogMessage(...)) or .waitingFor(Wait.forListeningPort()) so you don&#39;t connect to a half-booted service.</li><li><strong>Ryuk disabled but containers leak.</strong> Some locked-down CI environments disable Ryuk (TESTCONTAINERS_RYUK_DISABLED=true). If you do this, you own cleanup. Orphaned containers will pile up and exhaust disk/ports.</li><li><strong>Pulling images on the critical path.</strong> First run pulls images and is slow. Pre-pull in CI (docker pull postgres:16-alpine) before the test stage, or warm the cache, so the timing isn&#39;t attributed to flaky tests.</li></ul><h3>Wrapping Up</h3><p>The argument for Testcontainers is simple: test against what you ship. H2 and mocks give you a green suite that doesn’t actually exercise your SQL, your migrations, your serialization, or your locking. Testcontainers gives you a real PostgreSQL, a real Kafka, a real Redis — disposable, isolated, and fast enough with image caching and reuse.</p><p>The progression to adopt: start with @Testcontainers + a static @Container, switch to @ServiceConnection to delete the wiring boilerplate, share containers via a base class, and turn on local reuse for tight feedback loops. Once the pattern clicks, you&#39;ll wonder how you ever trusted a green H2 build.</p><p><em>Found this useful? Follow </em><strong><em>devdomain</em></strong><em> for more deep-dive Java and testing articles, and drop a comment with the trickiest integration-testing problem you’ve hit — real-world war stories make the best follow-up posts.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a9a78386e4f8" width="1" height="1" alt=""><hr><p><a href="https://medium.com/devdomain/integration-testing-with-testcontainers-real-databases-in-your-test-suiteyou-wrote-a-repository-a9a78386e4f8">Integration Testing with Testcontainers: Real Databases in Your Test SuiteYou wrote a repository…</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[Java NIO: Channels, Buffers, and Non-Blocking I/OIf you’ve ever written a server that spins up a…]]></title>
            <link>https://medium.com/devdomain/java-nio-channels-buffers-and-non-blocking-i-oif-youve-ever-written-a-server-that-spins-up-a-3a0894b6774e?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/3a0894b6774e</guid>
            <category><![CDATA[java]]></category>
            <category><![CDATA[jvm]]></category>
            <category><![CDATA[nio]]></category>
            <category><![CDATA[networking]]></category>
            <category><![CDATA[concurrency]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Tue, 22 Sep 2026 13:56:01 GMT</pubDate>
            <atom:updated>2026-09-22T13:56:01.544Z</atom:updated>
            <content:encoded><![CDATA[<p>Java NIO: Channels, Buffers, and Non-Blocking I/OIf you’ve ever written a server that spins up a thread per connection and watched it collapse under a few thousand idle clients, you’ve felt the limits of classic blocking I/O. Java NIO (New I/O, introduced in Java 1.4 and expanded as NIO.2 in Java 7) exists to break that ceiling. It gives you <strong>buffers</strong>, <strong>channels</strong>, and crucially <strong>non-blocking I/O with selectors</strong> — the foundation that lets a single thread service tens of thousands of connections.</p><p>NIO has a reputation for being fiddly, and the ByteBuffer API in particular trips people up. This article builds the model carefully: why blocking I/O doesn&#39;t scale, exactly how a buffer&#39;s position/limit/capacity work, what channels and selectors give you, and how it all assembles into the reactor pattern that powers Netty and the reactive stack.</p><h3>The Limits of Classic Blocking I/O</h3><p>The traditional java.io model is stream-based and blocking. A read blocks the calling thread until data arrives:</p><pre>// Classic blocking server — one thread per connection<br>try (ServerSocket server = new ServerSocket(8080)) {<br>    while (true) {<br>        Socket socket = server.accept();        // blocks until a client connects<br>        new Thread(() -&gt; handle(socket)).start(); // dedicate a thread to it<br>    }<br>}</pre><pre>static void handle(Socket socket) {<br>    try (var in = socket.getInputStream()) {<br>        byte[] buf = new byte[1024];<br>        int n = in.read(buf);   // BLOCKS until bytes arrive — thread is stuck<br>        // ... process ...<br>    } catch (IOException e) { /* ... */ }<br>}</pre><p>This is beautifully simple but doesn’t scale, for two reasons:</p><ul><li><strong>Thread cost.</strong> Each thread costs ~512 KB–1 MB of stack plus kernel scheduling overhead. Ten thousand connections means ten thousand threads — gigabytes of stack and brutal context-switching.</li><li><strong>Idle waste.</strong> Most connections are idle most of the time (think chat, keep-alive HTTP, websockets). A blocked thread waiting on a quiet socket does nothing but consume resources.</li></ul><p>The fundamental problem: <strong>blocking ties a thread to a connection for the connection’s entire lifetime, whether or not data is flowing.</strong> NIO decouples them: one thread can watch many connections and act only on the ones that are ready.</p><h3>Buffers: The Heart of NIO</h3><p>In NIO you don’t read/write bytes directly — you move them through a <strong>Buffer</strong>. A buffer is a fixed-size container backed by an array (or off-heap memory). The most important is ByteBuffer; there are typed variants (IntBuffer, CharBuffer, etc.).</p><p>A buffer tracks <strong>three indices</strong>, and understanding them is the whole game:</p><p>Property Meaning Invariant capacity total size, fixed at creation constant position index of the next byte to read/write 0 &lt;= position &lt;= limit limit first index you must NOT touch position &lt;= limit &lt;= capacity</p><p>There’s also mark, an optional remembered position you can reset() to.</p><h3>The fill / drain cycle</h3><p>A buffer is always in one of two phases — <strong>write mode</strong> (you put data in) or <strong>read mode</strong> (you take data out). You switch between them with flip(). Picture an empty 8-byte buffer:</p><pre>After allocate(8) — write mode:<br>  pos=0                          limit=cap=8<br>   v                              v<br>  [ . . . . . . . . ]<br>   ^ next write goes here</pre><pre>After writing &quot;Hi&quot; (2 bytes):<br>       pos=2                     limit=cap=8<br>        v                         v<br>  [ H i . . . . . . ]</pre><pre>After flip() — read mode (limit=old pos, pos=0):<br>  pos=0   limit=2<br>   v       v<br>  [ H i . . . . . . ]<br>   ^ next read         ^ stop reading here</pre><pre>After reading both bytes:<br>       pos=2,limit=2<br>        v<br>  [ H i . . . . . . ]</pre><pre>After clear() — back to write mode (pos=0, limit=cap):<br>  pos=0                          limit=cap=8</pre><p>The canonical loop:</p><pre>ByteBuffer buf = ByteBuffer.allocate(1024);</pre><pre>int bytesRead = channel.read(buf);   // channel WRITES into buffer (fill)<br>buf.flip();                          // switch to read mode<br>while (buf.hasRemaining()) {<br>    byte b = buf.get();              // drain<br>    // ... process b ...<br>}<br>buf.clear();                         // reset for the next fill</pre><p>The four buffer “verbs” you must know cold:</p><ul><li><strong>flip()</strong> — finish writing, prepare to read: limit = position; position = 0.</li><li><strong>clear()</strong> — discard contents, prepare to write again: position = 0; limit = capacity. (Doesn&#39;t erase data; just resets indices.)</li><li><strong>rewind()</strong> — re-read from the start: position = 0, limit unchanged.</li><li><strong>compact()</strong> — for partial reads: move unread bytes to the front, then position after them, ready to write more.</li></ul><h3>Heap vs direct buffers</h3><pre>ByteBuffer heap   = ByteBuffer.allocate(1024);       // lives in JVM heap<br>ByteBuffer direct = ByteBuffer.allocateDirect(1024); // off-heap, native memory</pre><p>A <strong>direct buffer</strong> lives outside the Java heap. The OS can perform I/O on it without an extra copy to/from the heap, which makes large or frequent transfers faster. The trade-offs: allocation is more expensive, it’s not subject to normal GC (freed via a Cleaner), and it costs native memory. <strong>Rule of thumb:</strong> use direct buffers for long-lived, large, I/O-heavy buffers; heap buffers for small, short-lived ones.</p><h3>Channels: Bidirectional Conduits</h3><p>A <strong>Channel</strong> represents an open connection to something that does I/O — a file, a socket, a pipe. Unlike streams, channels are <strong>bidirectional</strong> (a socket channel both reads and writes) and always operate through buffers.</p><p>Key channel types:</p><ul><li>FileChannel — file I/O, memory-mapping, zero-copy transfers.</li><li>SocketChannel — a TCP connection (client side).</li><li>ServerSocketChannel — accepts incoming TCP connections.</li><li>DatagramChannel — UDP.</li></ul><h3>File I/O with a channel</h3><pre>import java.io.IOException;<br>import java.nio.ByteBuffer;<br>import java.nio.channels.FileChannel;<br>import java.nio.file.Path;<br>import java.nio.file.StandardOpenOption;</pre><pre>public class FileCopy {<br>    public static void main(String[] args) throws IOException {<br>        Path src = Path.of(&quot;input.txt&quot;);<br>        Path dst = Path.of(&quot;output.txt&quot;);</pre><pre>        try (FileChannel in  = FileChannel.open(src, StandardOpenOption.READ);<br>             FileChannel out = FileChannel.open(dst,<br>                     StandardOpenOption.CREATE,<br>                     StandardOpenOption.WRITE,<br>                     StandardOpenOption.TRUNCATE_EXISTING)) {</pre><pre>            ByteBuffer buf = ByteBuffer.allocateDirect(8192);<br>            while (in.read(buf) != -1) {   // fill<br>                buf.flip();                // switch to read<br>                out.write(buf);            // drain into the other channel<br>                buf.compact();             // keep any unwritten bytes<br>            }<br>        }<br>    }<br>}</pre><h3>Zero-copy transfer</h3><p>FileChannel can transfer bytes directly between channels in the kernel, skipping user space entirely — the basis of efficient file serving:</p><pre>try (FileChannel in = FileChannel.open(Path.of(&quot;big.iso&quot;), StandardOpenOption.READ)) {<br>    // hands the bytes straight to the socket without copying through the JVM<br>    in.transferTo(0, in.size(), socketChannel);<br>}</pre><h3>Non-Blocking Mode and Selectors</h3><p>This is where NIO earns its keep. A SelectableChannel (sockets, not files) can be put into <strong>non-blocking mode</strong>:</p><pre>SocketChannel ch = SocketChannel.open();<br>ch.configureBlocking(false);  // reads/writes return immediately</pre><p>In non-blocking mode, read() returns whatever bytes are available <em>right now</em> (possibly zero) and never blocks. By itself that would force you to busy-poll. The solution is the <strong>Selector</strong> — an object that lets one thread register many channels and ask the OS, &quot;which of these are ready for I/O?&quot; This maps directly to the kernel&#39;s epoll/kqueue mechanisms.</p><p>You register a channel with a selector for a set of <strong>interest operations</strong>:</p><ul><li>OP_ACCEPT — a server socket has an incoming connection</li><li>OP_CONNECT — a client connect completed</li><li>OP_READ — data is available to read</li><li>OP_WRITE — the channel can accept writes</li></ul><p>selector.select() blocks until at least one registered channel is ready, then hands you the ready set. One thread, many connections.</p><h3>A complete non-blocking echo server</h3><pre>import java.io.IOException;<br>import java.net.InetSocketAddress;<br>import java.nio.ByteBuffer;<br>import java.nio.channels.*;<br>import java.util.Iterator;</pre><pre>public class NioEchoServer {</pre><pre>    public static void main(String[] args) throws IOException {<br>        Selector selector = Selector.open();</pre><pre>        ServerSocketChannel server = ServerSocketChannel.open();<br>        server.bind(new InetSocketAddress(8080));<br>        server.configureBlocking(false);<br>        server.register(selector, SelectionKey.OP_ACCEPT);</pre><pre>        System.out.println(&quot;Echo server listening on :8080&quot;);</pre><pre>        while (true) {<br>            selector.select();                     // blocks until something is ready<br>            Iterator&lt;SelectionKey&gt; it = selector.selectedKeys().iterator();</pre><pre>            while (it.hasNext()) {<br>                SelectionKey key = it.next();<br>                it.remove();                       // MUST remove — selector won&#39;t</pre><pre>                try {<br>                    if (key.isAcceptable()) {<br>                        accept(server, selector);<br>                    } else if (key.isReadable()) {<br>                        echo(key);<br>                    }<br>                } catch (IOException e) {<br>                    key.cancel();<br>                    key.channel().close();<br>                }<br>            }<br>        }<br>    }</pre><pre>    private static void accept(ServerSocketChannel server, Selector selector)<br>            throws IOException {<br>        SocketChannel client = server.accept();    // never null here<br>        client.configureBlocking(false);<br>        // attach a per-connection buffer so each client has its own state<br>        client.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));<br>        System.out.println(&quot;Accepted &quot; + client.getRemoteAddress());<br>    }</pre><pre>    private static void echo(SelectionKey key) throws IOException {<br>        SocketChannel client = (SocketChannel) key.channel();<br>        ByteBuffer buf = (ByteBuffer) key.attachment();</pre><pre>        int n = client.read(buf);                  // non-blocking read<br>        if (n == -1) {                             // client closed<br>            key.cancel();<br>            client.close();<br>            return;<br>        }<br>        buf.flip();<br>        client.write(buf);                         // echo back<br>        buf.compact();                             // retain unwritten bytes<br>    }<br>}</pre><p>The whole server runs on <strong>one thread</strong> yet handles many simultaneous clients. Note the critical detail: <strong>you must call </strong><strong>it.remove()</strong> on each selected key. The selector adds keys to the selected set but never removes them; forget this and you&#39;ll reprocess stale keys forever.</p><h3>The Reactor Pattern</h3><p>That event loop is the <strong>reactor pattern</strong>, a foundational design for scalable network servers:</p><pre>┌──────────────────────────────────────────┐<br>        │              Reactor (1 thread)           │<br>        │                                           │<br>        │   ┌─────────────┐    selector.select()    │<br>        │   │  Selector   │◄──────────┐             │<br>        │   └─────────────┘           │ ready keys  │<br>        │         │ dispatch          │             │<br>        │         ▼                   │             │<br>        │   ┌──────────┬──────────┬──────────┐      │<br>        │   │ ACCEPT   │  READ    │  WRITE   │      │<br>        │   │ handler  │ handler  │ handler  │      │<br>        │   └──────────┴──────────┴──────────┘      │<br>        └──────────────────────────────────────────┘<br>                 │            │            │<br>              client A     client B     client C</pre><p>The reactor blocks once in select(), then <strong>dispatches</strong> ready events to handlers. Real systems extend this with a <strong>multi-reactor</strong> design: one acceptor thread plus a pool of worker reactors (often one per CPU core), each owning a selector and a slice of the connections. CPU-bound or blocking work is offloaded to a separate thread pool so the event loop never stalls.</p><h3>When NIO Actually Matters</h3><p>Be honest about whether you need it. NIO’s complexity pays off when:</p><ul><li>You have <strong>many concurrent, mostly-idle connections</strong> (chat, websockets, IoT, long-polling, push). This is the C10k problem NIO was built for.</li><li>You’re building a <strong>protocol server or proxy</strong> where connection count dwarfs CPU work per request.</li><li>You need <strong>zero-copy file serving</strong> or memory-mapped files (FileChannel.map).</li></ul><p>It’s usually <em>not</em> worth it when:</p><ul><li>Connections are few or short-lived — thread-per-connection is simpler and plenty fast.</li><li>Each request is CPU-heavy — you’re bound by compute, not by waiting on I/O.</li></ul><p>Modern alternatives also change the calculus. <strong>Virtual threads (Project Loom, Java 21)</strong> let you write simple blocking-style code that scales to millions of “threads,” reclaiming much of NIO’s benefit without the buffer gymnastics. But virtual threads run <em>on top of</em> the JDK’s NIO machinery under the hood — so understanding NIO still matters.</p><h3>Relationship to Netty and Reactive Stacks</h3><p>Almost nobody writes raw selector loops in production. <strong>Netty</strong> wraps NIO in a battle-tested, ergonomic framework: it manages the reactor (its EventLoopGroup), pools direct buffers (ByteBuf with reference counting and pooling), and gives you a ChannelPipeline of composable handlers for encoding, decoding, TLS, and business logic. Netty fixes the rough edges of ByteBuffer (no manual flip/clip) and gracefully handles partial reads, backpressure, and write batching.</p><p>On top of Netty sit the <strong>reactive frameworks</strong> — Spring WebFlux, Project Reactor, Vert.x. They expose the non-blocking event loop through Mono/Flux (or futures) so you compose asynchronous pipelines without ever touching a selector. The chain looks like this:</p><pre>Your code (WebFlux / Vert.x)<br>        │  reactive streams, backpressure<br>        ▼<br>   Netty (event loop, ByteBuf, pipeline)<br>        │<br>        ▼<br>   Java NIO (Selector, Channel, ByteBuffer)<br>        │<br>        ▼<br>   OS (epoll / kqueue / IOCP)</pre><p>Understanding NIO is understanding the bottom of that stack — why event loops must never block, why buffers are pooled, why backpressure exists.</p><h3>Pitfalls and Gotchas</h3><ul><li><strong>Forgetting </strong><strong>flip().</strong> Writing into a buffer then trying to read without flipping reads garbage (you read from the wrong position). The fill→flip()→drain rhythm is non-negotiable.</li><li><strong>Forgetting </strong><strong>selectedKeys().remove().</strong> The selector never clears the selected set; omitting it.remove() causes endless reprocessing of stale keys.</li><li><strong>clear() doesn&#39;t erase data.</strong> It only resets the indices. The old bytes are still there until overwritten — never assume a &quot;cleared&quot; buffer is zeroed.</li><li><strong>Blocking inside the event loop.</strong> A single slow database call or Thread.sleep on the reactor thread stalls <em>every</em> connection it manages. Offload blocking work to a separate executor.</li><li><strong>Partial reads and writes.</strong> Non-blocking read()/write() transfer <em>as much as possible right now</em>, not everything. Use compact() and re-register OP_WRITE to drain the rest; never assume one call moved all the bytes.</li><li><strong>Leaking direct buffers.</strong> They’re freed by a Cleaner, not promptly by clear(). Allocating many short-lived direct buffers causes native-memory bloat and OutOfMemoryError: Direct buffer memory. Pool them or use heap buffers for transient data.</li><li><strong>Reusing a buffer across connections without isolation.</strong> Each connection needs its own buffer state — attach a buffer per SelectionKey (as in the example) rather than sharing one.</li><li><strong>Not handling </strong><strong>read() == -1.</strong> A return of -1 means the peer closed the connection; you must cancel the key and close the channel, or you&#39;ll spin on a dead socket.</li></ul><h3>Wrapping Up</h3><p>Java NIO replaces the thread-per-connection model with an event-driven one: <strong>buffers</strong> shuttle bytes, <strong>channels</strong> are the bidirectional conduits, and <strong>selectors</strong> let a single thread watch thousands of connections and act only on the ready ones — the reactor pattern.</p><p>Keep these anchors:</p><ul><li>Master the ByteBuffer lifecycle — position/limit/capacity and the fill→flip()→drain→clear() cycle.</li><li>Non-blocking mode + a Selector is what makes one thread scale to many idle connections.</li><li>You’ll usually consume NIO through Netty or a reactive framework — but those just wrap exactly the machinery shown here.</li></ul><p>Reach for NIO (or its descendants) when connection count, not CPU, is your bottleneck. For everything else, simpler blocking code — or virtual threads — may serve you better.</p><p><em>If this clarified NIO for you, follow </em><strong><em>devdomain</em></strong><em> for more JVM and networking deep dives. Building something on Netty or virtual threads? Share your war stories in the comments.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3a0894b6774e" width="1" height="1" alt=""><hr><p><a href="https://medium.com/devdomain/java-nio-channels-buffers-and-non-blocking-i-oif-youve-ever-written-a-server-that-spins-up-a-3a0894b6774e">Java NIO: Channels, Buffers, and Non-Blocking I/OIf you’ve ever written a server that spins up 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[Spring Cloud Config: Centralized, Versioned Configuration for Microservices]]></title>
            <link>https://medium.com/devdomain/spring-cloud-config-centralized-versioned-configuration-for-microservices-e76785c87c2c?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/e76785c87c2c</guid>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[spring-cloud]]></category>
            <category><![CDATA[microservices]]></category>
            <category><![CDATA[configuration]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Thu, 17 Sep 2026 13:36:01 GMT</pubDate>
            <atom:updated>2026-09-17T13:36:01.614Z</atom:updated>
            <content:encoded><![CDATA[<p>You start with three microservices. Each has an application.yml. Life is good.</p><p>Eighteen months later you have forty services, four environments, and a database password copy-pasted into nineteen of them. Someone rotates that password on a Friday. You spend the weekend grepping YAML.</p><p>This is <strong>config sprawl</strong>, and it is one of the quietest ways a microservices platform rots. Spring Cloud Config exists to fix it.</p><h3>The problem we are actually solving</h3><p>In a fleet of N services across M environments, naive configuration creates N × M files that all drift independently. The symptoms are predictable:</p><ul><li><strong>No single source of truth.</strong> The “real” value of feature.x.enabled lives in whichever pod last restarted.</li><li><strong>No audit trail.</strong> “Who changed the connection pool size and when?” has no answer. There is no diff, no blame, no review.</li><li><strong>Redeploy to change a value.</strong> Tweaking a timeout means a new image build, a CI pipeline run, and a rolling restart. Configuration and code share a release cadence they should not.</li><li><strong>Secrets scattered everywhere.</strong> Plaintext credentials end up baked into images, committed to repos, and pasted into Slack.</li></ul><p>The goal is a <strong>configuration control plane</strong>: one place that is versioned, reviewable, environment-aware, secure, and changeable at runtime. Spring Cloud Config gives you exactly that, with Git as the storage and history engine.</p><h3>Architecture at a glance</h3><pre>+------------------------------+<br>                       |        Git repository        |<br>                       |  config-repo/                |<br>                       |    application.yml           |<br>                       |    order-service.yml         |<br>                       |    order-service-prod.yml    |<br>                       |    payment-service-dev.yml   |<br>                       |  (branches = labels)         |<br>                       +---------------+--------------+<br>                                       | clone / pull<br>                                       v<br>                       +------------------------------+<br>                       |     Config Server :8888      |<br>                       |   @EnableConfigServer        |<br>                       |   /{app}/{profile}/{label}   |<br>                       |   /encrypt  /decrypt         |<br>                       +------+---------------+-------+<br>                              |               |<br>              spring.config.import           |<br>              (on startup + on refresh)      |<br>                              |               |<br>        +---------------------+----+   +------+-------------------+<br>        |   order-service          |   |   payment-service        |<br>        |   @RefreshScope beans     |   |   @RefreshScope beans     |<br>        |   /actuator/refresh       |   |   /actuator/refresh       |<br>        |   /actuator/busrefresh    |   |   /actuator/busrefresh    |<br>        +-------------+-------------+   +------------+-------------+<br>                      ^                              ^<br>                      |                              |<br>                      +-------------+----------------+<br>                                    |<br>                          +---------+----------+<br>                          |   Spring Cloud Bus  |<br>                          |   (RabbitMQ / Kafka) |<br>                          |  fan-out RefreshEvent |<br>                          +----------------------+</pre><p>The flow: services pull config from the server at startup. The server reads it from Git. When config changes, you can fan out a single busrefresh and every subscribed client reloads without a restart.</p><h3>Prerequisites</h3><p>Spring Boot 3.x requires <strong>Java 17+</strong>. The examples below target the Spring Cloud <strong>2023.x / 2024.x</strong> release train, which is the line that pairs with Spring Boot 3.2–3.3.</p><p>Pin the train with a BOM so every Spring Cloud artifact lines up:</p><pre>&lt;dependencyManagement&gt;<br>  &lt;dependencies&gt;<br>    &lt;dependency&gt;<br>      &lt;groupId&gt;org.springframework.cloud&lt;/groupId&gt;<br>      &lt;artifactId&gt;spring-cloud-dependencies&lt;/artifactId&gt;<br>      &lt;version&gt;2023.0.3&lt;/version&gt;<br>      &lt;type&gt;pom&lt;/type&gt;<br>      &lt;scope&gt;import&lt;/scope&gt;<br>    &lt;/dependency&gt;<br>  &lt;/dependencies&gt;<br>&lt;/dependencyManagement&gt;</pre><h3>The Config Server</h3><p>The server is a thin Spring Boot app that serves configuration over HTTP. Its dependency:</p><pre>&lt;dependency&gt;<br>  &lt;groupId&gt;org.springframework.cloud&lt;/groupId&gt;<br>  &lt;artifactId&gt;spring-cloud-config-server&lt;/artifactId&gt;<br>&lt;/dependency&gt;</pre><p>Enable it with one annotation:</p><pre>package com.devdomain.configserver;</pre><pre>import org.springframework.boot.SpringApplication;<br>import org.springframework.boot.autoconfigure.SpringBootApplication;<br>import org.springframework.cloud.config.server.EnableConfigServer;</pre><pre>@EnableConfigServer<br>@SpringBootApplication<br>public class ConfigServerApplication {</pre><pre>    public static void main(String[] args) {<br>        SpringApplication.run(ConfigServerApplication.class, args);<br>    }<br>}</pre><p>Point it at a Git repo in application.yml:</p><pre>server:<br>  port: 8888</pre><pre>spring:<br>  application:<br>    name: config-server<br>  cloud:<br>    config:<br>      server:<br>        git:<br>          uri: <a href="https://github.com/acme/config-repo.git">https://github.com/acme/config-repo.git</a><br>          default-label: main<br>          clone-on-start: true<br>          timeout: 10<br>          # Only scan these folders for {application}-{profile}.yml files<br>          search-paths:<br>            - services/{application}<br>            - shared</pre><p>A few production-relevant knobs:</p><ul><li>clone-on-start: true clones the repo at boot so the first client request is fast and you fail loud at startup if the repo is unreachable.</li><li>default-label is the branch used when a client does not request one. main for most teams.</li><li>search-paths lets you organize the repo into subfolders. The {application} placeholder is expanded per request, so services/order-service/order-service-prod.yml resolves correctly.</li></ul><h3>How the server maps requests to files</h3><p>Spring Cloud Config exposes a small, well-defined REST API. The canonical endpoint is:</p><pre>/{application}/{profile}[/{label}]</pre><ul><li><strong>application</strong> = the requesting service&#39;s spring.application.name.</li><li><strong>profile</strong> = the active Spring profile(s), e.g. dev, prod. Comma-separated for multiple.</li><li><strong>label</strong> = a Git ref: a <strong>branch, tag, or commit</strong>. Optional; defaults to default-label.</li></ul><p>The server resolves files in the repo using the {application}-{profile}.yml (or .properties) naming convention, and layers them in a defined order:</p><p>Repo file When it applies Precedence (high → low) order-service-prod.yml app = order-service, profile = prod Highest order-service.yml app = order-service, any profile Middle application-prod.yml any app, profile = prod Lower application.yml global defaults for every app Lowest</p><p>More specific wins. A property in order-service-prod.yml overrides the same key in application.yml. This lets you keep shared defaults in application.yml and override only what differs per service and environment.</p><p>You can hit the API directly to debug:</p><pre># order-service, prod profile, main branch<br>curl http://localhost:8888/order-service/prod</pre><pre># pin to a specific Git tag (label)<br>curl <a href="http://localhost:8888/order-service/prod/v2.4.0">http://localhost:8888/order-service/prod/v2.4.0</a></pre><pre># raw rendered YAML as a service would consume it<br>curl <a href="http://localhost:8888/order-service-prod.yml">http://localhost:8888/order-service-prod.yml</a></pre><p>The JSON response includes a propertySources array, ordered exactly as the server layered them — invaluable when a value is not what you expect.</p><h3>The client side</h3><p>Each microservice becomes a Config <strong>client</strong> with:</p><pre>&lt;dependency&gt;<br>  &lt;groupId&gt;org.springframework.cloud&lt;/groupId&gt;<br>  &lt;artifactId&gt;spring-cloud-starter-config&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-actuator&lt;/artifactId&gt;<br>&lt;/dependency&gt;</pre><h3>Goodbye bootstrap.yml, hello spring.config.import</h3><p>If you learned Spring Cloud before 2020, you remember bootstrap.yml and a separate &quot;bootstrap context&quot; that ran before the main application context to fetch remote config. <strong>That is legacy.</strong></p><p>Since <strong>Spring Boot 2.4</strong> (and unchanged in 3.x), the bootstrap context is <strong>disabled by default</strong>. The modern, recommended approach is the unified spring.config.import mechanism, which loads remote config as a first-class part of the normal application.yml processing:</p><pre>spring:<br>  application:<br>    name: order-service<br>  profiles:<br>    active: prod<br>  config:<br>    import: &quot;optional:configserver:http://localhost:8888&quot;<br>  cloud:<br>    config:<br>      # request a specific Git label; defaults to server&#39;s default-label<br>      label: main<br>      # how the client behaves if the server is down at startup<br>      fail-fast: true<br>      retry:<br>        max-attempts: 6<br>        initial-interval: 1000<br>        max-interval: 2000<br>        multiplier: 1.1</pre><p>Two details that bite people:</p><ul><li>The optional: prefix means &quot;do not crash at startup if the config server is unreachable.&quot; Drop optional: (or set fail-fast: true) when config is mandatory and you would rather fail loudly than boot with defaults. The two are subtly different: optional: controls whether a <em>missing import</em> is fatal; fail-fast controls whether <em>connection/retry exhaustion</em> is fatal. In production you usually want config to be mandatory.</li><li>You only need spring-cloud-starter-bootstrap if you are deliberately keeping the old bootstrap flow alive (or pulling in a library that still requires it). For new services, do not add it.</li></ul><h3>Consuming config in code</h3><p>Inject values like any other property. The cleanest pattern is @ConfigurationProperties, which binds a whole tree and refreshes cleanly:</p><pre>package com.devdomain.order.config;</pre><pre>import org.springframework.boot.context.properties.ConfigurationProperties;</pre><pre>@ConfigurationProperties(prefix = &quot;order&quot;)<br>public class OrderProperties {</pre><pre>    private int maxItemsPerOrder = 50;<br>    private Pricing pricing = new Pricing();</pre><pre>    public int getMaxItemsPerOrder() { return maxItemsPerOrder; }<br>    public void setMaxItemsPerOrder(int v) { this.maxItemsPerOrder = v; }<br>    public Pricing getPricing() { return pricing; }<br>    public void setPricing(Pricing p) { this.pricing = p; }</pre><pre>    public static class Pricing {<br>        private double taxRate = 0.0;<br>        public double getTaxRate() { return taxRate; }<br>        public void setTaxRate(double v) { this.taxRate = v; }<br>    }<br>}</pre><p>Backed by this in order-service-prod.yml:</p><pre>order:<br>  max-items-per-order: 200<br>  pricing:<br>    tax-rate: 0.21</pre><p>Enable it on a config class:</p><pre>package com.devdomain.order.config;</pre><pre>import org.springframework.boot.context.properties.EnableConfigurationProperties;<br>import org.springframework.context.annotation.Configuration;</pre><pre>@Configuration<br>@EnableConfigurationProperties(OrderProperties.class)<br>public class OrderConfig {<br>}</pre><h3>Refreshing config at runtime</h3><p>The headline feature: change a value in Git, and update running services <strong>without a redeploy</strong>.</p><h3>@RefreshScope and /actuator/refresh</h3><p>Expose the refresh endpoint:</p><pre>management:<br>  endpoints:<br>    web:<br>      exposure:<br>        include: refresh,busrefresh,health,info</pre><p>Annotate beans whose state depends on config that you want to be re-read:</p><pre>package com.devdomain.order.service;</pre><pre>import com.devdomain.order.config.OrderProperties;<br>import org.springframework.beans.factory.annotation.Value;<br>import org.springframework.cloud.context.config.annotation.RefreshScope;<br>import org.springframework.stereotype.Service;</pre><pre>@RefreshScope<br>@Service<br>public class PricingService {</pre><pre>    private final OrderProperties props;</pre><pre>    @Value(&quot;${order.pricing.surcharge:0.0}&quot;)<br>    private double surcharge;</pre><pre>    public PricingService(OrderProperties props) {<br>        this.props = props;<br>    }</pre><pre>    public double quote(double base) {<br>        return base * (1 + props.getPricing().getTaxRate()) + surcharge;<br>    }<br>}</pre><p>How @RefreshScope actually works: it wraps the bean in a proxy. When a RefreshEvent fires, the cached instance is discarded and recreated lazily on the next call — picking up new config in the process.</p><p>Trigger it for a single instance:</p><pre>curl -X POST http://localhost:9001/actuator/refresh</pre><p>The response lists the property keys that changed:</p><pre>[&quot;order.pricing.tax-rate&quot;,&quot;order.pricing.surcharge&quot;]</pre><p>@ConfigurationProperties beans are refreshed automatically by the refresh machinery — you do not even need @RefreshScope on them. That is a strong reason to prefer them over scattered @Value fields.</p><h3>Spring Cloud Bus and /actuator/busrefresh — fan-out</h3><p>Calling /actuator/refresh on one pod is fine for a single instance. With dozens of replicas across services, you do not want to script a loop over every instance.</p><p><strong>Spring Cloud Bus</strong> connects all instances to a shared message broker (RabbitMQ or Kafka) and broadcasts a single RefreshRemoteApplicationEvent. Hit /actuator/busrefresh on <strong>any one</strong> instance and <strong>every</strong> subscribed instance refreshes.</p><p>Add the AMQP bus starter to each client (and optionally the server, so it can originate the event):</p><pre>&lt;dependency&gt;<br>  &lt;groupId&gt;org.springframework.cloud&lt;/groupId&gt;<br>  &lt;artifactId&gt;spring-cloud-starter-bus-amqp&lt;/artifactId&gt;<br>&lt;/dependency&gt;</pre><p>Point at the broker:</p><pre>spring:<br>  rabbitmq:<br>    host: rabbitmq.internal<br>    port: 5672<br>    username: bus<br>    password: &#39;{cipher}AQB7c2f...&#39;   # encrypted, see below</pre><p>Trigger a fleet-wide refresh:</p><pre># any single instance broadcasts to all<br>curl -X POST http://localhost:9001/actuator/busrefresh</pre><p>You can even scope it to a subset by destination:</p><p>``bbash</p><h3>only order-service instances refresh</h3><p>curl -X POST “<a href="http://localhost:9001/actuator/busrefresh/order-service">http://localhost:9001/actuator/busrefresh/order-service</a>:**&quot;</p><pre>The clean production pattern: a Git push triggers a webhook → the Config Server&#39;s `/monitor` endpoint (from `spring-cloud-config-monitor`) publishes the bus event → all affected services refresh. No human runs curl at all.</pre><p>git push → webhook → Config Server /monitor → Bus event → all clients refresh</p><pre>## Encrypting secrets</pre><pre>Plaintext secrets in a Git repo are a non-starter. Spring Cloud Config encrypts values at rest in the repo and decrypts them when serving config. Encrypted values carry a **`{cipher}`** prefix.</pre><pre>### Symmetric vs asymmetric</pre><pre>| Aspect            | Symmetric (`encrypt.key`)            | Asymmetric (RSA keystore)                 |<br>|-------------------|--------------------------------------|-------------------------------------------|<br>| Key material      | One shared secret                    | Public/private key pair                    |<br>| Encrypt           | Needs the shared key                 | Needs only the public key                  |<br>| Decrypt           | Needs the shared key                 | Needs the private key                      |<br>| Rotation          | Re-encrypt everything at once         | Can hand out public key freely             |<br>| Setup complexity  | Trivial                              | Requires a keystore (JKS/PKCS12)           |<br>| Best for          | Small teams, getting started         | Larger orgs, separation of duties          |</pre><pre>**Symmetric** is the quickest start. Set a key on the server (via environment variable, never hard-coded):</pre><pre>```yaml<br>encrypt:<br>  key: ${CONFIG_ENCRYPT_KEY}</pre><p><strong>Asymmetric</strong> uses a keystore. Generate and configure:</p><pre>keytool -genkeypair -alias configkey -keyalg RSA -keysize 2048 \<br>  -dname &quot;CN=Config Server,OU=Platform,O=Acme,L=Lisbon,C=PT&quot; \<br>  -keystore config-server.jks -storepass changeit -keypass changeit</pre><pre>encrypt:<br>  key-store:<br>    location: file:/etc/config-server/config-server.jks<br>    password: ${KEYSTORE_PASSWORD}<br>    alias: configkey<br>    secret: ${KEY_PASSWORD}</pre><h3>/encrypt and /decrypt endpoints</h3><p>With a key configured, the server exposes encryption endpoints:</p><pre># encrypt a secret<br>curl -X POST --data-urlencode &quot;s3cr3t-db-password&quot; \<br>  http://localhost:8888/encrypt<br># -&gt; 682bc583f4641835fa2db009355293665d2647dade3375c0ee201de2a49f7bda</pre><pre># sanity-check by decrypting<br>curl -X POST --data-urlencode &quot;682bc583f4641835fa2db009355293665d2647dade3375c0ee201de2a49f7bda&quot; \<br>  <a href="http://localhost:8888/decrypt">http://localhost:8888/decrypt</a><br># -&gt; s3cr3t-db-password</pre><p>Take the ciphertext, prefix it with {cipher}, and commit <em>that</em> to Git:</p><pre>spring:<br>  datasource:<br>    url: jdbc:postgresql://db.internal:5432/orders<br>    username: orders_app<br>    password: &#39;{cipher}682bc583f4641835fa2db009355293665d2647dade3375c0ee201de2a49f7bda&#39;</pre><p>Quote the value in YAML — the leading { otherwise reads as a JSON flow map. By default the server decrypts before serving, so clients receive plaintext over the (TLS-secured) wire. If you would rather ship ciphertext to clients and decrypt there, set spring.cloud.config.server.encrypt.enabled: false on the server and give clients the key.</p><h3>Profiles per environment</h3><p>Profiles are how one repo serves every environment. A client declares its active profile:</p><p>``Fyaml spring: profiles: active: prod</p><pre>…and the server layers `application.yml` → `application-prod.yml` → `order-service.yml` → `order-service-prod.yml`. Keep environment-invariant settings (logging format, JSON serialization rules) in the unprofiled files and let profiled files carry only the deltas (URLs, pool sizes, feature flags).</pre><pre>A practical repo layout:</pre><p>config-repo/ application.yml # global defaults, all services application-prod.yml # global prod overrides order-service.yml # order defaults order-service-dev.yml order-service-prod.yml payment-service.yml payment-service-prod.yml</p><pre>Profiles also map cleanly onto Git **labels**: a long-lived `main` branch for stable config, short-lived branches for proposed changes that go through pull-request review, and tags (`v2.4.0`) for pinning a service to a known-good config snapshot during an incident.</pre><pre>## Pitfalls</pre><pre>Centralized config removes one class of pain and quietly introduces another. The ones that actually cause incidents:</pre><pre>- **`@RefreshScope` does not magically refresh everything.** A plain `@Value` field on a bean that is *not* in refresh scope keeps its startup value forever. Worse, `@Value` injected into singleton beans created before the refresh machinery (or into static contexts) silently goes stale. Prefer `@ConfigurationProperties` beans, which the refresh endpoint rebinds reliably.</pre><pre>- **Some beans need a real restart.** Connection pools (`DataSource` / HikariCP), thread pools, embedded servers, and Kafka listeners do not gracefully rebuild from a `RefreshEvent`. Changing `spring.datasource.url` and calling `/actuator/refresh` may leave you on the old connection — or in a half-migrated state. Treat infrastructure endpoints as restart-only, and dynamic flags/timeouts as refreshable.</pre><pre>- **Refresh ordering is not guaranteed.** A `RefreshEvent` recreates beans, but the order across many `@RefreshScope` beans is undefined. Avoid refresh-time logic that assumes bean B already saw the new value when bean A reconstructs.</pre><pre>- **Plaintext secrets live forever in Git history.** Encrypting a value *today* does nothing about the commit last month where you pasted it raw. You must rewrite history (`git filter-repo`) and rotate the leaked secret. Encrypt from day one.</pre><pre>- **An unsecured config server is a credential dump.** By default the server will happily serve every secret to anyone who can reach `:8888`. Put it behind authentication (Spring Security / mTLS), restrict it to the internal network, and never expose `/encrypt` or `/decrypt` publicly — those endpoints are an oracle.</pre><pre>- **`optional:` can hide a real outage.** `optional:configserver:` is great for local dev but in production it lets a service boot with stale or default config when the server is down — masking the failure. Make config mandatory in prod and let `fail-fast` plus retry surface the problem.</pre><pre>- **Profile precedence surprises.** Remember &quot;more specific wins.&quot; If `application-prod.yml` and `order-service.yml` both set a key, the *service-specific* file wins even though it is not profiled. When in doubt, read the `propertySources` order in the raw `/{app}/{profile}` response.</pre><pre>- **Label and caching confusion.** The server caches the cloned repo. If you push to `main` but a client requests an old `label` (tag/commit), it will get the old values — by design. And a client that never refreshes keeps whatever it loaded at startup regardless of what Git now says. &quot;I changed it in Git&quot; is not the same as &quot;it took effect.&quot;</pre><pre>## Wrapping up</pre><pre>Spring Cloud Config turns configuration into something you actually manage: versioned in Git, reviewed in pull requests, layered by profile, encrypted at rest, and changeable at runtime via a single `busrefresh`. The hard parts are not the annotations — they are knowing which values are safe to hot-reload, keeping secrets out of history, and locking down the server itself. Get those right and &quot;redeploy to change a value&quot; becomes a story you tell new hires.</pre><pre>---</pre><pre>If this saved you a weekend of grepping YAML, **follow devdomain** and drop a comment with the configuration war story that made you adopt a config server. What is the one value you wish you could change without a redeploy? I read and reply to every comment.</pre><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e76785c87c2c" width="1" height="1" alt=""><hr><p><a href="https://medium.com/devdomain/spring-cloud-config-centralized-versioned-configuration-for-microservices-e76785c87c2c">Spring Cloud Config: Centralized, Versioned Configuration for Microservices</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[Resilient Microservices with Resilience4j: Circuit Breakers, Retries, and Bulkheads]]></title>
            <link>https://medium.com/devdomain/resilient-microservices-with-resilience4j-circuit-breakers-retries-and-bulkheads-d6091b44ed4d?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/d6091b44ed4d</guid>
            <category><![CDATA[microservices]]></category>
            <category><![CDATA[resilience4j]]></category>
            <category><![CDATA[fault-tolerance]]></category>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[java]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Tue, 15 Sep 2026 09:31:01 GMT</pubDate>
            <atom:updated>2026-09-15T09:31:01.759Z</atom:updated>
            <content:encoded><![CDATA[<p>In a monolith, a slow database query slows down one request. In a microservice mesh, a single slow dependency can take down everything. The reason is brutal and counterintuitive: it’s not the failure that kills you, it’s the <em>waiting</em>.</p><p>Picture a checkout service that calls a payment service. Payment starts responding in 30 seconds instead of 50ms. Every checkout request now holds a thread for 30 seconds. Your thread pool — say 200 threads — fills in seconds. Now checkout can’t serve <em>any</em> request, including ones that don’t even touch payment. The failure propagates upstream to whatever calls checkout, and so on. This is a <strong>cascading failure</strong>, and it’s the single most common way distributed systems fall over.</p><p>Resilience patterns exist to contain this blast radius. <strong>Resilience4j</strong> is a lightweight, functional, Spring-friendly library that implements the canonical patterns — circuit breaker, retry, rate limiter, bulkhead, time limiter — as composable decorators. Unlike the now-defunct Hystrix, it has no thread-pool baggage by default, integrates with Micrometer for metrics, and works beautifully with Spring Boot annotations. This article walks through each pattern with real code, then shows how to combine them correctly.</p><h3>The mental model: decorate the call, don’t trust the network</h3><p>Every Resilience4j pattern wraps a unit of work (a Supplier, Function, CompletableFuture, etc.) in a decorator that adds a guarantee. You can compose decorators functionally, or — far more common in Spring — apply them with annotations and configure them in application.yml.</p><p>The patterns answer different questions:</p><p>Pattern Question it answers Protects against Circuit Breaker Should I even try calling this right now? A dependency that’s down/slow — stop hammering it Retry Should I try again after a transient blip? Brief, recoverable failures Rate Limiter Am I allowed to call this many times? Overwhelming a dependency or hitting a quota Bulkhead How many concurrent calls are allowed? One dependency exhausting all your threads Time Limiter How long will I wait before giving up? Unbounded latency / hung calls</p><p>They are independent and stackable. A single outbound call might be wrapped in <em>all five</em> at once.</p><h3>Setup</h3><p>Spring Boot 3.x with the AOP-based Resilience4j starter and Actuator for metrics:</p><pre>dependencies {<br>    implementation &#39;org.springframework.boot:spring-boot-starter-web&#39;<br>    implementation &#39;org.springframework.boot:spring-boot-starter-aop&#39;<br>    implementation &#39;io.github.resilience4j:resilience4j-spring-boot3:2.2.0&#39;<br>    implementation &#39;org.springframework.boot:spring-boot-starter-actuator&#39;<br>    implementation &#39;io.micrometer:micrometer-registry-prometheus&#39;<br>}</pre><p>The spring-boot-starter-aop dependency is <strong>not optional</strong> — the annotations (@CircuitBreaker, @Retry, etc.) are implemented as AOP aspects. Forget it and your annotations silently do nothing, which is a deeply confusing failure mode.</p><h3>Circuit Breaker: the centerpiece</h3><p>The circuit breaker borrows its metaphor from electrical engineering: when something goes wrong, it <em>trips</em> and stops the flow rather than letting damage spread. It is a state machine with three primary states.</p><pre>failures exceed threshold<br>  CLOSED ───────────────────────────►  OPEN<br>   ▲  │                                  │<br>   │  │ calls pass through               │ wait duration elapses<br>   │  └─ record success/failure          ▼<br>   │                              HALF_OPEN<br>   │     probe calls succeed          │<br>   └──────────────────────────────────┘<br>              probe calls fail → back to OPEN</pre><ul><li><strong>CLOSED</strong> — normal operation. Calls pass through; the breaker records outcomes in a sliding window.</li><li><strong>OPEN</strong> — the failure rate crossed the threshold. Calls are rejected <em>immediately</em> with CallNotPermittedException (no waiting, no thread held). The fallback runs instantly.</li><li><strong>HALF_OPEN</strong> — after a wait duration, the breaker allows a limited number of probe calls. If they succeed, it closes; if they fail, it re-opens.</li></ul><p>The genius is the OPEN state: instead of letting 200 threads each wait 30 seconds on a dead service, the breaker fails them in microseconds and frees those threads. That’s what stops the cascade.</p><h3>Configuration</h3><pre>resilience4j:<br>  circuitbreaker:<br>    instances:<br>      paymentService:<br>        sliding-window-type: COUNT_BASED<br>        sliding-window-size: 20<br>        minimum-number-of-calls: 10<br>        failure-rate-threshold: 50<br>        slow-call-rate-threshold: 80<br>        slow-call-duration-threshold: 2s<br>        wait-duration-in-open-state: 10s<br>        permitted-number-of-calls-in-half-open-state: 3<br>        automatic-transition-from-open-to-half-open-enabled: true<br>        record-exceptions:<br>          - java.io.IOException<br>          - java.util.concurrent.TimeoutException<br>        ignore-exceptions:<br>          - com.devdomain.payment.PaymentDeclinedException</pre><p>Each knob matters:</p><ul><li><strong>sliding-window-size: 20 + </strong><strong>minimum-number-of-calls: 10</strong> — the breaker evaluates the failure rate over the last 20 calls but won&#39;t trip until it has seen at least 10. This prevents one failure out of two calls (50%!) from instantly opening the circuit on startup.</li><li><strong>failure-rate-threshold: 50</strong> — opens when ≥50% of windowed calls fail.</li><li><strong>slow-call-*</strong> — crucially, a call that <em>succeeds slowly</em> is still a problem. If 80% of calls take longer than 2s, the breaker opens even though nothing technically failed. Slow calls are the real cascade trigger, so configuring this is essential.</li><li><strong>ignore-exceptions</strong> — a declined payment is a <em>business</em> outcome, not an infrastructure failure. It must not count toward tripping the breaker. Getting this list right is the difference between a breaker that protects you and one that flaps on normal business errors.</li></ul><h3>Usage with annotations and a fallback</h3><pre>package com.devdomain.payment;</pre><pre>import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;<br>import org.springframework.stereotype.Service;<br>import org.springframework.web.client.RestClient;</pre><pre>@Service<br>public class PaymentClient {</pre><pre>    private final RestClient restClient;</pre><pre>    public PaymentClient(RestClient.Builder builder) {<br>        this.restClient = builder.baseUrl(&quot;http://payment-service&quot;).build();<br>    }</pre><pre>    @CircuitBreaker(name = &quot;paymentService&quot;, fallbackMethod = &quot;chargeFallback&quot;)<br>    public PaymentResult charge(ChargeRequest request) {<br>        return restClient.post()<br>                .uri(&quot;/charges&quot;)<br>                .body(request)<br>                .retrieve()<br>                .body(PaymentResult.class);<br>    }</pre><pre>    // Fallback signature: same args + a trailing Throwable.<br>    private PaymentResult chargeFallback(ChargeRequest request, Throwable t) {<br>        if (t instanceof io.github.resilience4j.circuitbreaker.CallNotPermittedException) {<br>            // Circuit is OPEN — fail fast, queue for async retry, etc.<br>            return PaymentResult.deferred(request.orderId());<br>        }<br>        // Some other failure leaked through.<br>        return PaymentResult.failed(request.orderId(), t.getMessage());<br>    }<br>}</pre><p>The fallback method <strong>must</strong> have a matching signature: the same parameters as the protected method, plus a trailing Throwable (or a more specific exception type for finer-grained handling). A mismatched signature throws NoSuchMethodException at runtime — another silent-looking footgun. You can define multiple overloaded fallbacks keyed by exception type; Resilience4j picks the most specific match.</p><h3>Retry: for transient blips only</h3><p>Retry re-invokes a failed call a bounded number of times. It is the right tool <em>only</em> for genuinely transient, idempotent operations: a momentary connection reset, a brief 503. Retrying a non-idempotent operation (like a payment charge) can double-charge a customer. Retrying a deterministic failure (a 400 Bad Request) just wastes time and amplifies load.</p><pre>resilience4j:<br>  retry:<br>    instances:<br>      inventoryService:<br>        max-attempts: 3<br>        wait-duration: 500ms<br>        enable-exponential-backoff: true<br>        exponential-backoff-multiplier: 2<br>        enable-randomized-wait: true       # jitter<br>        randomized-wait-factor: 0.5<br>        retry-exceptions:<br>          - java.io.IOException<br>          - org.springframework.web.client.HttpServerErrorException<br>        ignore-exceptions:<br>          - org.springframework.web.client.HttpClientErrorException</pre><p>Two non-negotiable details:</p><ol><li><strong>Exponential backoff.</strong> Retrying immediately hammers a struggling service and can push it from “slow” to “dead.” Back off: 500ms, 1s, 2s.</li><li><strong>Jitter (</strong><strong>enable-randomized-wait).</strong> Without it, if 500 clients all fail at the same instant, they all retry at exactly 500ms, then 1s, in synchronized waves — a <em>thundering herd</em> that keeps the dependency pinned down. Jitter spreads the retries out. This is the most overlooked retry setting and one of the most important.</li></ol><pre>@Retry(name = &quot;inventoryService&quot;)<br>@CircuitBreaker(name = &quot;inventoryService&quot;, fallbackMethod = &quot;reserveFallback&quot;)<br>public Reservation reserve(String sku, int qty) {<br>    return restClient.post().uri(&quot;/reserve&quot;)<br>            .body(new ReserveRequest(sku, qty))<br>            .retrieve().body(Reservation.class);<br>}</pre><p>Note retry-exceptions vs ignore-exceptions: 5xx errors are server-side and often transient (retry), while 4xx errors are caused by your request and won&#39;t improve on retry (ignore).</p><h3>Rate Limiter: stay within bounds</h3><p>A rate limiter caps how many calls are permitted per time window. Use it to respect a third-party API quota, or to protect a fragile internal dependency from your own traffic spikes.</p><pre>resilience4j:<br>  ratelimiter:<br>    instances:<br>      geocodingApi:<br>        limit-for-period: 100        # 100 permits...<br>        limit-refresh-period: 1s     # ...refreshed every second<br>        timeout-duration: 250ms      # how long a call waits for a permit</pre><pre>@RateLimiter(name = &quot;geocodingApi&quot;, fallbackMethod = &quot;geocodeFallback&quot;)<br>public Coordinates geocode(String address) {<br>    return restClient.get().uri(&quot;/geocode?q={a}&quot;, address)<br>            .retrieve().body(Coordinates.class);<br>}</pre><pre>private Coordinates geocodeFallback(String address, RequestNotPermitted ex) {<br>    // Over the rate limit — degrade gracefully.<br>    return Coordinates.unknown();<br>}</pre><p>If a permit isn’t available within timeout-duration, the call throws RequestNotPermitted and the fallback fires. Set timeout-duration: 0 to fail instantly rather than block a thread waiting for a permit.</p><h3>Bulkhead: isolate the damage</h3><p>The bulkhead — named after the watertight compartments in a ship — limits <em>concurrent</em> calls to a dependency, so one slow dependency can’t consume all your threads. Resilience4j offers two flavors:</p><ul><li><strong>SemaphoreBulkhead</strong> (default) — a simple counting semaphore on the <em>caller&#39;s</em> thread. Lightweight, no extra threads.</li><li><strong>ThreadPoolBulkhead</strong> — runs calls on a dedicated, bounded thread pool with its own queue. Provides true isolation (the calling thread is freed) and only works with CompletableFuture return types.</li></ul><pre>resilience4j:<br>  bulkhead:                       # semaphore<br>    instances:<br>      reportService:<br>        max-concurrent-calls: 10<br>        max-wait-duration: 100ms<br>  thread-pool-bulkhead:           # thread-pool<br>    instances:<br>      heavyReport:<br>        core-thread-pool-size: 5<br>        max-thread-pool-size: 10<br>        queue-capacity: 20</pre><pre>// Semaphore bulkhead: at most 10 concurrent calls; the 11th waits up to 100ms then fails.<br>@Bulkhead(name = &quot;reportService&quot;, type = Bulkhead.Type.SEMAPHORE)<br>public Report generate(ReportRequest req) { ... }</pre><pre>// Thread-pool bulkhead: must return CompletableFuture.<br>@Bulkhead(name = &quot;heavyReport&quot;, type = Bulkhead.Type.THREADPOOL)<br>public CompletableFuture&lt;Report&gt; generateHeavy(ReportRequest req) {<br>    return CompletableFuture.supplyAsync(() -&gt; doWork(req));<br>}</pre><p>The point: even if reportService hangs completely, at most 10 of your threads are stuck. The other 190 keep serving unrelated traffic. That containment is what bulkheads buy you.</p><h3>Time Limiter: bound the wait</h3><p>A TimeLimiter caps how long an <em>asynchronous</em> call may run before being cancelled. It operates on CompletableFuture / reactive types — it cannot interrupt a blocking synchronous call (the underlying thread can&#39;t be force-killed safely), which is exactly why you pair it with the thread-pool bulkhead.</p><pre>resilience4j:<br>  timelimiter:<br>    instances:<br>      pricingService:<br>        timeout-duration: 2s<br>        cancel-running-future: true</pre><pre>@TimeLimiter(name = &quot;pricingService&quot;, fallbackMethod = &quot;priceFallback&quot;)<br>@Bulkhead(name = &quot;pricingService&quot;, type = Bulkhead.Type.THREADPOOL)<br>public CompletableFuture&lt;Price&gt; price(String sku) {<br>    return CompletableFuture.supplyAsync(() -&gt;<br>            restClient.get().uri(&quot;/price/{sku}&quot;, sku).retrieve().body(Price.class));<br>}</pre><pre>private CompletableFuture&lt;Price&gt; priceFallback(String sku, TimeoutException ex) {<br>    return CompletableFuture.completedFuture(Price.cached(sku));<br>}</pre><h3>Combining patterns: order matters</h3><p>Stacking annotations on one method is common, but the <em>order</em> in which Resilience4j applies the decorators is fixed and important. From outermost to innermost the default aspect order is:</p><pre>Retry ( Circuit Breaker ( Rate Limiter ( Time Limiter ( Bulkhead ( call ) ) ) ) )</pre><p>Read it inside-out and the logic is sound:</p><ol><li><strong>Bulkhead</strong> admits the call (concurrency check) — innermost.</li><li><strong>Time Limiter</strong> bounds its duration.</li><li><strong>Rate Limiter</strong> checks you’re within quota.</li><li><strong>Circuit Breaker</strong> records the outcome and may short-circuit.</li><li><strong>Retry</strong> wraps everything — so a retry re-enters the breaker, gets a fresh rate-limit permit, etc. — outermost.</li></ol><p>This ordering is deliberate and usually what you want. A critical implication: <strong>Retry is outermost, so it counts circuit-breaker rejections as failures unless told otherwise.</strong> When the breaker is OPEN, each retry attempt gets a CallNotPermittedException instantly — retrying a fast-failing open breaker is pointless churn. Configure retry to <em>not</em> retry on CallNotPermittedException:</p><pre>resilience4j:<br>  retry:<br>    instances:<br>      paymentService:<br>        max-attempts: 3<br>        ignore-exceptions:<br>          - io.github.resilience4j.circuitbreaker.CallNotPermittedException</pre><p>A full real-world combination:</p><pre>@Retry(name = &quot;payment&quot;)<br>@CircuitBreaker(name = &quot;payment&quot;, fallbackMethod = &quot;fallback&quot;)<br>@RateLimiter(name = &quot;payment&quot;)<br>@TimeLimiter(name = &quot;payment&quot;)<br>@Bulkhead(name = &quot;payment&quot;, type = Bulkhead.Type.THREADPOOL)<br>public CompletableFuture&lt;PaymentResult&gt; charge(ChargeRequest req) {<br>    return CompletableFuture.supplyAsync(() -&gt; paymentClient.doCharge(req));<br>}</pre><pre>private CompletableFuture&lt;PaymentResult&gt; fallback(ChargeRequest req, Throwable t) {<br>    return CompletableFuture.completedFuture(PaymentResult.deferred(req.orderId()));<br>}</pre><p>You can override the aspect order globally with properties like resilience4j.retry.retryAspectOrder, but the defaults are sensible — change them only with a clear reason.</p><h3>Functional API: when annotations aren’t enough</h3><p>Annotations cover most cases, but sometimes you need to decorate a call dynamically — for example, picking a circuit breaker instance by tenant, or wrapping a lambda you build at runtime. Resilience4j’s functional API does exactly that, and understanding it also demystifies what the annotations do under the hood.</p><pre>import io.github.resilience4j.circuitbreaker.CircuitBreaker;<br>import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;<br>import io.github.resilience4j.retry.Retry;<br>import io.github.resilience4j.retry.RetryRegistry;<br>import io.github.resilience4j.decorators.Decorators;</pre><pre>import java.util.function.Supplier;</pre><pre>public class FunctionalExample {</pre><pre>    private final CircuitBreaker breaker;<br>    private final Retry retry;</pre><pre>    public FunctionalExample(CircuitBreakerRegistry cbRegistry, RetryRegistry retryRegistry) {<br>        this.breaker = cbRegistry.circuitBreaker(&quot;paymentService&quot;);<br>        this.retry = retryRegistry.retry(&quot;paymentService&quot;);<br>    }</pre><pre>    public PaymentResult charge(ChargeRequest req) {<br>        Supplier&lt;PaymentResult&gt; supplier = () -&gt; paymentClient.doCharge(req);</pre><pre>        // Decorators apply in a controlled, explicit order.<br>        Supplier&lt;PaymentResult&gt; decorated = Decorators.ofSupplier(supplier)<br>                .withCircuitBreaker(breaker)<br>                .withRetry(retry)<br>                .withFallback(<br>                        java.util.List.of(Exception.class),<br>                        ex -&gt; PaymentResult.deferred(req.orderId()))<br>                .decorate();</pre><pre>        return decorated.get();<br>    }<br>}</pre><p>The Decorators builder makes composition explicit and is the cleanest way to wrap CompletableFuture chains or third-party clients you can&#39;t annotate. The registries (CircuitBreakerRegistry, RetryRegistry, etc.) are Spring beans auto-configured from your application.yml, so functional and annotation styles share the same configuration and metrics.</p><p>A common real use is per-tenant or per-endpoint isolation: cbRegistry.circuitBreaker(&quot;payment-&quot; + tenantId) lazily creates and reuses a breaker keyed by tenant, so one noisy tenant&#39;s failures don&#39;t trip the breaker for everyone else.</p><h3>Observability</h3><p>Resilience4j publishes everything through Micrometer. Expose the actuator endpoints:</p><pre>management:<br>  endpoints:<br>    web:<br>      exposure:<br>        include: health, circuitbreakers, metrics, prometheus<br>  health:<br>    circuitbreakers:<br>      enabled: true</pre><p>You then get metrics like resilience4j_circuitbreaker_state, resilience4j_circuitbreaker_failure_rate, and resilience4j_retry_calls. Alert on circuit-breaker state transitions to OPEN — that&#39;s an early warning that a dependency is degrading, often before users notice. You can also register event listeners programmatically:</p><pre>circuitBreakerRegistry.circuitBreaker(&quot;paymentService&quot;).getEventPublisher()<br>    .onStateTransition(e -&gt; log.warn(&quot;CB {} -&gt; {}&quot;,<br>        e.getCircuitBreakerName(), e.getStateTransition()));</pre><h3>Pitfalls and gotchas</h3><ul><li><strong>Missing </strong><strong>spring-boot-starter-aop.</strong> Without it the annotations are no-ops and do nothing — and nothing tells you. Always confirm the AOP starter is present.</li><li><strong>Self-invocation.</strong> Annotations are applied via proxies. If method a() calls annotated method b() <em>within the same bean</em>, the proxy is bypassed and resilience is skipped. Put protected calls in a separate bean.</li><li><strong>Counting business errors as failures.</strong> A 404 or a PaymentDeclined is a normal outcome. If it counts toward the failure rate, your breaker trips on healthy traffic. Curate ignore-exceptions deliberately.</li><li><strong>Retrying non-idempotent operations.</strong> Retrying a charge or an order-create without an idempotency key can double-execute the side effect. Only retry idempotent calls, or pass an idempotency key the server dedupes on.</li><li><strong>No jitter on retries.</strong> Synchronized retries create thundering herds that keep a dependency down. Always enable randomized wait.</li><li><strong>Fallback signature mismatch.</strong> The fallback must match the args plus a trailing Throwable; otherwise it&#39;s never found at runtime.</li><li><strong>TimeLimiter on synchronous code.</strong> It only cancels futures. A blocking JDBC call won’t be interrupted — set the underlying client’s own connect/read timeouts too.</li><li><strong>Fallbacks that hide outages.</strong> A fallback returning empty/cached data can mask a total dependency failure so nobody notices for days. Always emit a metric/log from fallback paths.</li><li><strong>Retrying an open circuit.</strong> Add CallNotPermittedException to retry&#39;s ignore list, or you waste cycles retrying instant rejections.</li><li><strong>One shared instance name for everything.</strong> Each dependency should have its own named instance so one flaky service’s failures don’t trip the breaker for an unrelated one.</li></ul><h3>Wrapping up</h3><p>Resilience isn’t a single feature you bolt on — it’s a layered defense. The circuit breaker stops you from hammering a dead dependency and frees threads instantly. Retry with backoff and jitter recovers from transient blips without creating herds. Rate limiters keep you within quota, bulkheads contain concurrency damage, and time limiters bound the wait. Composed in the right order, with business errors carefully excluded and every fallback observable, they turn a brittle service mesh into one that degrades gracefully instead of collapsing.</p><p>Start with a circuit breaker and a time limiter on every outbound call. Add the rest where your traffic profile demands it. And test your failure paths deliberately — resilience you’ve never exercised is resilience you don’t actually have.</p><p>Found this useful? Clap and <strong>follow devdomain</strong> for more hands-on distributed-systems engineering. How are you tuning your circuit breakers and bulkheads in production? Share your war stories in the comments.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d6091b44ed4d" width="1" height="1" alt=""><hr><p><a href="https://medium.com/devdomain/resilient-microservices-with-resilience4j-circuit-breakers-retries-and-bulkheads-d6091b44ed4d">Resilient Microservices with Resilience4j: Circuit Breakers, Retries, and Bulkheads</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[Hibernate Caching Explained: First-Level, Second-Level, and Query Cache]]></title>
            <link>https://medium.com/devdomain/hibernate-caching-explained-first-level-second-level-and-query-cache-08b2769175f3?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/08b2769175f3</guid>
            <category><![CDATA[hibernate]]></category>
            <category><![CDATA[jpa]]></category>
            <category><![CDATA[java]]></category>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[performance]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Thu, 10 Sep 2026 09:11:00 GMT</pubDate>
            <atom:updated>2026-09-10T09:11:00.816Z</atom:updated>
            <content:encoded><![CDATA[<p>Caching is one of those features that feels free until it isn’t. You flip a flag, your read-heavy endpoint gets faster, everyone celebrates — and three weeks later someone files a bug because a user’s profile shows a stale email address that “definitely changed yesterday.”</p><p>Hibernate ships with <strong>three distinct caching layers</strong>, and they solve different problems with different lifetimes, different scopes, and very different failure modes. If you treat them as one big “cache” switch you will eventually get burned. This article unpacks all three: the first-level (persistence-context) cache that you already use whether you know it or not, the optional second-level cache and its providers, and the query cache that almost everyone misconfigures.</p><h3>The three caches at a glance</h3><p>Cache Scope Lifetime Default Stores First-level (L1) Single EntityManager/Session One persistence context (usually one transaction) Always on, cannot disable Managed entity instances Second-level (L2) EntityManagerFactory (whole app, per JVM) Until evicted/expired Off Entity state (dehydrated), collections Query cache EntityManagerFactory Until evicted/expired Off Query result identifiers</p><p>The key mental model: <strong>L1 caches entity object references; L2 caches dehydrated entity state; the query cache caches the IDs a query returned, not the entities themselves.</strong></p><h3>First-level cache: the persistence context</h3><p>The L1 cache <em>is</em> the persistence context. Every EntityManager (Hibernate Session) maintains a map of every managed entity it has loaded or persisted, keyed by entity type and primary key. You don&#39;t configure it, you can&#39;t turn it off, and it is scoped to a single persistence context — typically a single transaction.</p><pre>import jakarta.persistence.EntityManager;<br>import jakarta.persistence.PersistenceContext;<br>import org.springframework.stereotype.Service;<br>import org.springframework.transaction.annotation.Transactional;</pre><pre>@Service<br>public class ProductService {</pre><pre>    @PersistenceContext<br>    private EntityManager em;</pre><pre>    @Transactional<br>    public void demonstrateL1() {<br>        Product p1 = em.find(Product.class, 1L); // SELECT hits the database<br>        Product p2 = em.find(Product.class, 1L); // no SQL — served from L1</pre><pre>        // Same managed instance, guaranteed identity within the context:<br>        assert p1 == p2;<br>    }<br>}</pre><p>The second find issues <strong>no SQL</strong>. More importantly, p1 == p2 is true: within one persistence context Hibernate guarantees a single in-memory instance per database row. This is the <em>identity guarantee</em>, and it is why dirty checking and lazy loading work at all.</p><h3>Why L1 sometimes “lies”</h3><p>Because L1 is bound to the persistence context, two transactions see two separate caches. This is correct, but it surprises people:</p><pre>@Transactional<br>public void update(Long id, String name) {<br>    Product p = em.find(Product.class, id);<br>    p.setName(name);<br>    // Auto dirty-checked and flushed at commit — `no explicit save needed.<br>}</pre><p>If another thread loaded the same product into <em>its</em> persistence context before your commit, that thread still sees the old name. That’s not a cache bug — it’s transaction isolation. L1 never crosses transaction boundaries, so it cannot serve stale data across requests. The dangerous one is the next layer.</p><h3>Second-level cache: shared across the application</h3><p>The L2 cache lives on the EntityManagerFactory and is shared by every session in the JVM. It survives transactions. When enabled and an entity is marked cacheable, Hibernate stores the entity&#39;s <strong>dehydrated state</strong> (a flat array of column values, not the object graph) in a region, keyed by ID.</p><p>L2 stores state, not instances. When you read a cached entity, Hibernate <em>rehydrates</em> a fresh object from the stored array. That’s why L2 entries can be shared safely across threads — “nobody hands out a mutable shared object.</p><h3>Enabling L2 in Spring Boot 3.x</h3><p>You need a provider on the classpath plus configuration. Hibernate uses JCache (JSR-107) as the standard bridge, with EhCache 3 or Caffeine as common backends.</p><pre>&lt;!-- pom.xml --&gt;<br>&lt;dependency&gt;<br>    &lt;groupId&gt;org.hibernate.orm&lt;/groupId&gt;<br>    &lt;artifactId&gt;hibernate-jcache&lt;/artifactId&gt;<br>&lt;/dependency&gt;<br>&lt;dependency&gt;<br>    &lt;groupId&gt;org.ehcache&lt;/groupId&gt;<br>    &lt;artifactId&gt;ehcache&lt;/artifactId&gt;<br>    &lt;version&gt;3.10.8&lt;/version&gt;<br>    &lt;classifier&gt;jakarta&lt;/classifier&gt;<br>&lt;/dependency&gt;</pre><pre># application.properties<br>spring.jpa.properties.hibernate.cache.use_second_level_cache=true<br>spring.jpa.properties.hibernate.cache.region.factory_class=jcache<br>spring.jpa.properties.hibernate.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider<br>spring.jpa.properties.hibernate.javax.cache.uri=classpath:ehcache.xml<br># Surface what gets cached during development:<br>spring.jpa.properties.hibernate.generate_statistics=true</pre><p>Note the jakarta classifier on EhCache 3.10+— without it you&#39;ll pull the javax-namespaced jars and Hibernate 6 (Jakarta Persistence) will not wire up.</p><h3>Marking entities cacheable</h3><p>L2 is opt-in per entity. Use Jakarta’s @Cacheable plus Hibernate&#39;s @Cache to pick a concurrency strategy:</p><pre>import jakarta.persistence.Cacheable;<br>import jakarta.persistence.Entity;<br>import jakarta.persistence.Id;<br>import org.hibernate.annotations.Cache;<br>import org.hibernate.annotations.CacheConcurrencyStrategy;</pre><pre>@Entity<br>@Cacheable<br>@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = &quot;products&quot;)<br>public class Product {</pre><pre>    @Id<br>    private Long id;</pre><pre>    private String name;<br>    private java.math.BigDecimal price;<br>    // getters/setters<br>}</pre><p>For collections, you must annotate the association itself — caching the owning entity does <strong>not</strong> cache its collections:</p><pre>@OneToMany(mappedBy = &quot;product&quot;)<br>@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = &quot;product.reviews&quot;)<br>private List&lt;Review&gt; reviews = new ArrayList&lt;&gt;();</pre><h3>Concurrency strategies</h3><p>The strategy controls how Hibernate keeps the cache coherent with the database under concurrent writes.</p><p>Strategy Use when Behavior READ_ONLY Reference data that never changes Fastest; throws if you try to update NONSTRICT_READ_WRITE Rare writes, brief staleness OK Evicts (not updates) on commit; small stale window READ_WRITE Frequent writes, need consistency Uses soft locks during write; safe but more overhead TRANSACTIONAL JTA environments with XA Cache participates in the transaction</p><p>For most Spring Boot apps on a single datasource, READ_WRITE is the safe default and READ_ONLY is ideal for lookup tables (countries, currencies, config).</p><h3>A minimal ehcache.xml</h3><pre>&lt;config xmlns=&#39;http://www.ehcache.org/v3&#39;&gt;<br>  &lt;cache alias=&quot;products&quot;&gt;<br>    &lt;expiry&gt;&lt;ttl unit=&quot;minutes&quot;&gt;30&lt;/ttl&gt;&lt;/expiry&gt;<br>    &lt;heap unit=&quot;entries&quot;&gt;10000&lt;/heap&gt;<br>  &lt;/cache&gt;<br>  &lt;cache alias=&quot;default-query-results-region&quot;&gt;<br>    &lt;expiry&gt;&lt;ttl unit=&quot;minutes&quot;&gt;5&lt;/ttl&gt;&lt;/expiry&gt;<br>    &lt;heap unit=&quot;entries&quot;&gt;2000&lt;/heap&gt;<br>  &lt;/cache&gt;<br>&lt;/config&gt;</pre><h3>Using Caffeine or Redis instead</h3><p>Caffeine is a drop-in JCache provider for a fast <strong>local</strong> cache — swap the provider class and dependency:</p><pre>spring.jpa.properties.hibernate.javax.cache.provider=com.github.benmanes.caffeine.jcache.spi.CaffeineCachingProvider</pre><p>Redis gives you a <strong>distributed</strong> L2 so multiple app instances share (and invalidate) the same cache. The common choice is Redisson, which ships a Hibernate region factory:</p><pre>spring.jpa.properties.hibernate.cache.region.factory_class=org.redisson.hibernate.RedissonRegionFactory<br>spring.jpa.properties.hibernate.cache.redisson.config=classpath:redisson.yaml</pre><p>The trade-off is fundamental: <strong>local caches (EhCache/Caffeine) are faster but go stale across nodes</strong> unless every write evicts on every node; <strong>distributed caches (Redis) stay coherent but add a network hop</strong> on every cache hit. Don’t reach for Redis L2 until you’ve proven a local cache causes cross-node staleness problems.</p><h3>The query cache</h3><p>The query cache solves a problem L2 doesn’t: caching the <em>results of a query</em>. But it does so in a subtle way — <strong>it stores only the primary keys the query returned</strong>, plus a timestamp. The entities themselves are then fetched from L2 (or the database if not cached there).</p><p>This means the query cache is nearly useless on its own. If you cache a query but the matched entities aren’t in L2, Hibernate re-fetches every entity by ID — sometimes turning one query into N. <strong>Always enable L2 for the entities a cached query returns.</strong></p><pre>spring.jpa.properties.hibernate.cache.use_query_cache=true</pre><pre>import org.hibernate.jpa.AvailableHints;<br>import jakarta.persistence.TypedQuery;</pre><pre>public List&lt;Product&gt; findActive() {<br>    TypedQuery&lt;Product&gt; q = em.createQuery(<br>        &quot;select p from Product p where p.active = true&quot;, Product.class);<br>    q.setHint(AvailableHints.HINT_CACHEABLE, true); // opt-in per query<br>    return q.getResultList();<br>}</pre><h3>Query cache invalidation and the timestamp region</h3><p>Hibernate maintains a special UpdateTimestampsCache region. Every time a table is modified, Hibernate records the timestamp of the last update for that table. When it considers serving a cached query result, it checks: <em>was any queried table modified after this result was cached?</em> If so, the cached result is discarded.</p><p>This makes the query cache <strong>automatically invalidated on writes to the affected tables</strong> — which is correct, but also means a write-heavy table will constantly bust its query cache, giving you all the overhead and none of the benefit. The query cache pays off only for queries over tables that change rarely relative to how often the query runs.</p><h3>Invalidation: how each layer stays fresh</h3><p>Event L1 L2 Query cache Entity updated via Hibernate Updated in context Region entry updated/evicted Table timestamp bumped → results busted em.clear() / context closed Cleared Untouched Untouched <strong>Native SQL </strong><strong>UPDATE/</strong><strong>DELETE</strong> Not aware <strong>Stale!</strong> <strong>Stale unless you tell Hibernate</strong> TTL expiry (provider) n/a Entry evicted Entry evicted</p><p>The killer is the third row. <strong>Hibernate only invalidates L2 for changes it performs through the ORM.</strong> Native queries, JDBC, Flyway/Liquibase migrations, and direct DB edits bypass L2 entirely, leaving stale entries behind. If you run native bulk updates, tell Hibernate which spaces (tables) they touch:</p><pre>import org.hibernate.query.NativeQuery;</pre><pre>NativeQuery&lt;?&gt; q = em.createNativeQuery(&quot;UPDATE product SET price = price * 1.1&quot;)<br>        .unwrap(NativeQuery.class);<br>q.addSynchronizedEntityClass(Product.class); // invalidate Product&#39;s L2 region<br>q.executeUpdate();</pre><p>You can also evict programmatically through the JPA Cache API:</p><pre>import org.springframework.beans.factory.annotation.Autowired;<br>import jakarta.persistence.EntityManagerFactory;</pre><pre>@Autowired<br>EntityManagerFactory emf;</pre><pre>public void evict() {<br>    emf.getCache().evict(Product.class, 1L); // one entity<br>    emf.getCache().evict(Product.class);      // whole Product region<br>    emf.getCache().evictAll();                // nuke everything<br>}</pre><h3>How L2 stores data: hydration and dehydration</h3><p>It’s worth dwelling on <em>what</em> L2 actually keeps, because it explains several behaviors that otherwise look arbitrary.</p><p>When Hibernate puts an entity into L2, it does not store your object. It stores a <strong>dehydrated</strong> form: a flat Object[] of the entity&#39;s column values, indexed by property, plus the version and the discriminator if applicable. Associations are stored as foreign-key identifiers, not as resolved object graphs.</p><p>When you read from L2, Hibernate <strong>hydrates</strong> a brand-new entity instance from that array, associates it with your current persistence context, and hands it back. Two important results follow:</p><ul><li><strong>L2 entries are immutable snapshots of state.</strong> Two threads reading the same cached entity each get their own fresh instance — there’s no shared mutable object to corrupt. This is what makes L2 thread-safe.</li><li><strong>Lazy associations are not “pre-loaded” by L2.</strong> A cached entity’s @OneToMany is still a lazy proxy unless that collection has its own cached region. L2 caching the parent does nothing for the children.</li></ul><p>This dehydration model is also why L2 cannot help with arbitrary query results — a query returns rows that may not map one-to-one to a cacheable entity. That’s the gap the query cache fills, and the reason it stores IDs rather than state.</p><h3>Putting it together: a worked configuration</h3><p>Here’s a coherent setup for a catalog service: products are read constantly and updated occasionally, categories are effectively static, and a “find active products” query runs on every page load.</p><pre>@Entity<br>@Cacheable<br>@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = &quot;products&quot;)<br>public class Product { /* ... */ }</pre><pre>@Entity<br>@Cacheable<br>@Cache(usage = CacheConcurrencyStrategy.READ_ONLY, region = &quot;categories&quot;)<br>public class Category { /* ... never changes at runtime */ }</pre><pre>spring.jpa.properties.hibernate.cache.use_second_level_cache=true<br>spring.jpa.properties.hibernate.cache.use_query_cache=true<br>spring.jpa.properties.hibernate.cache.region.factory_class=jcache<br>spring.jpa.properties.hibernate.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider<br>spring.jpa.properties.hibernate.javax.cache.uri=classpath:ehcache.xml</pre><p>Category uses READ_ONLY (cheapest, no soft-locking) because it&#39;s immutable at runtime. Product uses READ_WRITE because it&#39;s updated. The active-products query is cacheable and is backed by the cached Product region, so a cache hit returns IDs and then resolves them straight from L2 — no database round trip at all on the happy path. When any product is written, the timestamp region busts the query result and the next page load rebuilds it.</p><h3>When caching helps — and when it hurts</h3><p>L2 and the query cache pay off when <strong>reads massively outnumber writes</strong> and the data is small enough to fit comfortably in memory: reference tables, configuration, product catalogs, rarely-changing aggregates. The hit ratio is what matters; check it with statistics:</p><pre>import org.hibernate.stat.Statistics;</pre><pre>Statistics stats = emf.unwrap(org.hibernate.SessionFactory.class).getStatistics();<br>long hits = stats.getSecondLevelCacheHitCount();<br>long misses = stats.getSecondLevelCacheMissCount();<br>double ratio = (double) hits / (hits + misses);</pre><p>A hit ratio below ~80% usually means the cache costs more than it saves: you pay the put/evict overhead on writes and the memory footprint without recovering it on reads.</p><p>Caching <em>hurts</em> when:</p><ul><li><strong>Writes are frequent.</strong> Every write evicts/updates entries and busts query results. You add overhead and gain little.</li><li><strong>Data must be strictly fresh</strong> (prices, inventory, balances). Even a 5-minute TTL is a 5-minute window for wrong answers.</li><li><strong>You run multiple nodes with a local L2.</strong> Node A’s write doesn’t evict Node B’s cache. You now have per-node stale data that’s maddening to reproduce.</li><li><strong>Cardinality is huge.</strong> Caching millions of rarely-reread rows just evicts useful entries.</li></ul><h3>Common pitfalls</h3><ul><li><strong>Enabling the query cache without L2.</strong> The query cache stores IDs; without L2 each cached query re-hydrates every row by ID — often slower than no cache.</li><li><strong>Forgetting </strong><strong>@Cache on collections.</strong> Caching the parent entity does not cache its @OneToMany/@ManyToMany. The association needs its own @Cache.</li><li><strong>Native/bulk writes silently going stale.</strong> em.createQuery(&quot;delete ...&quot;).executeUpdate() (JPQL bulk) and native SQL bypass L1 and, for native, L2 unless you add synchronized spaces. Always evict or synchronize.</li><li><strong>Mixing the </strong><strong>javax and </strong><strong>jakarta EhCache jars</strong> in Hibernate 6 — use the jakarta classifier or(the cache region factory silently won&#39;t initialize.</li><li><strong>Caching mutable reference data as </strong><strong>READ_ONLY.</strong> If it ever changes, you&#39;ll get an exception on write or a permanently stale value. Use READ_WRITE if updates are possible.</li><li><strong>Local L2 in a clustered deployment.</strong> This is the single most common stale-data incident. Use Redis (Redisson) or accept TTL-bounded staleness deliberately.</li><li><strong>No TTL / unbounded heap.</strong> Without expiry and size limits you cache forever and risk OutOfMemoryError.</li><li><strong>Trusting demo numbers.</strong> L1 makes a single transaction look cache-friendly. Real gains come only from cross-transaction L2 with a measured high hit ratio.</li></ul><h3>Wrapping up</h3><p>Hibernate’s three caches are not interchangeable. L1 is always there, scoped to your transaction, and gives you the identity guarantee that makes the ORM work. L2 is an opt-in, JVM-wide store of dehydrated state that pays off for read-heavy reference data — if you pick the right concurrency strategy and respect invalidation. The query cache is a narrow tool that only helps over rarely-changing tables and only when L2 backs it up.</p><p>Measure your hit ratios, be honest about your read/write mix, and never assume a write you made outside the ORM updated the cache. Used deliberately, caching is a scalpel. Used as a magic flag, it’s a stale-data generator.</p><p>If this saved you a production incident, follow <strong>devdomain</strong> for more deep-dives on Spring Boot, JPA, and Hibernate internals. Got a caching war story or a config that bit you? Drop it in the comments — I read and reply to every one.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=08b2769175f3" width="1" height="1" alt=""><hr><p><a href="https://medium.com/devdomain/hibernate-caching-explained-first-level-second-level-and-query-cache-08b2769175f3">Hibernate Caching Explained: First-Level, Second-Level, and Query Cache</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[CQRS and Event Sourcing with Spring Boot: A Practical Guide]]></title>
            <link>https://medium.com/devdomain/cqrs-and-event-sourcing-with-spring-boot-a-practical-guide-1c598de459ca?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/1c598de459ca</guid>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[event-sourcing]]></category>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[cqrs]]></category>
            <category><![CDATA[java]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Tue, 08 Sep 2026 09:01:02 GMT</pubDate>
            <atom:updated>2026-09-08T09:01:02.565Z</atom:updated>
            <content:encoded><![CDATA[<p>There are two patterns that get name-dropped together so often that many engineers assume they’re the same thing: CQRS and Event Sourcing. They’re not. You can do CQRS without event sourcing, event sourcing without CQRS, both, or — most often, and most correctly — neither. Confusing them, or adopting both because a conference talk made them sound inevitable, is one of the more expensive architectural mistakes a team can make.</p><p>This guide separates the two cleanly, shows real Spring Boot 3.x code for each, builds a worked example that combines them, and — most importantly — is blunt about when they help and when they’ll wreck your delivery timeline.</p><h3>CQRS: One Model Was Two Models All Along</h3><p>CQRS stands for <strong>Command Query Responsibility Segregation</strong>. The idea, distilled: <em>the model you use to change state should be separate from the model you use to read state.</em></p><p>In a traditional CRUD app, one object — usually a JPA entity — serves both jobs. You load Order, mutate it, save it; you also load Order to display it. The two jobs pull in opposite directions. Writes want normalized data, invariants, and small transactional units. Reads want denormalized, query-shaped data optimized for the screen that displays it. Serving both from one model means compromises on both.</p><p>CQRS splits them:</p><ul><li><strong>Commands</strong> express intent to change state (PlaceOrderCommand, CancelOrderCommand). They return nothing meaningful (maybe an ID or void). They go through a model rich in business rules.</li><li><strong>Queries</strong> ask for data (GetOrderSummary). They return DTOs. They never change state and can read from a model shaped exactly for the answer.</li></ul><pre>COMMAND SIDE (write)                      QUERY SIDE (read)<br>   ┌────────────────────┐                    ┌───────────────────┐<br>   │ PlaceOrderCommand   │                    │ GetOrderSummary     │<br>   │  -&gt; CommandHandler  │                    │  -&gt; QueryHandler    │<br>   │  -&gt; Aggregate       │                    │  -&gt; Read model DTO  │<br>   │  -&gt; Write store     │ ── propagate ─ₔ�▶  │  -&gt; Read store      │<br>   They return DTOs. └───────────────────┘                   └───────────────────┘</pre><p>At its simplest, CQRS needs no special infrastructure — just discipline. Two sets of handlers, two sets of objects, one database:</p><pre>// --- Command side ---<br>public record PlaceOrderCommand(UUID customerId, List&lt;LineItem&gt; items) {}</pre><pre>@Service<br>class PlaceOrderHandler {<br>    private final OrderRepository orders;     // write model<br>    PlaceOrderHandler(OrderRepository orders) { this.orders = orders; }</pre><pre>    @Transactional<br>    public UUID handle(PlaceOrderCommand cmd) {<br>        Order order = Order.place(cmd.customerId(), cmd.items()); // enforces invariants<br>        orders.save(order);<br>        return order.id().value();<br>    }<br>}</pre><pre>// --- Query side ---<br>public record OrderSummaryDto(UUID id, String customerName, BigDecimal total, String status) {}</pre><pre>@Service<br>class OrderQueryService {<br>    private final JdbcTemplate jdbc;          // read model, hand-tuned SQL, no JPA<br>    OrderQueryService(JdbcTemplate jdbc) { this.jdbc = jdbc; }</pre><pre>    public OrderSummaryDto getSummary(UUID id) {<br>        return jdbc.queryForObject(&quot;&quot;&quot;<br>            SELECT o.id, c.name AS customer_name, o.total, o.status<br>            FROM orders o JOIN customers c ON c.id = o.customer_id<br>            WHERE o.id = ?&quot;&quot;&quot;,<br>            (rs, n) -&gt; new OrderSummaryDto(<br>                rs.getObject(&quot;id&quot;, UUID.class), rs.getString(&quot;customer_name&quot;),<br>                rs.getBigDecimal(&quot;total&quot;), rs.getString(&quot;status&quot;)),<br>            id);<br>    }<br>}</pre><p>That’s “level 1” CQRS — same database, separate code paths. It’s cheap, it’s useful, and it’s where most teams should stop. You only escalate to separate read/write <em>databases</em> when read and write scaling profiles genuinely diverge.</p><h3>Event Sourcing: Store the Facts, Not the State</h3><p>Event Sourcing is a different idea entirely. Instead of storing the <em>current state</em> of an entity, you store the <strong>sequence of events that led to it</strong>. The current state is a left-fold over those events.</p><p>Traditional persistence:</p><pre>orders table:  id=42, status=SHIPPED, total=120.00</pre><p>Event-sourced persistence:</p><pre>event stream for order 42:<br>  1. OrderPlaced     {customerId, items, total: 120.00}<br>  2. OrderPaid       {paymentId, amount: 120.00}<br>  3. OrderShipped    {trackingNumber, carrier}</pre><p>To get the current state, you replay the events from the beginning. The events are the source of truth; current state is derived and disposable.</p><p>Why would anyone do this? Because the events are an <strong>immutable, complete audit log</strong> by construction. You never lose history — you know not just <em>that</em> an order is shipped, but <em>when</em> it was placed, paid, and shipped, by whom, and in what order. You can answer questions you didn’t know to ask when you designed the system, replay events to debug production, and build entirely new read models retroactively from history.</p><h3>The Aggregate as an Event Folder</h3><p>An event-sourced aggregate applies commands to produce events, and applies events to mutate its own state:</p><pre>public class Order {</pre><pre>    private OrderId id;<br>    private OrderStatus status;<br>    private Money total;<br>    private final List&lt;Object&gt; uncommittedEvents = new ArrayList&lt;&gt;();</pre><pre>    // ----- COMMANDS: validate, then emit events (do NOT mutate directly) -----<br>    public static Order place(OrderId id, CustomerId customer, List&lt;OrderLine&gt; lines) {<br>        if (lines.isEmpty()) throw new IllegalArgumentException(&quot;Empty order&quot;);<br>        Order order = new Order();<br>        Money total = lines.stream().map(OrderLine::subtotal).reduce(Money.ZERO, Money::add);<br>        order.raise(new OrderPlaced(id, customer, lines, total, Instant.now()));<br>        return order;<br>    }</pre><pre>    public void ship(String trackingNumber) {<br>        if (status != OrderStatus.PAID) {<br>            throw new IllegalStateException(&quot;Cannot ship an unpaid order&quot;);<br>        }<br>        raise(new OrderShipped(id, trackingNumber, Instant.now()));<br>    }</pre><pre>    // ----- APPLY: state changes happen ONLY here, driven by events -----<br>    private void apply(OrderPlaced e) {<br>        this.id = e.orderId(); this.status = OrderStatus.PLACED; this.total = e.total();<br>    }<br>    private void apply(OrderShipped e) { this.status = OrderStatus.SHIPPED; }</pre><pre>    private void raise(Object event) {<br>        dispatch(event);<br>        uncommittedEvents.add(event);<br>    }</pre><pre>    // Replay constructor: rebuild from history<br>    public static Order rehydrate(List&lt;Object&gt; history) {<br>        Order order = new Order();<br>        history.forEach(order::dispatch);   // apply without re-recording<br>        return order;<br>    }</pre><pre>    private void dispatch(Object event) {<br>        switch (event) {<br>            case OrderPlaced e  -&gt; apply(e);<br>            case OrderShipped e -&gt; apply(e);<br>            default -&gt; throw new IllegalStateException(&quot;Unknown event &quot; + event);<br>        }<br>    }</pre><pre>    public List&lt;Object&gt; uncommittedEvents() { return List.copyOf(uncommittedEvents); }<br>    public void markCommitted() { uncommittedEvents.clear(); }<br>}</pre><h3>A Minimal Event Store on Spring + JDBC</h3><p>You don’t need a specialized event-store database to start. A single append-only table with optimistic concurrency on a version column is enough:</p><pre>CREATE TABLE event_store (<br>    aggregate_id   UUID        NOT NULL,<br>    version        INT         NOT NULL,<br>    event_type     VARCHAR(200) NOT NULL,<br>    payload        JSONB       NOT NULL,<br>    occurred_at    TIMESTAMPTZ NOT NULL DEFAULT now(),<br>    PRIMARY KEY (aggregate_id, version)   -- enforces no two events at same version<br>);</pre><pre>@Repository<br>class JdbcEventStore {</pre><pre>    private final JdbcTemplate jdbc;<br>    private final ObjectMapper mapper;<br>    // constructor omitted</pre><pre>    @Transactional<br>    public void append(UUID aggregateId, int expectedVersion, List&lt;Object&gt; events) {<br>        int version = expectedVersion;<br>        for (Object event : events) {<br>            version++;<br>            try {<br>                jdbc.update(&quot;&quot;&quot;<br>                    INSERT INTO event_store (aggregate_id, version, event_type, payload)<br>                    VALUES (?, ?, ?, ?::jsonb)&quot;&quot;&quot;,<br>                    aggregateId, version, event.getClass().getName(), toJson(event));<br>            } catch (DuplicateKeyException dup) {<br>                throw new ConcurrencyException(aggregateId, version); // someone else wrote<br>            }<br>        }<br>    }</pre><pre>    public List&lt;Object&gt; load(UUID aggregateId) {<br>        return jdbc.query(<br>            &quot;SELECT event_type, payload FROM event_store WHERE aggregate_id = ? ORDER BY version&quot;,<br>            (rs, n) -&gt; fromJson(rs.getString(&quot;event_type&quot;), rs.getString(&quot;payload&quot;)),<br>            aggregateId);<br>    }<br>}</pre><p>The PRIMARY KEY (aggregate_id, version) is doing the heavy lifting: two concurrent commands that both think they(re at version 3 can&#39;t both insert version 4 — one wins, the other gets a ConcurrencyException and retries. This is optimistic concurrency for free.</p><h3>Projections: Building Read Models from Events</h3><p>The query side of an event-sourced CQRS system is built by <strong>projections</strong> — handlers that consume events and write to read-optimized tables. This is where CQRS and Event Sourcing fit together so naturally: the write side emits events, the read side projects them into whatever shape the UI needs.</p><pre>@Component<br>class OrderSummaryProjection {</pre><pre>    private final JdbcTemplate jdbc;<br>    // constructor omitted</pre><pre>    @EventListener<br>    void on(OrderPlaced e) {<br>        jdbc.update(&quot;&quot;&quot;<br>            INSERT INTO order_summary (id, customer_id, total, status)<br>            VALUES (?, ?, ?, &#39;PLACED&#39;)&quot;&quot;&quot;,<br>            e.orderId().value(), e.customerId().value(), e.total().amount());<br>    }</pre><pre>    @EventListener<br>    void on(OrderShipped e) {<br>        jdbc.update(&quot;UPDATE order_summary SET status = &#39;SHIPPED&#39; WHERE id = ?&quot;,<br>            e.orderId().value());<br>    }<br>}</pre><h3>Rebuilding State and Replaying</h3><p>The superpower of event sourcing: a read model is <em>disposable</em>. Need a new report? Write a new projection and replay the entire event log through it. Found a projection bug? Truncate the read table, replay, done. Want to debug a production incident? Replay events into a test environment to reproduce the exact state.</p><pre>// Rebuild a projection from scratch<br>public void rebuildOrderSummary() {<br>    jdbc.update(&quot;TRUNCATE order_summary&quot;);<br>    eventStore.streamAllInOrder().forEach(eventPublisher::publishEvent);<br>}</pre><p>This is impossible with state-based storage — you only ever had “the current value,” and the path to it is gone.</p><h3>Snapshots — when replay gets slow</h3><p>Replaying 50,000 events to load one aggregate is slow. <strong>Snapshots</strong> store the materialized state at version N; on load you start from the snapshot and replay only events after it.</p><pre>public Order load(UUID id) {<br>    Snapshot snap = snapshots.latest(id).orElse(null);<br>    if (snap == null) return Order.rehydrate(eventStore.load(id));<br>    Order order = Order.fromSnapshot(snap.state());<br>    order.replay(eventStore.loadAfter(id, snap.version()));<br>    return order;<br>}</pre><h3>Eventual Consistency: The Tax You Must Pay</h3><p>Here is the part the conference talks gloss over. The moment you have a separate read store updated by projections, <strong>the read side lags the write side.</strong> A user places an order (command succeeds), immediately navigates to “my orders,” and… it isn’t there yet, because the projection hasn’t run.</p><p>This is <em>eventual consistency</em>, and it is not a bug you can fix — it is the defining trade-off. You manage it, you don’t eliminate it:</p><ul><li><strong>Return the new ID/state from the command</strong> so the UI can render optimistically without a round trip to the read model.</li><li><strong>Show “processing” states</strong> in the UI for operations the user just triggered.</li><li><strong>Keep the projection lag small</strong> (synchronous in-process projections lag by milliseconds; async cross-service projections by seconds).</li><li><strong>Never enforce a write-side invariant by querying the read model</strong> — it may be stale. Invariants live in the aggregate, always.</li></ul><p>Aspect Plain CRUD CQRS (1 DB) CQRS + Event Sourcing Read/write models Shared Separate Separate Source of truth Current state Current state Event log Audit / history Manual Manual Built-in Read consistency Strong Strong or eventual Eventual Operational complexity Low Medium High Time-travel / replay No No Yes Good default? Yes Sometimes Rarely</p><h3>When CQRS / Event Sourcing Help — and When They Hurt</h3><p><strong>Reach for CQRS when:</strong></p><ul><li>Read and write workloads scale very differently (e.g., 1000:1 read:write).</li><li>Read queries are complex and would force ugly compromises on the write model.</li><li>Different teams own reads and writes.</li></ul><p><strong>Reach for Event Sourcing when:</strong></p><ul><li>A complete, immutable audit trail is a hard requirement (finance, healthcare, compliance).</li><li><em>Temporal</em> questions matter (“what did the balance look like on March 3rd?”).</li><li>You genuinely need to build new read models from historical data after the fact.</li></ul><p><strong>Avoid both when:</strong></p><ul><li>Your domain is simple CRUD. The overhead buys you nothing and costs you everything.</li><li>Your team is new to the patterns and under deadline. The learning curve is real and the failure modes are subtle.</li><li>Strong read-after-write consistency is a product requirement you can’t design around.</li><li>You’d be adopting them for résumé reasons. (Be honest.)</li></ul><h3>Common Pitfalls</h3><ul><li><strong>Adopting both because they’re “always together.”</strong> They’re independent. Start with CQRS-lite if you need it; add event sourcing only for a specific, justified reason.</li><li><strong>Mutable or “fixable” events.</strong> Events are immutable historical facts. You never edit a stored event. To correct a mistake, append a <em>compensating</em> event (OrderCorrected), preserving the true history.</li><li><strong>Event schema evolution ignored.</strong> Your stored events from 2023 must still deserialize in 2026. Version events, use upcasters, and never remove fields blindly. This is the single most underestimated long-term cost.</li><li><strong>Giant aggregates / huge streams.</strong> A stream with millions of events is slow to replay and a snapshot maintenance burden. Keep aggregates small.</li><li><strong>Leaking eventual consistency to users without UX design.</strong> “Where did my order go?” support tickets are an architecture smell that reached the customer.</li><li><strong>Querying the read model to make write decisions.</strong> Stale data plus business invariant equals data corruption. Invariants belong in the aggregate.</li><li><strong>No idempotent projections.</strong> Events can be redelivered. A projection that does balance += amount without idempotency double-counts. Make handlers idempotent (e.g., track last processed version).</li></ul><h3>Closing Thoughts</h3><p>CQRS and Event Sourcing are sharp tools. CQRS — even the lightweight, single-database flavor — is broadly useful and low-risk; separating the intent to change from the act of reading clarifies most non-trivial domains. Event Sourcing is powerful but heavy: it pays off spectacularly where audit, temporality, and replay are first-class needs, and it punishes teams who adopt it for novelty. Treat eventual consistency as a design constraint, not a surprise. And when in doubt, start simple — you can evolve a clean CRUD app toward CQRS far more easily than you can dig out of an event-sourcing system you didn’t actually need.</p><p>If this clarified the difference, follow <strong>devdomain</strong> for more pragmatic architecture writing. Have you run event sourcing in production — what bit you that you wish you’d known? Tell me in the comments.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1c598de459ca" width="1" height="1" alt=""><hr><p><a href="https://medium.com/devdomain/cqrs-and-event-sourcing-with-spring-boot-a-practical-guide-1c598de459ca">CQRS and Event Sourcing with Spring Boot: A Practical Guide</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[How Spring Boot Auto-Configuration Actually Works (Under the Hood)]]></title>
            <link>https://medium.com/devdomain/how-spring-boot-auto-configuration-actually-works-under-the-hood-2a2c9c0e5907?source=rss----026d10b274d0---4</link>
            <guid isPermaLink="false">https://medium.com/p/2a2c9c0e5907</guid>
            <category><![CDATA[spring-framework]]></category>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[starter]]></category>
            <category><![CDATA[java]]></category>
            <category><![CDATA[auto-configuration]]></category>
            <dc:creator><![CDATA[Marcelo Domingues]]></dc:creator>
            <pubDate>Thu, 03 Sep 2026 08:51:01 GMT</pubDate>
            <atom:updated>2026-09-03T08:51:01.020Z</atom:updated>
            <content:encoded><![CDATA[<p>Spring Boot feels like magic the first time you use it. You add spring-boot-starter-web, write a controller, and an embedded Tomcat boots on port 8080 — no XML, no web.xml, no manual DispatcherServlet wiring. Add a JDBC driver and a DataSource materializes. Remove it and the DataSource quietly disappears.</p><p>That “magic” is <strong>auto-configuration</strong>: a disciplined, conditional, ordered mechanism that looks at your classpath and existing beans and decides what infrastructure to wire up. Once you understand it, it stops being magic and becomes a tool you can read, debug, and extend. This article takes the lid off: how @EnableAutoConfiguration discovers configurations, how the @Conditional family decides what to apply, how ordering is resolved, how to read the conditions report, and how to write your own auto-configuration and package it as a starter.</p><h3>Where it all begins: @SpringBootApplication</h3><p>Your entry point is annotated with @SpringBootApplication, which is a meta-annotation composing three things:</p><pre>@SpringBootConfiguration   // @Configuration + marks the primary config<br>@EnableAutoConfiguration   // the star of this article<br>@ComponentScan             // scans the current package and below<br>public @interface SpringBootApplication { }</pre><p>@ComponentScan finds <em>your</em> beans. @EnableAutoConfiguration brings in <em>Spring Boot&#39;s</em> opinionated infrastructure beans. The two are independent: you can use component scanning without auto-configuration and vice versa.</p><h3>@EnableAutoConfiguration and the import mechanism</h3><p>@EnableAutoConfiguration is itself simple:</p><pre>@AutoConfigurationPackage<br>@Import(AutoConfigurationImportSelector.class)<br>public @interface EnableAutoConfiguration { }</pre><p>The workhorse is AutoConfigurationImportSelector, a DeferredImportSelector. &quot;Deferred&quot; matters: it runs <em>after</em> all your regular @Configuration classes and component-scanned beans are processed. That ordering is what allows auto-configuration to &quot;back off&quot; when you&#39;ve already defined a bean yourself — it gets to look last.</p><p>The selector’s job is to produce a list of fully-qualified class names of configuration classes to import. Where does that list come from?</p><h3>spring.factories vs AutoConfiguration.imports</h3><p>Historically (Spring Boot 2.x and earlier), candidate auto-configurations were listed in META-INF/spring.factories under the key org.springframework.boot.autoconfigure.EnableAutoConfiguration:</p><pre># META-INF/spring.factories (legacy, deprecated since 2.7)<br>org.springframework.boot.autoconfigure.EnableAutoConfiguration=\<br>com.example.MyAutoConfiguration,\<br>com.example.AnotherAutoConfiguration</pre><p>Since <strong>Spring Boot 2.7</strong>, and as the <em>only</em> supported mechanism in <strong>3.x</strong>, the list lives in a dedicated file:</p><pre>META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports</pre><p>One fully-qualified class name per line:</p><pre>com.example.MyAutoConfiguration<br>com.example.AnotherAutoConfiguration</pre><p>This change is more than cosmetic. The new format is read with a faster, purpose-built parser and avoids the overloaded spring.factories file (which is still used for other extension points like ApplicationContextInitializer). If you maintain a library, migrate to AutoConfiguration.imports.</p><pre>┌──────────────────────────────────────────────────────────────┐<br>│ Application start                                              │<br>│   @EnableAutoConfiguration                                     │<br>│                                                         │<br>│                                                           │<br>│        ▼                                                       │<br>│   AutoConfigurationImportSelector (DeferredImportSelector)     │<br>│        │  1. read every jar&#39;s AutoConfiguration.imports        │<br>│        │  2. remove exclusions &amp; duplicates                    │<br>│        │  3. apply AutoConfigurationImportFilter (fast classpath│<br>│        │     checks via condition metadata)                    │<br>│        │  4. sort with @AutoConfiguration before/after + order  │<br>│        ▼                                                       │<br>│   Each surviving class evaluated against its @Conditional(s)   │<br>│        │                                                       │<br>│        ▼                                                       │<br>│   Beans registered only where every condition matches          │<br>└─────────────────────────────────────────────────────────────┘</pre><h3>@AutoConfiguration: the modern annotation</h3><p>In Spring Boot 3.x, auto-configuration classes are annotated with @AutoConfiguration rather than plain @Configuration. It&#39;s a specialized @Configuration(proxyBeanMethods = false) that also carries ordering hints:</p><pre>import org.springframework.boot.autoconfigure.AutoConfiguration;<br>import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;</pre><pre>@AutoConfiguration(after = DataSourceAutoConfiguration.class)<br>@ConditionalOnClass(JdbcTemplate.class)<br>public class MyJdbcAutoConfiguration {<br>    // bean methods<br>}</pre><p>proxyBeanMethods = false (the default for @AutoConfiguration) means bean methods are called in &quot;lite&quot; mode — faster and the recommended style, but it means calling one @Bean method from another returns a <em>new</em> instance, not the shared singleton. Inject dependencies as method parameters instead of calling sibling @Bean methods.</p><h3>The @Conditional family</h3><p>Conditions are the brain of auto-configuration. Each is a @Conditional that consults a Condition implementation returning true/false. Spring Boot ships a rich set:</p><p>Annotation Matches when… @ConditionalOnClass A class is present on the classpath @ConditionalOnMissingClass A class is absent @ConditionalOnBean A bean of a type/name already exists @ConditionalOnMissingBean No such bean exists yet (back-off) @ConditionalOnProperty A property has a given value (or just exists) @ConditionalOnResource A classpath resource exists @ConditionalOnWebApplication The app is a servlet/reactive web app @ConditionalOnNotWebApplication The app is not a web app @ConditionalOnExpression A SpEL expression evaluates true @ConditionalOnSingleCandidate Exactly one candidate bean (or a primary) exists</p><h3>@ConditionalOnMissingBean: the back-off contract</h3><p>This is the condition that makes Spring Boot feel polite. An auto-configuration provides a default <em>only if you haven’t</em>:</p><pre>@AutoConfiguration<br>public class JacksonAutoConfigurationLike {</pre><pre>    @Bean<br>    @ConditionalOnMissingBean<br>    public ObjectMapper objectMapper() {<br>        return new ObjectMapper();<br>    }<br>}</pre><p>If you define your own ObjectMapper bean anywhere, the auto-configured one backs off. Because AutoConfigurationImportSelector is deferred, the framework evaluates your beans first, so @ConditionalOnMissingBean sees them.</p><p>A subtle gotcha: @ConditionalOnMissingBean checks the bean <em>type</em>. If your custom bean is registered under a more specific type than the auto-config checks for, the back-off may not trigger. Be explicit:</p><pre>@Bean<br>@ConditionalOnMissingBean(ObjectMapper.class)<br>public ObjectMapper objectMapper() { /* ... */ }</pre><h3>Ordering of conditions matters</h3><p>@ConditionalOnBean and @ConditionalOnMissingBean operate on the <em>current state</em> of the bean factory at evaluation time. Because auto-configurations are evaluated in a specific order, a @ConditionalOnBean only sees beans defined earlier — either by user config or by an auto-configuration ordered before it. This is exactly why ordering exists.</p><h3>Ordering auto-configurations</h3><p>Three tools control order, and they all live on the class:</p><pre>@AutoConfiguration(<br>    after = DataSourceAutoConfiguration.class,<br>    before = SomeOtherAutoConfiguration.class)</pre><p>Or the standalone annotations for cross-cutting hints:</p><pre>@AutoConfigureAfter(DataSourceAutoConfiguration.class)<br>@AutoConfigureBefore(JpaAutoConfiguration.class)<br>@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)</pre><ul><li>@AutoConfigureAfter / before express <em>relative</em> ordering against named configurations. Use these when your config depends on another having run.</li><li>@AutoConfigureOrder sets an absolute priority for tie-breaking.</li></ul><p>Critically, <strong>ordering is not dependency injection ordering</strong> — it’s the order in which configuration <em>classes</em> are processed, which determines what beans exist when each class’s conditions are evaluated. The actual bean instantiation order is still driven by the dependency graph.</p><h3>A complete worked example</h3><p>Suppose we build a library that provides a GreetingService, but only when a greeting template property is present and the user hasn&#39;t supplied their own service.</p><pre>package com.acme.greeting;</pre><pre>public class GreetingService {<br>    private final String template;<br>    public GreetingService(String template) { this.template = template; }<br>    public String greet(String name) { return template.formatted(name); }<br>}</pre><p>The configuration properties record:</p><pre>package com.acme.greeting;</pre><pre>import org.springframework.boot.context.properties.ConfigurationProperties;</pre><pre>@ConfigurationProperties(prefix = &quot;acme.greeting&quot;)<br>public record GreetingProperties(String template) {<br>    public GreetingProperties {<br>        if (template == null) template = &quot;Hello, %s!&quot;;<br>    }<br>}</pre><p>The auto-configuration:</p><pre>package com.acme.greeting;</pre><pre>import org.springframework.boot.autoconfigure.AutoConfiguration;<br>import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;<br>import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;<br>import org.springframework.boot.context.properties.EnableConfigurationProperties;<br>import org.springframework.context.annotation.Bean;</pre><pre>@AutoConfiguration<br>@ConditionalOnProperty(prefix = &quot;acme.greeting&quot;, name = &quot;enabled&quot;, havingValue = &quot;true&quot;, matchIfMissing = true)<br>@EnableConfigurationProperties(GreetingProperties.class)<br>public class GreetingAutoConfiguration {</pre><pre>    @Bean<br>    @ConditionalOnMissingBean<br>    public GreetingService greetingService(GreetingProperties props) {<br>        return new GreetingService(props.template());<br>    }<br>}</pre><p>Register it so the import selector finds it. Create:</p><pre>src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports</pre><p>with one line:</p><pre>com.acme.greeting.GreetingAutoConfiguration</pre><p>Now any Spring Boot app that adds this jar gets a GreetingService — unless they define their own, or set acme.greeting.enabled=false. That&#39;s the whole contract.</p><h3>Generating metadata for fast filtering</h3><p>Add the annotation processors so your config integrates with the conditions report and IDE auto-completion:</p><pre>&lt;dependency&gt;<br>    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;<br>    &lt;artifactId&gt;spring-boot-configuration-processor&lt;/artifactId&gt;<br>    &lt;optional&gt;true&lt;/optional&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-autoconfigure-processor&lt;/artifactId&gt;<br>    &lt;optional&gt;true&lt;/optional&gt;<br>&lt;/dependency&gt;</pre><p>The autoconfigure-processor generates META-INF/spring-autoconfigure-metadata.properties, which lets AutoConfigurationImportFilter reject configurations <em>before</em> loading their classes — a meaningful startup-time optimization in large apps. The configuration-processor generates spring-configuration-metadata.json so acme.greeting.template autocompletes in your IDE&#39;s application.yml.</p><h3>Writing a custom @Conditional</h3><p>When the built-in conditions aren’t enough, write your own Condition. Suppose you want a bean only when the app runs on a machine with at least 4 CPUs:</p><pre>package com.acme.greeting;</pre><pre>import org.springframework.context.annotation.Condition;<br>import org.springframework.context.annotation.ConditionContext;<br>import org.springframework.core.type.AnnotatedTypeMetadata;</pre><pre>public class OnMultiCoreCondition implements Condition {<br>    @Override<br>    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {<br>        return Runtime.getRuntime().availableProcessors() &gt;= 4;<br>    }<br>}</pre><p>Apply it with @Conditional(OnMultiCoreCondition.class) on a @Bean method or a configuration class. For richer cases, extend SpringBootCondition, which provides a ConditionOutcome with a human-readable message that shows up in the conditions report — far more debuggable than a bare boolean:</p><pre>import org.springframework.boot.autoconfigure.condition.ConditionOutcome;<br>import org.springframework.boot.autoconfigure.condition.SpringBootCondition;<br>import org.springframework.context.annotation.ConditionContext;<br>import org.springframework.core.type.AnnotatedTypeMetadata;</pre><pre>public class OnMultiCoreCondition extends SpringBootCondition {<br>    @Override<br>    public ConditionOutcome getMatchOutcome(ConditionContext ctx, AnnotatedTypeMetadata md) {<br>        int cores = Runtime.getRuntime().availableProcessors();<br>        return cores &gt;= 4<br>            ? ConditionOutcome.match(&quot;found &quot; + cores + &quot; cores&quot;)<br>            : ConditionOutcome.noMatch(&quot;only &quot; + cores + &quot; cores&quot;);<br>    }<br>}</pre><p>A crucial rule: conditions evaluated at the <em>class</em> level via @ConditionalOnClass must reference the gated class only in the <em>annotation</em>, never in a method signature you might touch before the condition runs. Spring Boot is careful to read @ConditionalOnClass values from the annotation metadata via ASM, without loading the class — so a missing class doesn&#39;t cause NoClassDefFoundError. If you reference the class directly in a field or return type that gets resolved during candidate filtering, you defeat that protection. Keep gated types behind the condition.</p><h3>How auto-configuration interacts with your @Configuration</h3><p>A common question: if both my @Configuration and an auto-configuration define a bean, who wins? Because the import selector is <em>deferred</em>, your beans are registered first. Auto-configurations using @ConditionalOnMissingBean then see your bean and back off — so <strong>you win</strong>. This is the entire design philosophy of Spring Boot: provide sensible defaults, but always let the application override them. The framework never fights you for a bean you explicitly declared.</p><p>This also means the order of your own configuration classes relative to auto-configuration is fixed and predictable: user configuration always precedes auto-configuration. You don’t need ordering annotations on your own @Configuration to &quot;beat&quot; an auto-config — the deferred import guarantees it.</p><h3>The auto-configuration vs starter distinction</h3><p>People say “starter” loosely, but there’s a clean separation:</p><ul><li>An <strong>auto-configuration</strong> is the code: @AutoConfiguration classes plus their .imports file.</li><li>A <strong>starter</strong> is a (usually code-free) dependency aggregator: a pom.xml that pulls in the auto-configuration plus the libraries it configures.</li></ul><p>By convention, name your starter acme-spring-boot-starter (third-party starters use the &lt;name&gt;-spring-boot-starter suffix; spring-boot-starter-&lt;name&gt; is reserved for the Spring team). The starter&#39;s only job is convenient dependency management:</p><pre>&lt;!-- acme-greeting-spring-boot-starter/pom.xml --&gt;<br>&lt;dependencies&gt;<br>    &lt;dependency&gt;<br>        &lt;groupId&gt;com.acme&lt;/groupId&gt;<br>        &lt;artifactId&gt;acme-greeting-autoconfigure&lt;/artifactId&gt;<br>    &lt;/dependency&gt;<br>    &lt;!-- plus any libraries the autoconfigure module needs at runtime --&gt;<br>&lt;/dependencies&gt;</pre><p>Keeping autoconfigure and starter as separate modules lets advanced users depend on the autoconfigure module alone if they want finer control.</p><h3>Debugging with the conditions report</h3><p>When auto-configuration does something you don’t expect — or <em>doesn’t</em> do something you expected — turn on the conditions evaluation report:</p><pre>debug=true</pre><p>or run with --debug. At startup you get a report with three sections:</p><ul><li><strong>Positive matches</strong> — configurations that applied, with the condition that matched.</li><li><strong>Negative matches</strong> — configurations that did <em>not</em> apply, and exactly which condition failed.</li><li><strong>Unconditional classes</strong> — always applied.</li></ul><p>A negative match reads like this:</p><pre>DataSourceAutoConfiguration:<br>   Did not match:<br>      - @ConditionalOnClass did not find required class<br>        &#39;javax.sql.DataSource&#39; (OnClassCondition)</pre><p>This is the single most useful debugging tool for auto-configuration. Instead of guessing why your DataSource didn&#39;t appear, the report tells you the exact condition that vetoed it.</p><p>You can also hit the Actuator conditions endpoint at runtime for the same data as JSON:</p><pre>management.endpoints.web.exposure.include=conditions</pre><h3>Excluding auto-configurations</h3><p>Sometimes you want to opt out. Three equivalent ways:</p><pre>@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })</pre><pre>spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration</pre><p>The property form is handy when the class isn’t on your compile classpath. If you exclude a class that <em>isn’t</em> a candidate auto-configuration, Spring Boot fails fast with a clear error — so typos surface immediately.</p><h3>Common pitfalls</h3><ul><li><strong>Using plain </strong><strong>@Configuration for auto-config.</strong> In 3.x, use @AutoConfiguration so ordering hints and the deferred-import contract work correctly. Plain @Configuration listed in .imports won&#39;t be ordered like an auto-configuration.</li><li><strong>Calling sibling </strong><strong>@Bean methods.</strong> With proxyBeanMethods = false (the @AutoConfiguration default), calling one bean method from another returns a fresh instance. Inject the dependency as a parameter instead.</li><li><strong>Forgetting the </strong><strong>.imports file.</strong> No file, no discovery. The class is invisible to the selector no matter how it&#39;s annotated.</li><li><strong>@ConditionalOnMissingBean type mismatch.</strong> If your custom bean&#39;s declared type differs from what the condition checks, back-off won&#39;t trigger. Name the type explicitly.</li><li><strong>Ordering assumptions.</strong> @ConditionalOnBean only sees beans from earlier-evaluated configs. If your condition depends on another auto-config&#39;s bean, declare @AutoConfigureAfter for it.</li><li><strong>spring.factories in new code.</strong> It still works for some extension points but is the wrong place for auto-config in 3.x. Use AutoConfiguration.imports.</li><li><strong>Heavy condition logic.</strong> Custom Condition classes run during context startup. Keep them cheap; avoid I/O.</li></ul><h3>Wrapping up</h3><p>Auto-configuration is not magic — it’s a deferred import selector that reads a list of candidate @AutoConfiguration classes from AutoConfiguration.imports, filters them with fast metadata checks, orders them, and evaluates each against its @Conditional annotations. The back-off behavior you rely on every day comes from @ConditionalOnMissingBean plus the fact that your beans are processed first. Armed with the conditions report and the @Conditional family, you can read exactly why any bean did or didn&#39;t appear — and you can package your own infrastructure as a clean, reusable starter that behaves just like Spring Boot&#39;s own.</p><p>If this cleared up the magic for you, follow <strong>devdomain</strong> for more under-the-hood Spring deep dives, and tell me in the comments which auto-configuration surprised you most when you first read its source.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=2a2c9c0e5907" width="1" height="1" alt=""><hr><p><a href="https://medium.com/devdomain/how-spring-boot-auto-configuration-actually-works-under-the-hood-2a2c9c0e5907">How Spring Boot Auto-Configuration Actually Works (Under the Hood)</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>
    </channel>
</rss>