<?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[Spring Boot World - Medium]]></title>
        <description><![CDATA[Your comprehensive resource for mastering Spring Boot development. Discover tutorials, best practices, and real-world solutions for building modern Java applications - Medium]]></description>
        <link>https://medium.com/spring-boot-world?source=rss----61087b1caf41---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>Spring Boot World - Medium</title>
            <link>https://medium.com/spring-boot-world?source=rss----61087b1caf41---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sun, 15 Nov 2026 22:24:54 GMT</lastBuildDate>
        <atom:link href="https://medium.com/feed/spring-boot-world" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[The Dual Write Problem: the Outbox Answer That Passes the Interview]]></title>
            <link>https://medium.com/spring-boot-world/the-dual-write-problem-the-outbox-answer-that-passes-the-interview-38fdfc626a87?source=rss----61087b1caf41---4</link>
            <guid isPermaLink="false">https://medium.com/p/38fdfc626a87</guid>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[system-design-interview]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[system-design-concepts]]></category>
            <category><![CDATA[software-engineering]]></category>
            <dc:creator><![CDATA[Lucas Fernandes ‍]]></dc:creator>
            <pubDate>Thu, 12 Nov 2026 10:57:07 GMT</pubDate>
            <atom:updated>2026-11-12T11:12:02.824Z</atom:updated>
            <content:encoded><![CDATA[<h4>What If the Database Commits but Kafka Is Down?</h4><h3>Introduction</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Nx_NANPAoE1w-aFlEQy80g.jpeg" /></figure><p>You are in a senior backend interview. The interviewer says: <em>design checkout with Postgres and Kafka.</em></p><p>You draw an Order Service. You save the order. You send OrderCreated. Looks done.</p><p>Then the interviewer leans in: <strong>“What if the database commits but Kafka is down?”</strong></p><p>You freeze. That freeze is what costs you the offer.</p><p>This article covers the famous <strong>dual write problem</strong>, the two ways it breaks, and the pattern that answers it: the Transactional Outbox. At the end there is a cheat-sheet you can say out loud in 30 seconds. The same answer also keeps a real checkout honest in production.</p><p><em>Prefer video? Everything below runs live in this one:</em></p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FRuJJJ3q56Mk%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DRuJJJ3q56Mk&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FRuJJJ3q56Mk%2Fhqdefault.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/719f10aa14c43ad288cb52a8826d406c/href">https://medium.com/media/719f10aa14c43ad288cb52a8826d406c/href</a></iframe><blockquote><strong>Full code:</strong> <a href="https://github.com/ice-lfernandes/java-plus-ai-youtube-repos/tree/main/dual-write-outbox-pattern">dual-write-outbox-pattern on GitHub</a></blockquote><h3>Two writes that must agree</h3><p>The scenario: an Order Service, Postgres, and Kafka. The user clicks buy, and you owe two things that must agree:</p><ol><li>Save the order in the database.</li><li>Publish OrderCreated, so stock, payment and shipping can react.</li></ol><p>If both happen, you are consistent. If only one happens, the system lies. That gap is the whole interview.</p><p>Here is what most candidates draw: Order Service on the left, Postgres in the middle, Kafka on the right. One solid arrow to Postgres (save order), one solid arrow to Kafka (send event). It looks safe.</p><p>It is not, because there is no transaction that spans both. That is the definition of a <strong>dual write</strong>: two systems, no shared transaction. Put a cross on either arrow and watch what breaks.</p><h4>Failure A: the ghost order</h4><p>You save to Postgres. The commit succeeds. Then Kafka is down, or the network times out, or the broker rejects the batch.</p><p>Now the database says order 1234 exists. But stock was never reserved. Shipping never starts. Payment reconciliation sees nothing. Support gets a ticket: “where is my order?”</p><p>You can’t find the order in Kafka. The database says yes. The rest of the distributed system says nothing happened.</p><h4>Failure B: the ghost event</h4><p>So you try to be clever and send to Kafka first. It succeeds. Then Postgres fails: a duplicate key, a constraint violation, a serialization error.</p><p>Now you have reserved stock, triggered payment and started shipping prep, for an order that does not exist. You can’t query it. You can’t cancel it cleanly. You can’t refund it by id. Downstream teams act on a phantom.</p><p>Same diagram, opposite cross. If you stop here, it is the same failed interview.</p><h4>Retries don’t fix atomicity</h4><p>The follow-up kills most people. <em>Can you just retry? Can you swap the order? Can you add try/catch?</em></p><p>No. Retries don’t fix atomicity. They change the failure.</p><ul><li>Retry the Kafka send after Postgres committed, and you may send twice. If the first send reached the broker but your code timed out and sent again, the retry creates a duplicate, and downstream charges twice.</li><li>Retry Postgres after Kafka succeeded, and the event is already out, whatever the database decides.</li></ul><p>(One precision: the Kafka producer’s own internal retries don’t duplicate, because idempotence is on by default since Kafka 3.0. The duplicate comes from your <em>application</em> sending again.)</p><p>You moved the problem without solving it. Now you need the senior vocabulary. There are three delivery guarantees, and only two are real across two systems:</p><ul><li><strong>At most once:</strong> you may lose events, but you never duplicate.</li><li><strong>At least once:</strong> you never lose events, but duplicates are possible.</li><li><strong>Exactly once</strong>, across Postgres and Kafka, as two writes in your code with no shared transaction: does not exist. Anyone who promises it just failed the question.</li></ul><p>Note the scope. Kafka does have exactly-once semantics <em>inside</em> Kafka, and you can get exactly-once when the read position and the output live in the same system. What doesn’t exist is exactly-once for a dual write your application performs against two independent systems.</p><p>For checkout, money and inventory, the choice is <strong>at least once plus idempotence</strong>. A duplicate you can ignore beats a lost order you never knew about.</p><h3>Two fixes that look right (and aren’t)</h3><p>Before the answer, it helps to know exactly why the tempting ones fail. Each is a follow-up a good interviewer will try.</p><h4>@Transactional</h4><p>@Transactional gives you atomicity for <em>one</em> transaction manager. Your database and your broker are two resources, so there are two commits, one after the other. Whichever commits second can still fail after the first one succeeded. You are back to Failure A or Failure B, only with less obvious timing.</p><p>Spring does have support for putting both under one annotation. With KafkaTransactionManager (or a KafkaTemplate synchronized to another transaction manager), a Kafka transaction is started and committed <em>alongside</em> your database transaction. That reduces the window. It does not close it: the two commits are still separate, and the second one can fail after the first one went through. The old ChainedKafkaTransactionManager, which tried to chain them, has been deprecated since Spring Kafka 2.7. Read the <a href="https://docs.spring.io/spring-kafka/reference/kafka/transactions.html">Spring for Apache Kafka transactions chapter</a> and note what it promises: synchronization, not atomicity across both systems.</p><h4>Two Phase Commit</h4><p>2PC (two-phase commit) is the textbook answer to “atomic across two systems”. A coordinator asks every participant to <em>prepare</em>, and only if all say yes does it tell them to <em>commit</em>. It is real, and PostgreSQL supports <a href="https://www.postgresql.org/docs/current/two-phase.html">prepared transactions</a>. It still loses in this scenario, for three reasons:</p><ul><li><strong>Kafka is not an XA resource.</strong> Kafka does not join an external 2PC today. <a href="https://cwiki.apache.org/confluence/display/KAFKA/KIP-939%3A+Support+Participation+in+2PC">KIP-939</a> proposes it, but its public 2PC APIs were pulled from the Kafka 4.1 release until the feature is ready, and the work is still open (<a href="https://issues.apache.org/jira/browse/KAFKA-15370">KAFKA-15370</a>). Check the release notes before you quote this in an interview, because it is the one sentence here that can go stale.</li><li><strong>It blocks on failure.</strong> A participant that has voted yes holds its locks and waits for the coordinator. If the coordinator dies between the two phases, that participant is stuck until someone resolves it. For checkout, that means locked rows on your hottest table.</li><li><strong>It couples availability.</strong> Every checkout now needs the database <em>and</em> the broker <em>and</em> the coordinator healthy at the same moment. Microservices.io makes the same point: <a href="https://microservices.io/patterns/data/transactional-outbox.html">2PC is not viable</a> because the database or broker may not support it, and coupling the service to both is undesirable anyway.</li></ul><p>Both share one root: they try to make two independent systems agree at one instant. The outbox gives up on the instant and asks for agreement <em>eventually</em>, with a durable record in between.</p><h3>The sentence that passes</h3><p>If you propose Two Phase Commit here, you fail on the follow-up. 2PC blocks on failure (a prepared participant waits for the coordinator), and Kafka is not an XA resource. That is why the Outbox comes first.</p><p>Here is the sentence interviewers accept in most cases. Memorize it:</p><blockquote><strong><em>I avoid dual write by writing business state and an outbox record in one local transaction, then relaying that record to Kafka with at-least-once delivery and an idempotent consumer.</em></strong></blockquote><p>It names the strategy, the transaction boundary, and the trade-off with its fix. It takes fifteen seconds to say. The rest of the article is what happens when the interviewer asks you to unpack it.</p><h4>Step 1: one local transaction</h4><p>The code in this article comes from a small runnable demo: Spring Boot 4.1, Java 21, Postgres and Kafka in Docker Compose, Flyway migrations. Create a second table in the <em>same</em> Postgres database:</p><pre>CREATE TABLE outbox_events (<br>    id             BIGSERIAL PRIMARY KEY,<br>    aggregate_type TEXT NOT NULL,<br>    aggregate_id   TEXT NOT NULL,<br>    topic          TEXT NOT NULL,<br>    payload        JSONB NOT NULL,<br>    created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),<br>    published      BOOLEAN NOT NULL DEFAULT false<br>);</pre><p>It lives next to the orders table. That placement is the trick.</p><p>Your Java code changes in one critical way. Inside one @Transactional method you do two database writes and <strong>zero calls to Kafka</strong>:</p><pre>@Transactional<br>public Order placeOrder(NewOrder command) {<br>    Order order = orderRepository.save(Order.from(command));<br>    outboxRepository.save(OutboxEvent.orderCreated(order));<br>    return order;<br>}</pre><p>Same database, same local transaction. Either both rows commit, or both roll back. No partial success, no ghost order, no ghost event. Postgres atomicity protects both rows together.</p><p>You turned a distributed write into a local write.</p><p>One JPA detail: payload is JSONB in the table but a String in the entity. Without a hint, Hibernate binds it as varchar and Postgres rejects the insert. The demo maps it like this:</p><pre>@JdbcTypeCode(SqlTypes.JSON)<br>private String payload;</pre><p>One note on BIGSERIAL: the relay below reads in id order, so ids should grow with insertion. Don’t use a random UUID v4 here; if you need UUIDs, use UUIDv7. Know the limit, though: a sequence value is taken at insert time, not at commit time, so id order is close to commit order but not identical. Events of the same order still leave in order as long as the transactions that write them for one order run one after the other (for example, because each one updates that order’s row and takes its lock). Don’t promise global order.</p><h3>What goes in the outbox row</h3><p>A few design choices matter more than they look:</p><ul><li><strong>The payload is a contract.</strong> It is what other teams read from Kafka, so treat it like an API: version it, and don’t dump your internal entity into it.</li><li><strong>aggregate_id becomes the Kafka key.</strong> Events of the same order then land on the same partition, which is what preserves their order (more on that below).</li><li><strong>The outbox id is the event id.</strong> The consumer uses it to detect duplicates. Debezium’s own outbox example does the same: its docs describe the event id as usable <a href="https://debezium.io/blog/2019/02/19/reliable-microservices-data-exchange-with-the-outbox-pattern/">by consumers to detect duplicate events</a>.</li><li><strong>Treat the table as insert-only.</strong> The relay flips the published flag in the polling design, but nothing should rewrite the business content of an event after the fact.</li></ul><p>The idea is not new. Chris Richardson documents it as the <a href="https://microservices.io/patterns/data/transactional-outbox.html">Transactional Outbox pattern</a> on microservices.io, together with its two ways of publishing: the <em>polling publisher</em> and <em>transaction log tailing</em>. Those are exactly the polling and CDC options below.</p><h4>Step 2: the relay</h4><p>A separate relay publishes to Kafka, asynchronously. Start simple: a scheduler runs every few hundred milliseconds, selects a batch where published = false ordered by id, sends each row to Kafka with its outbox id as the event id, then marks it published.</p><pre>@Scheduled(fixedDelay = 300)<br>@Transactional<br>public void relay() {<br>    List&lt;OutboxEvent&gt; batch =<br>            outboxRepository.findTop100ByPublishedFalseOrderByIdAsc();<br>    if (batch.isEmpty()) {<br>        return;<br>    }<br><br>    int acked = 0;<br>    for (OutboxEvent event : batch) {<br>        ProducerRecord&lt;String, String&gt; record = new ProducerRecord&lt;&gt;(<br>                event.getTopic(), event.getAggregateId(), event.getPayload());<br>        record.headers().add(&quot;event_id&quot;,<br>                String.valueOf(event.getId()).getBytes(StandardCharsets.UTF_8));<br>        try {<br>            SendResult&lt;String, String&gt; result = kafkaTemplate.send(record).join(); // wait for the broker ack<br>            log.info(&quot;Kafka ack: eventId={} topic={} partition={} offset={}&quot;,<br>                    event.getId(),<br>                    result.getRecordMetadata().topic(),<br>                    result.getRecordMetadata().partition(),<br>                    result.getRecordMetadata().offset());<br>            event.markPublished();<br>            acked++;<br>        } catch (CompletionException ex) {<br>            log.error(&quot;Kafka send failed, will retry: eventId={} topic={}&quot;,<br>                    event.getId(), event.getTopic(), ex.getCause());<br>            break; // keep order: stop at first failure, retry next tick<br>        }<br>    }<br>    log.info(&quot;Outbox batch done: acked={} pending={}&quot;, acked, batch.size() - acked);<br>}</pre><p>The join() is not decoration. KafkaTemplate.send is asynchronous and returns a CompletableFuture. Without waiting for the ack, the method would mark the row published and commit even when the send failed, and you would lose the event. join() blocks until the broker answers, and a failed send surfaces as a CompletionException. (Prefer join() over get() here: get() throws checked exceptions, and @Transactional does not roll back on checked exceptions by default.)</p><p>On a failure the loop stops at that row on purpose. Rows that were already acked stay marked and commit. The failed row and everything after it stay unpublished and come back on the next tick, in the same order. Skipping the failed row and sending the next one would break per-order ordering. (Scheduling needs @EnableScheduling on the application class.)</p><p>The relay runs in its own thread, outside the request path. Checkout stays fast: the request waits for Postgres, not for Kafka.</p><p>This pattern is common enough that someone packaged it. <a href="https://github.com/namastack/namastack-outbox">Namastack Outbox</a> is an open-source library for Spring Boot: inside the same transaction you call outbox.schedule(payload, key), and the library runs the relay, the retries and the ordering per key. The interviewer will still ask how it works, so know the hand-rolled version.</p><h3>What if the relay crashes?</h3><p>That is the next question. Say the relay sends to Kafka, then crashes before marking the row published. On restart, it sends again.</p><p>That is expected, and correct. You get at-least-once by design. So you never promise exactly-once from the relay. You promise that <strong>the consumer can handle the same event twice.</strong></p><p>A lost event becomes impossible on a relay crash, because the row stays in the database until Kafka acks it. (After the ack, durability is Kafka’s job. Replication settings such as min.insync.replicas still matter.) A duplicate becomes harmless because of the next step.</p><h3>The relay in depth</h3><p>The relay is the part of the pattern that looks trivial and has the most edge cases. Think of it as a sequence of steps, and ask what a crash does at each one:</p><ul><li><strong>Crash before the send:</strong> nothing was published and the row is still unpublished. The next run sends it. Nothing lost, nothing duplicated.</li><li><strong>Crash after the send, before the commit that marks it:</strong> Kafka has the event, the database still says unpublished. The next run sends it again. A duplicate, handled by the consumer.</li><li><strong>Send fails (broker down, timeout):</strong> join() throws, the loop stops at that row, and it stays unpublished (rows acked before it commit as published). The relay keeps retrying while the outage lasts. Orders keep being accepted, and the outbox simply grows.</li><li><strong>Commit fails after a successful send:</strong> same as the second case: a duplicate later.</li></ul><p>So the relay has exactly one invariant: <em>a row is marked published only after the broker acknowledged it</em>. Everything else is allowed to repeat.</p><p>Three things to know when you run it for real:</p><ol><li><strong>Holding a transaction while you wait for Kafka.</strong> In the demo, the database transaction stays open during each send. That is simple and correct, but a slow broker means a long-lived transaction. Production relays often claim a batch, publish, and then mark in a short second transaction, accepting more duplicates in exchange. Which is right depends on your batch size and your broker’s latency.</li><li><strong>More than one relay instance.</strong> Two relays reading the same rows will publish everything twice. The standard fix is a row-level lock that skips rows another transaction already holds:</li></ol><pre>SELECT * FROM outbox_events<br>WHERE published = false<br>ORDER BY id<br>LIMIT 100<br>FOR UPDATE SKIP LOCKED;</pre><p>SKIP LOCKED has existed since PostgreSQL 9.5, and the <a href="https://www.postgresql.org/docs/current/sql-select.html">SELECT documentation</a> warns that it provides an inconsistent view of the data. That is the price: with several instances, two events of the same order can be picked by different relays and leave out of order. Libraries such as Namastack advertise strict ordering per key with automatic partitioning across instances; if you hand-roll it, you have to build that yourself.</p><p>3. <strong>Poison rows.</strong> A payload that can never be serialized or a topic that does not exist will fail on every run and block the head of the queue. Decide up front: retry with backoff, count attempts, and move the row to a failed state that raises an alert instead of retrying forever. This is another reason to reach for a library at the start.</p><h4>Producer settings that make “at least once” real</h4><p>At-least-once only holds if the producer is configured not to drop acknowledged-looking writes:</p><ul><li><strong>acks=all</strong> makes the leader wait for the in-sync replicas before acknowledging.</li><li><strong>enable.idempotence=true</strong> stops the producer’s own internal retries from creating duplicates. It has been the default since Kafka 3.0 (<a href="https://cwiki.apache.org/confluence/display/KAFKA/KIP-679%3A+Producer+will+enable+the+strongest+delivery+guarantee+by+default">KIP-679</a>), except for a bug that left it off in 3.0.0 and 3.1.0 (KAFKA-13598).</li><li><strong>min.insync.replicas</strong> defaults to 1, so “acknowledged” can mean one copy. For data you can’t lose, raise it together with the topic’s replication factor. See the <a href="https://kafka.apache.org/documentation/#brokerconfigs_min.insync.replicas">broker configs</a>.</li></ul><p>Note what idempotence does <em>not</em> do: it dedupes retries inside one producer session. It cannot know that your relay crashed and re-sent the same row after a restart. That duplicate is yours to handle at the consumer.</p><h3>Step 3: the idempotent consumer</h3><p>Most tutorials stop at the relay. Don’t.</p><p>The consumer keeps a table called processed_events, with the event id as the primary key:</p><pre>CREATE TABLE processed_events (<br>    event_id     TEXT PRIMARY KEY,<br>    processed_at TIMESTAMPTZ NOT NULL DEFAULT now()<br>);</pre><p>When OrderCreated arrives, the stock service reads the event_id header, opens a local transaction and inserts that id with ON CONFLICT DO NOTHING:</p><pre>@KafkaListener(topics = &quot;orders&quot;)<br>@Transactional<br>public void onOrderCreated(ConsumerRecord&lt;String, String&gt; record) {<br>    String eventId = new String(<br>            record.headers().lastHeader(&quot;event_id&quot;).value(), StandardCharsets.UTF_8);<br><br>    int inserted = jdbc.update(<br>            &quot;INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT (event_id) DO NOTHING&quot;,<br>            eventId);<br>    if (inserted == 0) {<br>        log.info(&quot;event {} already processed, skipping&quot;, eventId);<br>        return;<br>    }<br><br>    JsonNode json = mapper.readTree(record.value());<br>    jdbc.update(&quot;UPDATE stock SET quantity = quantity - ? WHERE product = ?&quot;,<br>            json.get(&quot;quantity&quot;).asInt(), json.get(&quot;product&quot;).asText());<br>    log.info(&quot;event {} applied: stock decremented&quot;, eventId);<br>}</pre><p>If the row went in, apply the business update in that same transaction: here, decrement stock. A real system would also create the shipment, in the same transaction if it lives in the same database. If the same event arrives again, the insert hits the primary key and writes nothing. Zero rows means duplicate, so you skip the side effect. The duplicate dies there.</p><p>Two details worth saying out loud:</p><ul><li>Use the <strong>outbox id</strong> as the event id, not the aggregate_id. Two different events for the same order would collide on the aggregate id.</li><li>A charge on an external payment gateway sits <em>outside</em> your database transaction. Pass the event id as that gateway’s idempotency key.</li></ul><p>Even the Namastack docs say it: handlers and downstream consumers should be idempotent, because a record may be delivered more than once.</p><h4>The idempotent consumer in depth</h4><p>The pattern comes from the same catalog: <a href="https://microservices.io/patterns/communication-style/idempotent-consumer.html">Idempotent Consumer</a>. It works because the dedupe record and the business change commit in the <em>same</em> local transaction. Three consequences:</p><ul><li><strong>Order of operations matters.</strong> Insert into processed_events first, then do the work, all in one transaction. If the work throws, the insert rolls back too, so the retry is processed instead of being skipped as a duplicate. Debezium’s example makes the same point: if the transaction rolls back, <a href="https://debezium.io/blog/2019/02/19/reliable-microservices-data-exchange-with-the-outbox-pattern/">the event is not marked as processed</a>, and the exception sends it back to the Kafka consumer loop to be retried.</li><li><strong>Commit the Kafka offset after the database commit.</strong> If you commit the offset first and crash, the event is lost. If you commit it after, a crash causes redelivery, which the table absorbs. Again: at-least-once, made safe.</li><li><strong>ON CONFLICT DO NOTHING reports zero rows for a skipped insert.</strong> That is why the code can use the row count as the duplicate signal. See the <a href="https://www.postgresql.org/docs/current/sql-insert.html">INSERT documentation</a>.</li></ul><p>Other ways to get idempotency, when a dedupe table is not the best fit:</p><ul><li><strong>Natural idempotency.</strong> Some operations are safe to repeat by nature: “set status to SHIPPED” or an upsert keyed by the order id. If the handler can be written that way, you may not need a table.</li><li><strong>Version or sequence checks.</strong> Store the last applied version per aggregate and ignore anything older or equal.</li><li><strong>Idempotency keys at the edge.</strong> For calls to an external system, such as a payment gateway, send the event id as the gateway’s idempotency key. Your database cannot roll back their side effect.</li></ul><p>Operationally, processed_events grows forever unless you trim it. A reasonable rule is to delete rows older than the longest time an old event could still be redelivered (retention of the topic, plus any replay window you allow). That number is your call, not a standard: size it from your own retention settings.</p><h3>Polling first, CDC later</h3><p>If you made it this far, here is a bonus for the interview. There is still room for an upgrade.</p><p>The line that shows judgment: <strong>“I start with polling for simplicity, and I move to CDC when I need speed.”</strong></p><p><strong>Polling</strong> (what we built):</p><ul><li>Simple, easy to understand, easy to debug with SQL: you can see the unpublished rows, and you can replay by resetting a flag.</li><li>Good enough for most checkouts.</li><li>The cost is load and delay: you poll the database, and you add tens to hundreds of milliseconds before an event leaves.</li><li>With several relay instances you need SELECT … FOR UPDATE SKIP LOCKED, and then two events of the same order can leave out of order. That is why libraries such as Namastack partition the work by key across instances.</li></ul><p><strong>CDC (Change Data Capture)</strong> with <a href="https://debezium.io/">Debezium</a>:</p><ul><li>It doesn’t replace the Outbox. It replaces <em>polling</em>. Debezium tails the Postgres write-ahead log, and every insert into the outbox table streams to Kafka almost instantly.</li><li>No selects, no published flag, lower latency, less load on the database.</li><li>The cost is operations: you run Kafka Connect, manage connectors, schemas, snapshots and restarts, and monitor lag in a new system. Postgres needs wal_level=logical (see <a href="https://www.postgresql.org/docs/current/logicaldecoding-explanation.html">logical decoding</a>).</li><li>Debezium treats the outbox as an insert-only queue (it ships an <a href="https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html">EventRouter transform</a> for this), so your table schema may need a mapping.</li></ul><p>For ordering, use the order id as the Kafka key, so events for the same order land on the same partition. Kafka keeps order inside a partition, as long as your relay sends them in order. And with polling, archive published rows nightly so the table stays fast.</p><p>So say it like this: <strong>polling first, log-based CDC when latency actually hurts and you can afford the operations.</strong></p><h3>Your 30-second cheat-sheet</h3><p>Screenshot this, then say it in order.</p><ol><li><strong>Name it.</strong> Dual write: two systems, no shared transaction.</li><li><strong>Show the ghosts.</strong> Database wins and Kafka loses is a ghost order. Kafka wins and the database loses is a ghost event.</li><li><strong>Kill the naive fixes.</strong> @Transactional can’t make Kafka and the database atomic. Retries create duplicates. Swapping the order doesn’t help.</li><li><strong>Give the fix.</strong> One local transaction for order plus outbox. An async relay. At-least-once delivery plus an idempotent consumer with a processed_events table.</li><li><strong>Show judgment.</strong> Polling first. CDC with Debezium when you need speed. Never Two Phase Commit for checkout.</li></ol><p>Then go to the whiteboard. Draw the Order Service, Postgres with orders and outbox_events, one local transaction around both, the async relay to Kafka, and the processed_events table under the consumer. Point to the box and say <strong>“atomic write.”</strong> Point to the arrow and say <strong>“reliable publish.”</strong> Point to the table and say <strong>“safe retry.”</strong></p><p>Three gestures, and you pass.</p><p>Now go back to the interview. Same question: <em>what if the database commits but Kafka is down?</em> This time, you have the sentence.</p><h3>Ship it this week</h3><p>The same design works in production. This week:</p><ul><li>Start with outbox plus polling. A library like Namastack gives you the relay and the retries out of the box.</li><li>Add idempotency keys on every consumer.</li><li>Archive published rows nightly.</li><li>Add metrics for relay lag and duplicate rate.</li><li>Add an alert on the age of the oldest unpublished row.</li><li>Only add Debezium when polling latency actually hurts.</li></ul><h3>Outbox versus the alternatives, honestly</h3><p>The outbox is not free, and an interviewer who has run one will ask what it costs you:</p><ul><li><strong>Eventual consistency.</strong> Between the commit and the relay, other services don’t know about the order yet. Your UI and APIs must tolerate that.</li><li><strong>Extra moving part.</strong> A table, a relay (or Kafka Connect), metrics and cleanup. You trade a distributed-correctness problem for an operational one. That is usually a good trade, not a free one.</li><li><strong>Duplicates are part of the contract.</strong> Every consumer must be idempotent. If you can’t enforce that across teams, the pattern’s guarantee is weaker than it looks.</li><li><strong>Ordering is per key, not global.</strong> Kafka orders within a partition. Design keys accordingly.</li></ul><p>When is it overkill? If the event is a best-effort notification where losing one is fine (a cache invalidation, a metric), a plain fire-and-forget publish may be the honest answer. The outbox is for events whose loss would make the system lie: orders, payments, stock.</p><h3>How to prove it works</h3><p>An outbox you have never seen fail is a belief, not a guarantee. Four tests worth writing:</p><ol><li><strong>Kafka down.</strong> Stop the broker, place an order, assert the order and the outbox row exist and the request succeeded. Start the broker, assert the event arrives.</li><li><strong>Crash between send and mark.</strong> Kill the relay process right after the send. Restart it and assert the event appears twice in the topic, and that the consumer’s effects happened once.</li><li><strong>Same event twice.</strong> Feed the consumer the same event id two times, assert one stock decrement and one processed_events row.</li><li><strong>Rollback.</strong> Force the consumer’s business step to throw, assert no processed_events row remains, and that the retry succeeds.</li></ol><h3>Further reading</h3><p>Patterns and concepts:</p><ul><li><a href="https://microservices.io/patterns/data/transactional-outbox.html">Transactional Outbox</a>, Chris Richardson, microservices.io</li><li><a href="https://microservices.io/patterns/communication-style/idempotent-consumer.html">Idempotent Consumer</a>, microservices.io</li><li><a href="https://microservices.io/patterns/data/saga.html">Saga</a>: the pattern to read next, for business flows that span several services</li></ul><p>Tools and docs:</p><ul><li><a href="https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html">Debezium Outbox Event Router</a> and the walkthrough <a href="https://debezium.io/blog/2019/02/19/reliable-microservices-data-exchange-with-the-outbox-pattern/">Reliable Microservices Data Exchange With the Outbox Pattern</a></li><li><a href="https://github.com/namastack/namastack-outbox">Namastack Outbox</a> and its <a href="https://namastack.io/docs">docs</a>: an Apache-2.0 Spring Boot library</li><li><a href="https://docs.spring.io/spring-kafka/reference/kafka/transactions.html">Spring for Apache Kafka: transactions</a></li><li><a href="https://kafka.apache.org/documentation/#semantics">Kafka message delivery semantics</a></li><li><a href="https://www.postgresql.org/docs/current/logicaldecoding-explanation.html">PostgreSQL: logical decoding</a>, the mechanism CDC tools read</li><li><a href="https://www.postgresql.org/docs/current/sql-insert.html">PostgreSQL: INSERT … ON CONFLICT</a></li><li><a href="https://cwiki.apache.org/confluence/display/KAFKA/KIP-939%3A+Support+Participation+in+2PC">KIP-939: participating in 2PC</a> and <a href="https://issues.apache.org/jira/browse/KAFKA-15370">KAFKA-15370</a></li></ul><h3>Key takeaways</h3><ul><li>Saving to a database and publishing to a broker is a dual write. No ordering of the two, and no retry, makes it atomic.</li><li>Both orderings fail: DB first gives a ghost order, Kafka first gives a ghost event.</li><li>Exactly-once across two systems you write to yourself doesn’t exist. Choose at-least-once plus idempotence.</li><li>The Outbox turns the distributed write into a local one: business row and event row in the same transaction.</li><li>A relay crash produces duplicates, never losses. The idempotent consumer makes duplicates harmless.</li><li>Polling first, CDC when latency hurts. Never 2PC for checkout.</li></ul><p><strong>Watch the video:</strong> <a href="https://youtu.be/RuJJJ3q56Mk">Why Your System Design Interview Fails (The Dual Write Problem)</a> <strong>More on YouTube:</strong> subscribe to <a href="https://www.youtube.com/@JavaPlusAI">Java Plus AI</a> for the full series.</p><p><em>If this saved you from a freeze, a few 👏 help others find it.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=38fdfc626a87" width="1" height="1" alt=""><hr><p><a href="https://medium.com/spring-boot-world/the-dual-write-problem-the-outbox-answer-that-passes-the-interview-38fdfc626a87">The Dual Write Problem: the Outbox Answer That Passes the Interview</a> was originally published in <a href="https://medium.com/spring-boot-world">Spring Boot World</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Giving Every AI Skill a Write Territory]]></title>
            <link>https://medium.com/spring-boot-world/giving-every-ai-skill-a-write-territory-bf330a527adf?source=rss----61087b1caf41---4</link>
            <guid isPermaLink="false">https://medium.com/p/bf330a527adf</guid>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[spring-boot]]></category>
            <dc:creator><![CDATA[Lucas Fernandes ‍]]></dc:creator>
            <pubDate>Thu, 29 Oct 2026 14:17:00 GMT</pubDate>
            <atom:updated>2026-10-29T14:16:59.806Z</atom:updated>
            <content:encoded><![CDATA[<h4>How to Stop a Claude Code Skill From Writing Files It Shouldn’t</h4><blockquote><em>Telling an agent in prose which files it may touch is a wish. Here is how a class declared in a JSON file, one Java hook and an end-of-turn sweep turn it into a rule, with real block messages and the gaps left in.</em></blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*acQEWkNRtUdLIEdg6pyQHg.png" /></figure><h3>Introduction</h3><p>A design run once edited docker-compose.yml.</p><p>The run was a feature pipeline whose only job is to write specs under docs/. Every skill involved said so, in prose, in its own instructions. The run finished, and a schema-registry service block was sitting in the compose file anyway.</p><p>Nothing had been hacked and no tool was misused. The agent was being helpful in a direction nobody had asked for, and the only thing standing between it and the file was a sentence it was free to ignore.</p><p>This is the part of <a href="https://github.com/nerviz-ai/nerviz">Nerviz</a> I think is the most useful beyond Nerviz, so this article is about it: <strong>every skill and every subagent has a write territory, declared as data, and everything outside it is refused.</strong> If you haven’t read the <a href="https://medium.com/spring-boot-world/i-stopped-writing-prompts-and-built-an-agent-harness-for-spring-boot-72620593f63d">pillar article of the series</a>, the short version is that Nerviz is a harness for Spring Boot on Claude Code: guides steer the model before it acts, sensors check what it did. This is a sensor.</p><h3>Why a denylist loses</h3><p>The obvious first fix is a list of files the design run must not touch. Put docker-compose.yml on it and move on.</p><p>That list is written after the fact. It contains exactly the files someone already got burned by. The next incident is a file nobody had thought of, and by definition it is not on the list. The model has a much bigger imagination for helpful edits than you have for forbidden ones.</p><p>An allowlist flips the question. Instead of “what must this skill never touch?”, you ask “what may this skill write?”, and the answer is short, because a skill has one job. A design skill writes one use case folder. That’s it. docker-compose.yml was not in that answer, so it never needed to be named anywhere.</p><p>The change in the decision record that introduced this is one line: the territory model is <em>“Allowlist, deny by default”</em>, and the denylist it replaced was dropped.</p><h3>Classes, as data</h3><p>Listing the allowed paths per skill would duplicate a lot of lines across 34 skills. Nerviz groups them.</p><p>Every skill belongs to exactly one class, and every agent to exactly one agent class. Both are declared in one file, .claude/schemas/extensions.json, which is the single owner of what each class may write. Eight skill classes:</p><ul><li><strong>design</strong>: writes docs/use-cases/UC-*/** and docs/use-cases/BACKLOG.md. The skills that each write one partial of a use case.</li><li><strong>orchestrator</strong>: writes docs/use-cases/** and docs/lessons-learned/**. The pipeline skills that chain others. Two of them, new-feature-implement and init-project, override that to nothing, because everything they do is delegated to subagents.</li><li><strong>build</strong>: the class default is empty on purpose. Each skill carries its own override, because project-bootstrap writes the whole tree while gof-design-patterns writes one Java class.</li><li><strong>observer</strong>: nothing. A skill that reports on the project and writes to it is a bug.</li><li><strong>meta</strong>: .claude/<strong>, CLAUDE.md, .github/</strong> and other harness files. One skill, the one that edits the harness itself.</li><li><strong>ops</strong>: nothing. Its effect is git, over the shell.</li><li><strong>report</strong>: docs/lessons-learned/**.</li><li><strong>media</strong>: docs/diagrams/**. It arrived in v0.46.0 with the diagram skill, because that path belonged to no class before.</li></ul><p>And four agent classes, driver, executor, installer and verifier. The executor, the one that implements an approved spec, may write src/**, the single status line of the spec it closes, and the POMs. A verifier, which reads a public issue written by a stranger, may write nothing in the repository at all.</p><p>A comment inside the file explains the thing I like most about the design: an empty list is a decision, not an omission. Coverage is total, too. A skill on disk that no class lists is a failure, and the schema hook names it.</p><p>The hook reads this data. There is no list of paths in the Java source, so changing a territory is a JSON edit and adding a skill touches no code.</p><h3>Checkpoints</h3><h4>First checkpoint: the file tools</h4><p>The guard is a Java hook, ArchHook.jar, registered on PreToolUse. A territory opens when a skill from a class is invoked and closes at the next prompt. While it is open, every Write, Edit, MultiEdit and NotebookEdit is matched against the class’s write_allow.</p><p>I ran it by hand against a copy of the harness. A /new-feature run starts, the first design skill is called, and it tries to write the compose file:</p><pre>❌ `use-case-design` is class `design` — docker-compose.yml is outside its territory.<br>   write_allow: docs/use-cases/UC-*/**, docs/use-cases/BACKLOG.md<br>Put the content in the spec as a code block, or finish the run and invoke the<br>skill that owns this path. If the path is legitimately this skill&#39;s, widen<br>skill_classes.design in .claude/schemas/extensions.json — retrying will not help.</pre><p>Exit code 2, and the write never happens. Notice what the message does. It names the class, lists what the class may write, and tells the model where the content belongs instead (the spec, as a code block). The last line says retrying will not help, which matters: a model that is told only “refused” tends to try again with a different spelling.</p><p>A write to docs/use-cases/UC-001-x/UC-001-domain.md in the same run goes through silently.</p><h4>Second checkpoint: the shell</h4><p>Blocking Write and Edit is not enough, and I learned that from a run, not from a design session. A later run wrote two files with a heredoc and passed through every hook, because every hook matched on a tool name and Bash was in no matcher. The host’s own instructions even preferred Bash for edits.</p><p>So there is a Bash matcher too. The hook reads the command for the write shapes it understands, listed in the same JSON file: &gt; and &gt;&gt;, tee, truncate, sed -i, perl -i, cp, mv, install, rsync, dd of=, and a shell wrapper like sh -c “…”, which it re-parses one level. The same compose edit, as a command:</p><pre>sed -i &#39;&#39; &#39;s/a/b/&#39; docker-compose.yml</pre><p>gets the same block message, same exit code. And in the same run, a design skill trying to invoke a build skill gets this:</p><pre>❌ `project-bootstrap` is class `build` and `use-case-design` (class `design`) is open — a design run does not materialize files.<br>Record what is missing in the partial and in the consolidated spec, finish the run,<br>then invoke `/project-bootstrap` from a prompt of its own.</pre><p>A build skill cannot even be called during a design run. That is what makes the pipeline docs-only in the mechanism, not only in prose.</p><p>Now the honest part. This parser is deliberately incomplete. A shell has more ways to write a file than any list can hold, and a parser that blocks whatever it cannot read would stop ./mvnw on its first false positive and be switched off the same week. A target containing $, a backtick, a glob or ~ is skipped without a word. The decision record says the bar is not completeness: it is that the obvious spellings stop being free.</p><h4>Third checkpoint: what actually changed on disk</h4><p>For the spellings the parser skips, there is a net that does not care which tool wrote. At the end of every turn, a Stop hook, guard sweep, compares the working tree against a baseline taken when the prompt arrived, and judges what changed against the same territory.</p><p>I simulated a write the tool-time guards could not see, a plain Python open(…, ‘w’), then ended the turn:</p><pre>❌ This turn wrote outside what the open phase and the frozen folders admit.<br>   The tool-time guards did not see these writes — a shell spelling they do not<br>   parse, or a tool with no matcher. Revert them, or report them and stop.<br><br>❌ `new-feature` is class `orchestrator` — docker-compose.yml is outside its territory.<br>   write_allow: docs/use-cases/**, docs/lessons-learned/**</pre><p>Two limits, both in the repository’s own documentation. It is detection, not prevention: the file is already written when it fires, and the model is told to revert it. And it is git-shaped: a path that git ignores never shows up in git status, so it is never swept. The baseline is the reason it does not flag a tree that was dirty before you started.</p><p>One more detail that cost a real bug. The sweep first judged every change against whatever phase was open at the end of the turn. A /new-feature run that ended by chaining git-publish (class ops, which writes nothing) had its own approved spec flagged. The fix was to have the sweep judge only the writes no tool-time guard had already admitted.</p><h3>Subagents are judged by their own class</h3><p>A subagent starts with a clean context, which is why it exists, and that raises a question: whose territory applies when it writes?</p><p>The answer is its own. Every write a subagent makes carries its agent_type, and the hook checks the path against that agent’s class, whatever phase the caller left open. Here is the agent that installs ArchUnit trying to touch the compose file:</p><pre>❌ agent `archunit-installer` is class `installer` — docker-compose.yml is outside its territory.<br>   write_allow: pom.xml, **/pom.xml, src/test/java/**/ArchitectureTest.java, ...<br>Report the path in the summary you return and stop, or hand it to the piece<br>that owns it. If the path is legitimately this agent&#39;s, widen<br>agent_classes.installer in .claude/schemas/extensions.json — retrying will not help.</pre><p>This used to be the weak spot. Before agent classes existed, each agent’s instructions carried a prose contract such as “does not write docker-compose.yml”, and the guard gave all four agents an unconditional bypass. None of those promises was enforced by anything. Today the promise is data.</p><p>Because the territory is keyed on the agent type, there is no state file to read and no ordering to race. The payload names the agent on every single write.</p><h3>What it does not cover</h3><p>I’d rather you hear these from me:</p><ul><li><strong>With no skill running, nothing is restricted.</strong> A territory opens when a skill is invoked. Editing a file by hand, or asking the model for an edit outside any skill, is never blocked. That is on purpose, and it means this is a guarantee about skills, not about the model in general.</li><li><strong>The shell parser is a best effort,</strong> as above. The sweep catches the rest after the fact, and only if git can see the path.</li><li><strong>A chain adds up.</strong> When a build skill calls another build skill, the phase holds both territories, so each writes its own paths. That rule was added after a chain of two real skills was refused its own pom.xml. The cost is that an early skill in a chain can write what a later one is entitled to.</li><li><strong>It lives inside Claude Code.</strong> The hooks are Claude Code hooks. Another assistant would need its own port.</li></ul><h3>The nearest neighbour</h3><p>The closest idea I found is a per-task scope script in another Spring-aware harness: enforce-files-in-scope.sh in loiane’s project, which limits writes to the files of the active task. That is a similar instinct, and it is the right one.</p><p>The differences are modest but real. Here the territory is per class and stored as data, so one edit changes every skill in the class. Shell writes and the end-of-turn sweep are covered. And the enforcement is one Java file tested on three operating systems, not bash and jq. I would re-check the other project before quoting me, because both repos move.</p><h3>Steal this, even without Nerviz</h3><ol><li><strong>List what each skill may write, not what it may not.</strong> Start from the job. A design skill emits a spec, so its list is the spec folder.</li><li><strong>Put the lists in one data file</strong> and have the hook read it. Prose in the skill’s body is the thing that failed.</li><li><strong>Make the message do the work.</strong> Name the class, show the allowed paths, say where the content belongs, and say that retrying will not help.</li><li><strong>Cover the shell, then sweep.</strong> Parse the spellings people really type, then compare the working tree at the end of the turn for everything else.</li><li><strong>Judge subagents by their own identity.</strong> If your hook ignores which agent is writing, every subagent has the caller’s powers.</li><li><strong>Test it with the incident.</strong> Make the file that burned you a test case that must fail. A territory you have never watched refuse a write is a guess.</li></ol><h3>Key takeaways</h3><ul><li>Prose telling a model which files it may write is a wish. An allowlist enforced by a hook is a rule.</li><li>A denylist only contains the incidents you already had. An allowlist covers the one you didn’t.</li><li>Classes keep the data small: eight for skills, four for agents, with per-skill overrides where a class cannot share a territory.</li><li>Check at three points: the file tools, the shell forms you can read, and a sweep of what actually changed. Each one has a hole the next one covers.</li><li>It restricts skills and subagents, not you. By design.</li></ul><p>So here is my question: what is the one file your AI assistant has edited that you never asked it to touch, and could you have predicted it? Tell me in the comments.</p><ul><li><strong>Try it:</strong> claude plugin marketplace add nerviz-ai/marketplace, then /nerviz:new-project.</li><li><strong>Repository:</strong> <a href="https://github.com/nerviz-ai/nerviz">nerviz-ai/nerviz</a>. A star helps others find it.</li><li><strong>Site:</strong> <a href="https://nerviz.dev/?utm_source=medium&amp;utm_campaign=17">nerviz.dev</a>.</li><li><strong>Output of a real run</strong>: <a href="https://github.com/nerviz-ai/bank-app">nerviz-ai/bank-app</a>.</li></ul><p><em>If this saved you from a surprise edit, a few 👏 help others find it.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=bf330a527adf" width="1" height="1" alt=""><hr><p><a href="https://medium.com/spring-boot-world/giving-every-ai-skill-a-write-territory-bf330a527adf">Giving Every AI Skill a Write Territory</a> was originally published in <a href="https://medium.com/spring-boot-world">Spring Boot World</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Why a Java Service Can Hit 100% CPU Without High Request Traffic]]></title>
            <description><![CDATA[<div class="medium-feed-item"><p class="medium-feed-image"><a href="https://medium.com/spring-boot-world/why-a-java-service-can-hit-100-cpu-without-high-request-traffic-72ee0829ed96?source=rss----61087b1caf41---4"><img src="https://cdn-images-1.medium.com/max/1672/1*9aOjlB14PCMkQ0IT4XcaNg.png" width="1672"></a></p><p class="medium-feed-snippet">A Java service is running normally. Request volume is low, the database is healthy, and there has been no major traffic spike. Yet CPU&#x2026;</p><p class="medium-feed-link"><a href="https://medium.com/spring-boot-world/why-a-java-service-can-hit-100-cpu-without-high-request-traffic-72ee0829ed96?source=rss----61087b1caf41---4">Continue reading on Spring Boot World »</a></p></div>]]></description>
            <link>https://medium.com/spring-boot-world/why-a-java-service-can-hit-100-cpu-without-high-request-traffic-72ee0829ed96?source=rss----61087b1caf41---4</link>
            <guid isPermaLink="false">https://medium.com/p/72ee0829ed96</guid>
            <category><![CDATA[java]]></category>
            <category><![CDATA[backend-development]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[system-design-interview]]></category>
            <category><![CDATA[springboot-3]]></category>
            <dc:creator><![CDATA[The Turtle Code]]></dc:creator>
            <pubDate>Thu, 29 Oct 2026 13:37:48 GMT</pubDate>
            <atom:updated>2026-10-29T13:37:46.715Z</atom:updated>
        </item>
        <item>
            <title><![CDATA[Understanding BOLA in Spring Boot: From Vulnerable Endpoints to Ownership-Based Authorization]]></title>
            <link>https://medium.com/spring-boot-world/understanding-bola-in-spring-boot-from-vulnerable-endpoints-to-ownership-based-authorization-e7a7a9297a0e?source=rss----61087b1caf41---4</link>
            <guid isPermaLink="false">https://medium.com/p/e7a7a9297a0e</guid>
            <category><![CDATA[java]]></category>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[api-security]]></category>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[backend-development]]></category>
            <dc:creator><![CDATA[Aliorhan Ok | Backend Developer]]></dc:creator>
            <pubDate>Tue, 27 Oct 2026 12:34:33 GMT</pubDate>
            <atom:updated>2026-10-27T12:34:32.716Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*r9HIrcx_-bmMaksPi550Iw.jpeg" /></figure><p><em>Building a practical API security lab with Java 21, Spring Boot, Spring Security, Spring Data JPA, and automated tests.</em></p><h3>Introduction</h3><p>Authentication and authorization are two fundamental concepts in backend security, but they solve different problems.</p><p>Authentication answers:</p><p><strong>Who are you?</strong></p><p>Authorization answers:</p><p><strong>What are you allowed to access?</strong></p><p>An API can authenticate a user successfully and still expose private information if it fails to verify whether that user is authorized to access the requested resource.</p><p>This type of vulnerability is known as <strong>Broken Object Level Authorization (BOLA)</strong>. It is a major API security risk addressed by the OWASP API Security Top 10.</p><p>To understand the issue in practice, I built a Spring Boot security lab that demonstrates an intentionally vulnerable document endpoint and a second endpoint that enforces document ownership.</p><p><strong>GitHub repository:</strong> <a href="https://github.com/EagleSoft461/bola-lab">https://github.com/EagleSoft461/bola-lab</a></p><h3>1. Understanding BOLA</h3><p>Imagine an application that allows authenticated users to retrieve documents through an API:</p><pre>GET /api/documents/{documentId}</pre><p>The backend receives a document ID, queries the database, and returns the matching document.</p><p>The vulnerability occurs when the application checks whether the document exists but does not verify whether the authenticated user is allowed to access it.</p><p>Consider this scenario:</p><ol><li>User A owns a private document.</li><li>User B is authenticated.</li><li>User B requests the document owned by User A.</li><li>The backend retrieves the document without checking ownership.</li></ol><p>If the API returns the document, authentication has succeeded, but object-level authorization has failed.</p><p>The important lesson is simple:</p><p><strong>Knowing an object’s identifier must not grant permission to access it.</strong></p><p>A secure API must enforce authorization whenever a user attempts to access a protected resource.</p><h3>2. Building the Security Lab</h3><p>I implemented the demonstration using the following technologies:</p><ul><li>Java 21</li><li>Spring Boot</li><li>Spring Security with HTTP Basic authentication</li><li>Spring Data JPA</li><li>H2 in-memory database</li><li>Maven</li><li>JUnit and MockMvc</li></ul><p>The document entity contains three fields:</p><pre>UUID id;<br>UUID ownerId;<br>String title;</pre><p>The id identifies a document, while ownerId identifies its owner.</p><p>The lab uses two test users to simulate a common authorization scenario: one user attempts to access a document belonging to another user.</p><p>The objective is to compare two implementations and verify their behavior through automated tests.</p><h3>3. The Vulnerable Implementation</h3><p>The insecure service retrieves a document using its ID alone:</p><pre>documentRepository.findById(documentId);</pre><p>This query checks whether a document exists. It does not verify whether the authenticated user owns that document.</p><p>For demonstration purposes, the vulnerable behavior is exposed through a dedicated endpoint:</p><pre>GET /api/lab/insecure/documents/{documentId}</pre><p>In the automated test, one authenticated user requests a document owned by the other user.</p><p>The endpoint responds with 200 OK and returns the document.</p><p>This confirms that the endpoint does not enforce the ownership rule.</p><p>The issue is not that the application failed to authenticate the requester. The issue is that it failed to authorize access to the specific object.</p><h3>4. Implementing Ownership-Based Authorization</h3><p>To address this problem, I added a repository method that queries by both document ID and owner ID:</p><pre>documentRepository.findByIdAndOwnerId(<br>    documentId,<br>    authenticatedUserId<br>);</pre><p>This changes the data-access operation in an important way.</p><p>Instead of retrieving any document with the requested ID, the query returns a document only when both conditions match:</p><ul><li>The document ID matches the requested ID.</li><li>The owner ID matches the authenticated user’s ID.</li></ul><p>The secure service uses this repository method, while the controller obtains the authenticated identity from Spring Security.</p><p>The protected endpoint is:</p><pre>GET /api/documents/{documentId}</pre><p>If the requested document does not belong to the authenticated user, the repository lookup returns no result. The controller responds with 404 Not Found.</p><p>Returning 404 is a deliberate choice for this lab. Depending on an application&#39;s requirements and information-disclosure policy, 403 Forbidden may also be appropriate.</p><p>The key requirement is that the backend must not return a protected document to an unauthorized requester.</p><p>This approach also avoids relying on the frontend to hide documents or prevent users from changing resource IDs. Authorization must be enforced on the server.</p><h3>5. Verifying the Behavior with Automated Tests</h3><p>I used JUnit and MockMvc to test both implementations.</p><p>The tests compare the responses when one authenticated user attempts to access another user’s document.</p><p>Test scenarioObserved resultInsecure endpoint: another user requests the document200 OKSecure endpoint: another user requests the same type of protected resource404 Not Found</p><p>I ran the two targeted tests with Maven:</p><pre>.\mvnw.cmd &quot;-Dtest=DocumentControllerTest#insecureEndpointAllowsAnotherUserToReadDocument+secureEndpointBlocksAnotherUserFromReadingDocument&quot; test</pre><p>The output confirmed the expected HTTP statuses:</p><pre>Secure endpoint HTTP status: 404<br>Insecure endpoint HTTP status: 200</pre><pre>Tests run: 2, Failures: 0, Errors: 0, Skipped: 0</pre><pre>BUILD SUCCESS</pre><p><strong>Both tests passed.</strong></p><p>The results demonstrate the intended contrast: the vulnerable endpoint returns the document, while the ownership-protected endpoint does not return it to another user.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cjtdw9l6HfZiA-N_OrnMeQ.png" /></figure><p><em>Figure 1. Automated tests demonstrating the different authorization behavior of the two endpoints.</em></p><p>These tests provide repeatable evidence of the behavior in this lab. They do not prove that every authorization path in a larger application is secure; broader coverage and additional security testing would still be necessary.</p><h3>6. What I Learned</h3><p>Building this lab helped me connect backend implementation details with practical API security principles.</p><h3>Authentication is not authorization</h3><p>A valid authenticated identity does not grant unrestricted access to every resource in an application.</p><h3>Authorization must be enforced at the object level</h3><p>Endpoints that read, update, delete, or export user-owned resources need appropriate access-control checks.</p><h3>Database queries can support authorization</h3><p>Querying by both resource ID and owner ID makes the ownership condition explicit. It also reduces the risk of retrieving a resource without applying the intended ownership constraint.</p><p>This pattern is useful, but it does not replace a complete authorization design. Applications with shared resources, teams, delegated access, or role-based policies may need additional rules.</p><h3>Automated security tests are essential</h3><p>Testing only whether an endpoint returns a successful response is not enough.</p><p>Tests should also verify that users cannot access resources outside their permissions.</p><h3>UUIDs do not replace authorization</h3><p>Using UUIDs instead of sequential identifiers does not solve BOLA. Resource identifiers may be difficult to guess, but the server must still verify permissions for every relevant request.</p><h3>7. Limitations and Future Improvements</h3><p>This repository is an educational demonstration, not a production-ready application.</p><p>It uses HTTP Basic authentication, in-memory test users, and an H2 in-memory database to keep the example focused.</p><p>A production application would require a broader security review, including secure identity management, credential handling, HTTPS, authorization coverage across all relevant endpoints, and appropriate integration and security tests.</p><p>Possible next steps for this lab include:</p><ul><li>Adding ownership checks for document updates and deletion.</li><li>Testing role-based and shared-resource permissions.</li><li>Adding integration tests against a production-like database.</li><li>Expanding coverage for missing resources and unauthorized operations.</li><li>Integrating the test suite into a continuous integration pipeline.</li></ul><p>The intentionally vulnerable endpoint should remain confined to a controlled educational environment and must not be exposed as a public production endpoint.</p><h3>Conclusion</h3><p>BOLA demonstrates how a small omission in backend authorization logic can undermine an otherwise authenticated API.</p><p>Compare these two repository methods:</p><pre>documentRepository.findById(documentId);</pre><pre>documentRepository.findByIdAndOwnerId(<br>    documentId,<br>    authenticatedUserId<br>);</pre><p>The first retrieves a document based on its identifier alone. The second includes the ownership condition in the lookup.</p><p>That difference is central to the behavior demonstrated by this project.</p><p>Building the lab gave me practical experience with Spring Security, Spring Data JPA, and automated testing for object-level authorization.</p><p><strong>Explore the source code and tests:</strong> <a href="https://github.com/EagleSoft461/bola-lab">https://github.com/EagleSoft461/bola-lab</a></p><h3>References</h3><ul><li><a href="https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/">OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html">OWASP Insecure Direct Object Reference Prevention Cheat Sheet</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e7a7a9297a0e" width="1" height="1" alt=""><hr><p><a href="https://medium.com/spring-boot-world/understanding-bola-in-spring-boot-from-vulnerable-endpoints-to-ownership-based-authorization-e7a7a9297a0e">Understanding BOLA in Spring Boot: From Vulnerable Endpoints to Ownership-Based Authorization</a> was originally published in <a href="https://medium.com/spring-boot-world">Spring Boot World</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[I Stopped Writing Prompts and Built an Agent Harness for Spring Boot]]></title>
            <link>https://medium.com/spring-boot-world/i-stopped-writing-prompts-and-built-an-agent-harness-for-spring-boot-72620593f63d?source=rss----61087b1caf41---4</link>
            <guid isPermaLink="false">https://medium.com/p/72620593f63d</guid>
            <category><![CDATA[agent-harness]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[spring-boot]]></category>
            <dc:creator><![CDATA[Lucas Fernandes ‍]]></dc:creator>
            <pubDate>Fri, 16 Oct 2026 09:40:38 GMT</pubDate>
            <atom:updated>2026-10-16T09:47:15.573Z</atom:updated>
            <content:encoded><![CDATA[<h4>Stop Telling Claude Code What to Remember. Make It Impossible to Forget</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*V_30q_9mMbB3F3jQAvUr9g.png" /><figcaption><a href="https://nerviz.dev/">https://nerviz.dev</a></figcaption></figure><h3>The Problem</h3><p>You start a Spring Boot Java project with a 30-line CLAUDE.md.</p><p>Six months later it has 400 lines. The model ignores half of them. The same rule lives in three skills that no longer agree. Picture the failure: the file says “the domain doesn’t import Spring” on one line and “services are annotated with @Service&quot; two hundred lines later, and a @Service ends up on a domain class. <strong>Nobody can say which instruction lost.</strong></p><p>The usual reaction is to rewrite the prompt: shorter, sharper, in capitals. It helps for a week.</p><p>The problem was never the wording. The AI “forgets” the architecture because nothing tells it, cheaply and reliably, what is true in this project, and nothing stops it when it gets it wrong.</p><p><strong>A better sentence cannot fix a missing mechanism.</strong></p><p>So I stopped writing prompts and built a harness. This article is the pillar of a series about it: what a harness is, how it maps onto Claude Code, and one complete run you can open on GitHub and check line by line.</p><h3>Agent = model + harness</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gNx71fO70CAgxROOH5Vhjg.png" /></figure><p>The working definition in 2026 is short: <strong>an agent is a model plus a harness.</strong></p><p>The model is the LLM. The harness is everything around it that shapes what it does: instructions, context, tools, permissions, tests, review, feedback. When an agent behaves badly, you usually blame the model. Most of the time the harness is where the bug is.</p><p>Birgitta Böckeler, writing on martinfowler.com, splits the harness into two kinds of controls (see her <a href="https://www.martinfowler.com/articles/exploring-gen-ai/harness-engineering-memo.html">harness engineering memo</a>):</p><ul><li><strong>Guides</strong> act <em>before</em> the model does. They steer it: instructions, procedures, examples, the spec of what you want built.</li><li><strong>Sensors</strong> act <em>after</em>. They observe what the model did and push back: tests, linters, the build, a hook that refuses a write.</li></ul><p>Claude Code already supplies the model and the tool loop. It does not supply the rest, and for Spring Boot the rest is a lot:</p><ul><li>which architecture you chose</li><li>which package may import which</li><li>what a use case must specify before code exists</li><li>what happens when the model reaches for a file it should not touch</li></ul><p>That “rest” is what I built. I call it <strong>Nerviz</strong>, after Pier Luigi Nervi, the engineer-architect whose once said: <strong>the structures are their own form</strong>.</p><p>It lives at <a href="https://github.com/nerviz-ai/nerviz">https://github.com/nerviz-ai/nerviz</a> and the site is <a href="https://nerviz.dev/">nerviz.dev</a> (and btw is open source).</p><h3>Guides and sensors, mapped to files you can open</h3><p>Here is the same split, with the actual pieces.</p><p><strong>Guides, before the model acts:</strong></p><ul><li><strong>Norms</strong> in <em>.claude/rules/</em>. One file per rule, loaded on demand through paths: a norm about persistence enters the context only when a file in its territory is touched.</li><li><strong>Skills</strong> in <em>.claude/skills/</em>. Procedures plus exemplars: how to create a REST adapter, step by step, with a reference controller that compiles.</li><li><strong>Blueprints.</strong> The architecture, as a YAML file. Nine of them (hexagonal, clean-architecture-single-module, onion, vertical-slice, modular-monolith…) plus a template for your own.</li><li><strong>The spec</strong> of the feature being built: one approved document per use case.</li></ul><p><strong>Sensors, after the model acts:</strong></p><ul><li><strong>Hooks</strong> that refuse a forbidden import, a write outside a skill’s territory, an edit to an approved spec, or a force push.</li><li><strong>The incremental build.</strong> Every write to a .java file triggers a test-compile of the touched module.</li><li><strong>ArchUnit</strong> and a <strong>compose health gate</strong>.</li><li><strong>CI</strong> that validates the harness itself, not only the code it generates.</li></ul><p>One line from the project’s own rules explains the split better than I can: <strong>hooks verify, they do not teach.</strong> If something must always hold, it is not prose in a guide. It is a hook or a permissions.deny line.</p><p>That is why the 400-line CLAUDE.md fails. It asks a guide to do a sensor’s job. A guide is a request. The model may weigh it, forget it, or lose it to a longer instruction two hundred lines below. A sensor is a fact: the write is blocked, or it is not.</p><h4>What a sensor looks like</h4><p>Every hook in Nerviz launches one precompiled jar, built from a single Java file (ArchHook.java), in exec form:</p><pre>{ <br>  &quot;type&quot;: &quot;command&quot;, &quot;command&quot;: &quot;java&quot;,<br>  &quot;args&quot;: [&quot;-jar&quot;, &quot;${CLAUDE_PROJECT_DIR}/.claude/hooks/ArchHook.jar&quot;, &quot;check&quot;] <br>}</pre><p>No shell is invoked, so bash, PowerShell or WSL make no difference, and there is one implementation instead of a .sh and a .ps1 that drift apart. The audience has a JDK by definition, so the dependency already exists.</p><p>The architecture reaches the hook as data. The generated project carries a .claude/forbidden-imports.txt like this one, from the example project below:</p><pre>src/main/java/dev/nerviz/bankapp/domain/model|org.springframework.<br>src/main/java/dev/nerviz/bankapp/domain/model|jakarta.<br>src/main/java/dev/nerviz/bankapp/application/usecase|dev.nerviz.bankapp.infrastructure.</pre><blockquote>Read it as: <em>nothing under domain/model may import org.springframework. or jakarta., and a use case may not import infrastructure.</em> When Claude writes a .java file that breaks a line, the check hook exits with code 2 and the write is refused, with a message that names the rule. The model is not asked to remember. It is told no..</blockquote><h3>Every mode of ArchHook</h3><p>check is one of twelve modes in the same file. Each is registered in settings.json against a Claude Code event, and every list a mode reads (recognized frontmatter fields, write territories, which services to probe) is data in .claude/schemas/extensions.json, never a constant in the Java. Adding a skill to a class is one entry, not a code change.</p><p><strong>On every write to a .java file (PostToolUse):</strong></p><ul><li><strong>check: blocks.</strong> Reads .claude/forbidden-imports.txt and refuses a file that imports a prefix forbidden in its module (exit 2). If the boundary holds, it runs an incremental test-compile of the touched module, so code that does not compile is refused too. Typed by hand as check , it exits 1 for a path that is not an existing .java file instead of passing in silence.</li><li><strong>format: never blocks.</strong> Runs spotless:apply on the touched module. A failure is ignored: formatting is a courtesy, not a gate.</li></ul><p><strong>On every write to the harness’s own files (PreToolUse, PostToolUse and Stop):</strong></p><ul><li><strong>schema: blocks.</strong> Validates the instruction files themselves. The frontmatter of skills, agents and rules against the fields the runtime actually recognizes (the runtime ignores an unknown field without a word, and claude plugin validate lets it through), the body sections each class requires, the paths a shell injection in a skill reads, a skill named after a native command, a rule with no paths. It also checks every hook registration in settings.json, .mcp.json with a secret scan, the plugins that must stay disabled, and the export manifest against what is on disk.</li></ul><p><strong>Around each action of a skill or agent (UserPromptSubmit, PreToolUse, Stop):</strong></p><ul><li><strong>guard: blocks.</strong> Write territory, deny by default. Every skill belongs to one of seven classes and every agent to one of four, and the class lists the paths it may write. It has six sub-modes: prompt snapshots the working tree at the start of a turn, call refuses a build-class skill during a design run, write judges Write and Edit, bash reads shell write shapes (&gt;, tee, sed -i, sh -c “…”) and refuses a force push, sweep runs at Stop and re-checks what git says changed this turn, and status prints the open phase as one JSON line for a UI mod. An approved spec folder is frozen. A subagent is judged by its own class. With no phase open there is no restriction: a person editing by hand is not a skill overstepping.</li></ul><p><strong>At the end of a turn (Stop):</strong></p><ul><li><strong>tests: blocks.</strong> Runs verify, without integration tests and coverage, on the modules changed since HEAD. It is deferred while a writer subagent is still running, tracked by markers at SubagentStart and SubagentStop. With no HEAD (a repository with no commit yet), it runs nothing.</li><li><strong>compose gate: blocks.</strong> Checks that every compose service is up and that no foreign container holds its ports. Published ports have to be reachable and advertised at an address a host client can resolve, image: tags have to match the ones src/test pins for Testcontainers, and every ${VAR:default} pointing at a service has to hold on the host and inside the app container. Typed without gate, compose prints the same report and never blocks. Docker is not a dependency: with no compose file, no docker on the PATH or a stopped daemon, it reports and returns.</li></ul><p><strong>Context and trail (generated project only, never block):</strong></p><ul><li><strong>context.</strong> At SubagentStart, injects the design-pattern catalog into agents whose class asks for it, so the catalog is in context every time and does not depend on the model remembering to read it.</li><li><strong>audit.</strong> Hooked to eleven lifecycle events, it writes one Markdown report per skill or agent run. audit genesis fills a new project’s GENESIS.md (start, finish, tokens) from the session transcripts, because a number the model wrote would be memory.</li></ul><p><strong>By hand or from CI:</strong></p><ul><li><strong>doctor.</strong> Diagnoses the setup on this machine, including a personal skill that shadows a project one. doctor gate is the same report as a CI step: exit 1 when a line the project lists as gating is red (no boundary rules, a source file importing what its module forbids, invalid schema, stale hook jar, broken hook registrations, bad .mcp.json). It exists because a bare doctor only printed, and a CI step once went green on ENFORCEMENT OFF.</li><li><strong>export.</strong> Writes a target project’s .claude/ from the manifest, transformed for one blueprint and stamped with provenance. It is deterministic on purpose, and CI requires two exports per blueprint to produce identical trees. It exits 2 and writes nothing when the destination’s Spring Boot line is below the supported floor.</li><li><strong>boundary-map.</strong> Writes .claude/forbidden-imports.txt from a blueprint: one line per prefix for a multi-module layout, derived package by package for a single-module one. It exits 2 and writes nothing when the blueprint yields no rule, because a map with no rule blocks nothing.</li><li><strong>build.</strong> Compiles ArchHook.java into ArchHook.jar. build — verify, run by CI, fails when the committed jar is not byte for byte what the source compiles to under the pinned JDK. The jar starts in about 0.3 s, against about 3.3 s to recompile from source on every call.</li></ul><p>Five of the twelve can block: check, schema, guard, tests and compose gate. The rest format, measure, diagnose, write or build. One caveat: a hook that cannot verify something exits 0 on purpose, which is safe but can give a false green, so doctor exists to say when enforcement is silently off.</p><h3>The proof: one run, in public</h3><p>Claims about harnesses are cheap. So the series is built on one project anyone can open: <a href="https://github.com/nerviz-ai/bank-app">https://github.com/nerviz-ai/bank-app</a>, a Spring Boot 4 application on Java 21, generated by Nerviz from a single command.</p><h4>Step 1: /init-project</h4><p>This is the flow the command follows, step by step. Only the first step is a conversation. The rest runs in a background agent:</p><pre>/init-project<br>     │<br>     ├─ 1. INTERVIEW, in the main session — blueprint · coordinates · build · features · TLS · Sonar<br>     │<br>     ▼  agent: project-initializer (isolated context, model: sonnet, asks nothing)<br>     │<br>     ├─ 2. VALIDATES blueprint — 6-rule checklist            [fails fast]<br>     ├─ 3. BASE via Spring Initializr (curl)                 [GA versions, nothing hardcoded]<br>     ├─ 4. RESTRUCTURES into modules per the blueprint       [exemplars give the shape]<br>     ├─ 5. GENERATES forbidden-imports.txt + root and module CLAUDE.md/AGENTS.md, CI, Checkstyle,<br>     │      lombok.config, logback, Dockerfile + docker-compose<br>     ├─ 6. EXPORTS norms, development skills, executor agents — the project is self-contained<br>     ├─ 7. INSTALLS hooks (ArchHook.jar + extensions.json + settings.json), opens the audit trail<br>     ├─ 8. VERIFIES build + smoke + tested boundary block; writes README + GENESIS.md<br>     ├─ 9. REPORT in the fixed output contract<br>     ├─ 10. GENESIS.md figures from the session transcripts — Started, Finished, tokens<br>     └─ if the build is green: OFFERS git-publish — two independent confirmations</pre><p>And here is one real run of it:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Fu6lP3L7DTgtW9FipDQvCw.gif" /><figcaption><em>One /init-project run, sped up about 5x.</em></figcaption></figure><p>The command opens with an interview in the main session: blueprint, coordinates, build tool, features, TLS, Sonar. Then a background agent (project-initializer, running on sonnet, with an isolated context and instructions to ask nothing) takes over and does the rest: validates the blueprint, pulls the base project from the Initializr, restructures it into the blueprint’s layout, generates the config, installs the hooks, and builds.</p><p>What it generated:</p><ul><li>Blueprint clean-architecture-single-module, with the versions the Initializr resolved that day (Java 21, Spring Boot 4.1.1, Maven). None of them came from the model’s memory.</li><li><strong>15 boundary rules</strong> in .claude/forbidden-imports.txt, with the block verified before the run reports success (the report says “blocking verified”). A harness that only claims enforcement is a guide in disguise.</li><li><strong>Zero business code.</strong> No ExampleController to delete. The first feature comes from a real spec.</li><li>A .claude/ that travels with the project: norms, development skills, executor agents, the hook, its schema, and the active blueprint. The project does not need the generator afterwards.</li><li>A root CLAUDE.md of 120 lines. It is an index and a list of invariants. The detail lives in norms that load only when needed.</li></ul><p>The result starts and verifies as a normal Spring Boot project:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*z0cOhe3GNdOJMO63GP6Xbw.png" /><figcaption><em>mvn clean verify on the generated project: tests, Spotless, Checkstyle and JaCoCo, green</em></figcaption></figure><p>One honest note about that report. The GENESIS.md says so itself: it is a one-time reconstruction, with no per-tool-call timeline, because no audit hook existed yet when the project was born. Start, finish, tokens and cost are the exception, and they are read from the transcripts. Every report written after it is produced by a hook.</p><h4>Step 2: /new-feature</h4><p>Inside the generated project, in a new session, I typed one line (typos included, because that is how people type):</p><pre>/new-feature create one rest endpoint do save entity customer (name, securitynumber (11 digitis), bornDate (only above 18 yeas old)</pre><p>The command has two flows, and the argument decides which one runs. This one creates the spec:</p><pre>/new-feature &lt;feature description&gt;     (or UC-NNN-slug to resume a draft, or empty to list)<br>     │<br>     use-case-design → domain-modeling → rest-api-architect (unless GraphQL only)<br>        → graphql-api-architect (conditional)<br>        → security-architect (conditional) → http-client-architect (conditional)<br>        → ai-architect (conditional) → search-architect (conditional)<br>        → messaging-architect (conditional) → jobs-architect (conditional)<br>        → persistence-architect → cache-architect (conditional) → test-architect<br>     │  one use case per run; a split goes to docs/use-cases/BACKLOG.md<br>     │  design skills write only docs/ — never src/, never docker-compose.yml, never git<br>     │  a missing DB or broker is asked about and recorded: add it, runs elsewhere, or by hand<br>     │  each one decides the design patterns of its own layer, in its partial<br>     │<br>     ▼  consolidates into UC-NNN-spec.md (status: draft)<br>     ▼  asks approval → status: approved (the guard hook freezes the folder from here)<br>     ▼  git-publish (the approved spec only)<br>     ▼  prints the next two commands:  /clear  ·  /new-feature-implement UC-NNN-slug</pre><p>The conditional layers only run when the use case needs them. And here is the run:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jZqijQTBQ4P87mkrt4b-Ag.gif" /><figcaption><em>One /new-feature run, sped up about 12x.</em></figcaption></figure><p>This is where the harness earns its name. The request does not go to “the model, please build it”. It goes through a chain with <strong>one owner skill per layer</strong>: use-case-design, then domain-modeling, then rest-api-architect, then persistence-architect, then test-architect. Each one writes only its own part of the spec, and each one asks only the questions that change <em>its</em> design: who may call it, what happens on a duplicate, how the age rule is computed, how long data is kept.</p><p>Layers the use case does not need are not silently skipped. The final spec records them as not applicable, with the reason: no security design because the endpoint is public and the project has no filter chain, no messaging because the use case emits no event, no jobs because nothing is scheduled.</p><p>Then the part I care about most. The spec is consolidated into one UC-001-spec.md, you approve it, and from that moment a hook <strong>freezes the spec folder</strong>. Only the status line, the checklist toggles and the changelog may still change. Code is written afterwards, in a second command (/new-feature-implement UC-001-create-customer) run after /clear, so the implementation session does not pay, on every turn, for the 250 to 370 thousand tokens of context the design left behind.</p><p>The numbers come from the run’s <a href="https://github.com/nerviz-ai/bank-app/blob/main/.claude/audit-usage/2026-10-07T08-52-11--new-feature.md">audit report</a>, written by a hook:</p><ul><li><strong>10 minutes 15 seconds</strong> in total, of which 1 minute 59 seconds waiting for my answers. About 8 minutes of active work.</li><li><strong>About USD 3.46</strong>, on claude-opus-5–5. This is a projection: the audit prices the run’s tokens at API rates, as if an API key were used. The harness never uses one, because it runs inside Claude Code, so this is not a charge. Read it as the size of the run, not as a bill.</li><li>The slowest step was test-architect at 2 minutes 17 seconds (28% of active time).</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NloWTgtCofaBGlT6ggeNjw.gif" /><figcaption><em>The audit report of that run. Every skill’s time, tokens and cost, with no tokens spent producing it.</em></figcaption></figure><p>The result is public: <a href="https://github.com/nerviz-ai/bank-app/blob/main/docs/use-cases/UC-001-create-customer/UC-001-spec.md">the approved spec</a> and <a href="https://github.com/nerviz-ai/bank-app/tree/main/docs/use-cases/UC-001-create-customer">its partials</a>. You can read it before you decide whether any of this is for you.</p><h3>What you can steal without installing anything</h3><p>Even if you never touch Nerviz, three ideas carry over to any .claude/ folder:</p><ol><li><strong>One owner per rule.</strong> If the same norm lives in two files, they will diverge. Keep it in one and cite it by path.</li><li><strong>If it must always hold, make it a sensor.</strong> A sentence in CLAUDE.md is a request. A hook that exits with code 2, or a permissions.deny line, is a fact. Spend your sensors on the three or four rules whose violation actually hurts.</li><li><strong>Measure the runs.</strong> You cannot lower a bill you cannot read. A hook that writes what each skill and agent run did costs zero tokens and survives a dead session.</li></ol><blockquote>Everything is open source (MIT). The harness is at <a href="https://github.com/nerviz-ai/nerviz">nerviz-ai/nerviz</a>, the site is <a href="https://nerviz.dev/">nerviz.dev</a>, and the project it generated is at <a href="https://github.com/nerviz-ai/bank-app">nerviz-ai/bank-app</a>.</blockquote><h3>Key takeaways</h3><ul><li><strong>Agent = model + harness.</strong> When an agent misbehaves, look at the harness before the model.</li><li><strong>Guides steer, sensors check.</strong> A 400-line instruction file asks a guide to do a sensor’s job.</li><li><strong>Architecture belongs in data and hooks, not in prose.</strong> A forbidden import should be refused with exit code 2, not remembered.</li><li><strong>Say what it costs.</strong> Claude Code only, Spring Boot only, and expensive by design.</li></ul><p>So here is my question for you: what is the rule in <em>your</em> CLAUDE.md that the model ignores most often, and what would it take to turn it into a sensor? Tell me in the comments.</p><p><em>If this saved you from one more 400-line CLAUDE.md, leave a few 👏 (you can clap up to 50 times), and send it to the teammate whose instruction file has a section called “IMPORTANT”.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=72620593f63d" width="1" height="1" alt=""><hr><p><a href="https://medium.com/spring-boot-world/i-stopped-writing-prompts-and-built-an-agent-harness-for-spring-boot-72620593f63d">I Stopped Writing Prompts and Built an Agent Harness for Spring Boot</a> was originally published in <a href="https://medium.com/spring-boot-world">Spring Boot World</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Threads are the engine. CompletableFuture and @Async are the steering wheel. ️]]></title>
            <link>https://medium.com/spring-boot-world/threads-are-the-engine-completablefuture-and-async-are-the-steering-wheel-%EF%B8%8F-d36e0fa18c75?source=rss----61087b1caf41---4</link>
            <guid isPermaLink="false">https://medium.com/p/d36e0fa18c75</guid>
            <category><![CDATA[multithreading]]></category>
            <category><![CDATA[concurrency]]></category>
            <category><![CDATA[java]]></category>
            <category><![CDATA[java-thread]]></category>
            <category><![CDATA[java-development]]></category>
            <dc:creator><![CDATA[Adarsh Vijayakumar]]></dc:creator>
            <pubDate>Tue, 13 Oct 2026 16:25:45 GMT</pubDate>
            <atom:updated>2026-10-13T16:25:44.192Z</atom:updated>
            <content:encoded><![CDATA[<p>Java Multithreading, Part II: Connect tasks with CompletableFuture and Spring’s @Async — and avoid the blocking trap</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*EsNJlGCmIKqDwzOUPwElMA.png" /></figure><p>Remember the moment in <a href="https://medium.com/spring-boot-world/why-does-your-java-code-break-when-you-add-another-thread-962da621b67f">Part I</a> when adding <em>one more thread</em> turned perfectly working code into a crime scene? 🕵️</p><p>Here’s the plot twist nobody tells you: <strong>in modern Java, you rarely need to manage threads by hand at all.</strong> 🎬</p><p>Picture a service that needs data from three places before it can answer. Do you writenew Thread(...), sprinkle join() everywhere, and hope for the best? There&#39;s a far better way: stop <em>managing threads</em> and start <em>describing workflows</em>.</p><p>In this Part II, let’s understand:</p><ul><li>⚡ <strong>CompletableFuture</strong>: run work in the background, chain it, combine it, and rescue it when it fails</li><li>🌱 <strong>@Async</strong>: Spring Boot’s one-annotation shortcut, and the hidden executor trap behind it</li><li>🪤 <strong>Production pitfalls</strong>: every one comes with a fix you can copy</li></ul><p>All the CompletableFuture examples come from runnable code in my <a href="https://github.com/adarsh-vijayakumar/Multithreading">Multithreading repo on GitHub</a> (Java 17, Spring Boot 4.1.1). Clone it, run it, break it. 👇</p><h4>1. 🚧 Why Plain Future Hits a Wall</h4><p>First, what is <strong>asynchronous programming</strong>? It means starting <em>N</em> tasks in separate threads without blocking the main thread, then getting notified when each one finishes (or fails). Non-blocking code, real concurrency. 🎯</p><p>Java has offered ways to do this for years: Future, ExecutorService, ThreadPool. But Future has four painful limits, and CompletableFuture was built to remove every one of them:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LT4A6_DcZ66gFt5rBNtudw.png" /></figure><p>Think of Future as a restaurant receipt you must stand at the counter holding, because get() blocks. CompletableFuture is the buzzer that vibrates when your order is ready, and it can even tell the kitchen what to cook next. 🍔</p><h4>2. 🚀 Your First Async Tasks: runAsync vs supplyAsync</h4><p>Two entry points, one simple rule:</p><ul><li><strong>runAsync(Runnable)</strong> returns CompletableFuture&lt;Void&gt;. It means &quot;run this in the background; I don&#39;t need anything back.&quot; 🏃</li><li><strong>supplyAsync(Supplier&lt;T&gt;)</strong> returns CompletableFuture&lt;T&gt;. It means &quot;run this and <em>supply</em> me a value.&quot; 🎁</li></ul><p>Both have an overload that accepts your own Executor. Without one, the task runs on the JVM-wide ForkJoinPool.commonPool().</p><p>The demo code reads 1,000 employee records from a JSON file that acts as a mock database, so you don&#39;t need to set up a database.</p><p><strong>runAsync: when there’s nothing to return</strong></p><pre>public CompletableFuture&lt;Void&gt; saveEmployees() {<br>    return CompletableFuture.runAsync(() -&gt; simulateEmployeeSave());<br>}</pre><p><strong>supplyAsync: when you want a result back</strong></p><pre>public CompletableFuture&lt;List&lt;Employee&gt;&gt; getEmployeesFromDatabase() {<br>    return CompletableFuture.supplyAsync(() -&gt; {<br>        try {<br>            return EmployeeDatabase.fetchAllEmployees();<br>        } catch (IOException e) {<br>            throw new CompletionException(&quot;Unable to read employees.json&quot;, e);<br>        }<br>    });<br>}</pre><p>The method returns <em>immediately</em> with a future. Only a call to get() or join() blocks. ⏱️</p><blockquote><em>💡 </em><strong><em>Tip: checked exceptions.</em></strong><em> A </em><em>Supplier or </em><em>Runnable cannot throw checked exceptions, so wrap them in a </em><em>CompletionException. The future carries the failure to the caller: </em><em>get() surfaces it as an </em><em>ExecutionException whose cause is your original </em><em>IOException.</em></blockquote><pre>ExecutorService executor = Executors.newFixedThreadPool(5);<br>try {<br>    demo.saveEmployeesWithCustomExecutor(executor).get();<br>} finally {<br>    executor.shutdown(); // you created it, you close it<br>}</pre><blockquote><em>🪤</em> <strong>Pitfall #1: the vanishing task.</strong> The common pool uses daemon threads. If main returns before your task finishes, the JVM exits and the task silently disappears. <strong>Fix:</strong> in a console app, wait with get() or join() at the outer boundary, and only there.</blockquote><blockquote>🪤 <strong>Pitfall #2: the JVM that never exits.</strong> Threads in your own pool are non-daemon. Forget shutdown() and the program hangs forever after the work is done (a quick test confirms it). <strong>Fix:</strong> always shut down in a finally block.</blockquote><blockquote>💡 <strong>Tip: seeing </strong><strong>Thread-0 instead of </strong><strong>ForkJoinPool.commonPool-worker-1?</strong> CompletableFuture only uses the common pool when it has more than one thread of parallelism (cores minus one by default). On a machine or container with 2 or fewer CPUs, every task gets a brand-new thread instead. Also remember the common pool is shared by the whole JVM, so give blocking I/O tasks their own executor.</blockquote><p>▶️ <strong>Run it yourself:</strong> <a href="https://github.com/adarsh-vijayakumar/Multithreading/blob/main/src/main/java/com/adarshvijayakumar/multithreading/completableFuture/CF_runAsync.java">CF_runAsync.java</a> and <a href="https://github.com/adarsh-vijayakumar/Multithreading/blob/main/src/main/java/com/adarshvijayakumar/multithreading/completableFuture/CF_supplyAsync.java">CF_supplyAsync.java</a>. Each has a main(), and the console prints which thread ran each task.</p><h4><strong>3. 🛑 Taking Control: </strong><strong>complete(), Timeouts, and a Dangerous Myth</strong></h4><p>A CompletableFuture doesn&#39;t have to be born from a task. You can create an empty one and finish it yourself:</p><pre>CompletableFuture&lt;String&gt; future = new CompletableFuture&lt;&gt;();<br><br>future.get();                            // blocks until someone completes it<br>future.complete(&quot;cached-or-default&quot;);    // completes it manually (first writer wins)</pre><p>The classic use case: a task is taking too long, so you hand back a cached or default value instead of keeping the user waiting. ⏳</p><blockquote><em>🪤 </em><strong><em>Pitfall: </em></strong><strong><em>complete() does NOT stop the task.</em></strong><em> </em>It&#39;s tempting to think that completing a slow future kills the thread working on it. It doesn&#39;t. In a quick test, complete(&quot;cached&quot;) returned true and get() returned instantly, yet the original task still ran to the very end in the background. cancel(true) is no better, because CompletableFuture ignores the interrupt flag: the future reports isCancelled(), but the thread keeps working and is never interrupted.</blockquote><p><strong>The fix:</strong> use the built-in timeout helpers (Java 9+) and be clear about what they do.</p><pre>// Stop waiting after 2 seconds and use a fallback value<br>String price = CompletableFuture.supplyAsync(() -&gt; fetchPriceSlowly())<br>        .completeOnTimeout(&quot;cached-price&quot;, 2, TimeUnit.SECONDS)<br>        .join();<br><br>// Or fail loudly: the future completes with a TimeoutException<br>CompletableFuture&lt;String&gt; strict = CompletableFuture.supplyAsync(() -&gt; fetchPriceSlowly())<br>        .orTimeout(2, TimeUnit.SECONDS);</pre><p>Both helpers stop the <em>waiting</em>, not the <em>work</em>. If the task itself must stop early, build that into the task: check a flag or Thread.interrupted() inside its loop. 🛡️</p><h4>4. 🔗 Chaining: Build a Pipeline, Not a Pyramid</h4><p><em>Chaining</em> means linking <strong>dependent</strong> stages: each stage waits for the previous one to finish, and the result of the whole chain is just another CompletableFuture. No nested callbacks, no pyramid of doom. 🏔️</p><p>Three methods cover almost everything:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0nclx_jcfKNUXoOzI6OYdA.png" /></figure><p><strong>📧 Real use case: the training reminder pipeline<br></strong>Goal: remind every new joiner whose training is still pending.</p><p>Fetch all employees from the “database”</p><ol><li>Fetch all employees from the “database”</li><li>Keep only the new joiners</li><li>Keep only those with training pending</li><li>Collect their email addresses</li><li>Send the reminders</li></ol><pre>return CompletableFuture.supplyAsync(() -&gt; fetchEmployees()) // 1. get everyone<br>      .thenApply(employees -&gt; employees.stream()<br>            .filter(e -&gt; &quot;TRUE&quot;.equals(e.getNewJoiner()))<br>            .toList())                               // 2. new joiners<br>      .thenApply(employees -&gt; employees.stream()<br>            .filter(e -&gt; &quot;TRUE&quot;.equals(e.getLearningPending()))<br>            .toList())                             // 3. training pending<br>      .thenApply(employees -&gt; employees.stream()<br>            .map(Employee::getEmail)<br>            .toList())                              // 4. email addresses<br>      .thenAccept(emails -&gt; <br>            emails.forEach(this::simulateEmailNotification)) // 5. notify<br>      .thenRun(() -&gt; System.out.println(&quot;Reminder workflow finished&quot;));</pre><p>Read it from top to bottom: each stage receives the previous stage’s result and prepares it for the next. The first three continuations transform data, so they use `thenApply()`. Sending reminders consumes email addresses, so it uses `thenAccept()`. The final message needs no previous result, so it uses `thenRun()`.</p><blockquote><em>🪤 </em><strong><em>Pitfall #1: the flipped filter.</em></strong><em> </em>A wrong boolean compiles fine and runs fine. On the sample data, filtering on &quot;FALSE&quot; instead of &quot;TRUE&quot; in step 3 would email the 263 people who already finished their training, and skip the 241 who haven&#39;t. <strong>Fix:</strong> give predicates names (isTrainingPending(employee)) and unit-test them.</blockquote><blockquote><em>🪤 </em><strong><em>Pitfall #2: “which thread runs my stage?”</em></strong><em> </em>A plain thenApply doesn&#39;t schedule a new task. Measuring both cases shows: if the previous stage is still running, your stage runs on the thread that completes it; if the previous stage is already done, your stage runs on the <strong>calling thread</strong> (main, in a quick test). So a heavy transformation can quietly block your caller. <strong>Fix:</strong> for heavy or blocking stages, use thenApplyAsync(fn, executor) and choose where the work runs.</blockquote><p>▶️ <strong>Run it yourself:</strong> <a href="https://github.com/adarsh-vijayakumar/Multithreading/blob/main/src/main/java/com/adarshvijayakumar/multithreading/completableFuture/ChainingTasksOnCompletableFuture.java">ChainingTasksOnCompletableFuture.java</a> prints the thread name at every stage, so you can watch the hand-offs happen.</p><h4>5. 🤝 Combining Futures: Four Patterns, One Cheat Sheet</h4><p>Real apps rarely do just one thing. To pick the right tool, ask one question: <strong>are the tasks dependent or independent?</strong></p><p><strong>1️⃣ </strong><strong>thenCompose: task B needs the result of task A</strong></p><p>Example: fetch an employee, then fetch that employee’s rating. The second lookup is itself asynchronous.</p><pre>CompletableFuture&lt;Integer&gt; ratingFuture = demo.getEmployee()<br>        .thenCompose(demo::getEmployeeRating);</pre><p>Here getEmployee() returns a CompletableFuture&lt;Employee&gt;, and getEmployeeRating(Employee) returns a CompletableFuture&lt;Integer&gt;. <br>Use thenApply and you get a future <em>inside</em> a future: CompletableFuture&lt;CompletableFuture&lt;Integer&gt;&gt;, which you would have to unwrap with get().get().thenCompose flattens it into a single CompletableFuture&lt;Integer&gt;.</p><p>If you know Streams: <strong><em>thenApply</em> </strong>is<strong> </strong><strong><em>map</em>, </strong><strong><em>thenCompose</em> is </strong><strong><em>flatMap</em>.</strong> 🧠</p><p>The two stages run one after the other, and nothing guarantees they share a thread.</p><blockquote><em>🪤 </em><strong><em>Pitfall: passing </em></strong><strong><em>null downstream.</em></strong><em> </em>If the lookup ends with .orElse(null), the next stage crashes with a NullPointerException far away from the real problem. <strong>Fix:</strong> fail fast with a clear message, so the future completes exceptionally right where the problem is:</blockquote><blockquote><em>.orElseThrow(() -&gt; new IllegalStateException(&quot;Employee not found: &quot; + id))</em></blockquote><p><strong>2️⃣ </strong><strong>thenCombine and </strong><strong>thenAcceptBoth: two independent tasks</strong></p><p>When neither task needs the other, start both and merge the results:</p><pre>CompletableFuture&lt;Map&lt;String, Long&gt;&gt; genderCountFuture = demo.countEmployeesByGender();<br>CompletableFuture&lt;Long&gt; newJoinerCountFuture = demo.countNewJoiners();<br><br>CompletableFuture&lt;String&gt; summaryFuture = genderCountFuture.thenCombine(<br>        newJoinerCountFuture,<br>        (genderCounts, newJoinerCount) -&gt;<br>                &quot;Employees by gender: &quot; + genderCounts + &quot;; new joiners: &quot; + newJoinerCount);</pre><p>Both tasks run concurrently, and the function fires once <strong>both</strong> have succeeded. On the sample data, that means 501 female, 499 male, and 504 new joiners.</p><ul><li>thenCombine combines two results and <strong>produces</strong> a new one</li><li>thenAcceptBoth consumes two results and produces <strong>nothing</strong> (CompletableFuture&lt;Void&gt;)</li></ul><blockquote><em>🪤 </em><strong><em>Pitfall: accidental serial execution.</em></strong><em> </em>Calling get() or join() on the first future before starting the second turns your parallel code sequential.<br><strong>Fix:</strong> start every future first, then combine or wait.</blockquote><p><strong>3️⃣ </strong><strong>allOf: wait for ALL of them (more than two)</strong></p><pre>CompletableFuture&lt;String&gt; weatherFuture = CompletableFuture.supplyAsync(() -&gt; fetchWeather());<br>CompletableFuture&lt;String&gt; newsFuture    = CompletableFuture.supplyAsync(() -&gt; fetchNews());<br><br>CompletableFuture.allOf(weatherFuture, newsFuture)<br>        .thenRun(() -&gt; System.out.println(weatherFuture.join() + &quot; - &quot; + newsFuture.join()))<br>        .join();</pre><p>allOf is non-blocking and returns a CompletableFuture&lt;Void&gt;, so you read each result yourself with join(). That is safe inside the callback, because every future is already done. ✅</p><blockquote><em>🪤 </em><strong><em>Pitfall: </em></strong><strong><em>allOf is not fail-fast.</em></strong><em> </em>If one future fails, allOf still waits for all the others, then completes exceptionally, and the good results are hidden behind the failure. <strong>Fix:</strong> give each future its own fallback before combining, so one failure can&#39;t sink the batch:</blockquote><blockquote><em>CompletableFuture&lt;String&gt; newsFuture = supplyAsync(() -&gt; fetchNews()).exceptionally(ex -&gt; &quot;news: unavailable&quot;);</em></blockquote><p><strong>4️⃣ </strong><strong>anyOf: the first one wins 🏁</strong></p><pre>CompletableFuture&lt;Object&gt; fastest = CompletableFuture.anyOf(cf1, cf2);<br>fastest.thenAccept(price -&gt; System.out.println(&quot;Received the first stock price: &quot; + price));</pre><p>It returns a CompletableFuture&lt;Object&gt;, so you cast the result yourself.</p><blockquote>🪤 <strong>Pitfall: </strong><strong>anyOf races completions, not successes.</strong> If the fastest future fails, that failure is your answer, even though a slower future would have succeeded. <strong>Fix:</strong> turn failures into &quot;never completes&quot; and add a timeout as the safety net:</blockquote><pre>static &lt;T&gt; CompletableFuture&lt;T&gt; ignoreFailure(CompletableFuture&lt;T&gt; f) {<br>    return f.handle((value, ex) -&gt; ex == null<br>                    ? CompletableFuture.completedFuture(value)<br>                    : new CompletableFuture&lt;T&gt;())      // a failure never completes<br>            .thenCompose(Function.identity());<br>}<br><br>Object winner = CompletableFuture.anyOf(ignoreFailure(cf1), ignoreFailure(cf2))<br>        .orTimeout(2, TimeUnit.SECONDS)   // if everything fails, don&#39;t wait forever<br>        .join();</pre><p><strong>📋 The cheat sheet</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0KKIJ8TIjnffQzOkF0HJTg.png" /></figure><p>▶️ <strong>Run it yourself:</strong> <a href="https://github.com/adarsh-vijayakumar/Multithreading/blob/main/src/main/java/com/adarshvijayakumar/multithreading/completableFuture/CombiningCompletableFutures.java">CombiningCompletableFutures.java</a> demonstrates thenCompose, thenCombine and thenAcceptBoth end to end, with thread names printed.</p><p><strong>🩹 When Things Go Wrong: Don’t Let Failures Vanish</strong></p><p>Here’s the good news: when one stage of a chain fails, the stages after it are <strong>skipped</strong> and the failure travels down the chain until something handles it. So a single handler at the end can cover the whole pipeline. 🛟</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*H-vkgRgO5BF3ONJGSrC0Bg.png" /></figure><pre>String message = CompletableFuture.supplyAsync(() -&gt; riskyCall())<br>      .thenApply(result -&gt; result.toUpperCase())     // skipped if riskyCall() fails<br>      .handle((value, ex) -&gt; ex == null<br>              ? &quot;OK: &quot; + value<br>              : &quot;Failed: &quot; + ex.getCause().getMessage())<br>      .join();</pre><blockquote>🪤 <strong>Pitfall #1: the silent failure.</strong> A failed future that nobody observes prints nothing and throws nothing. The error simply vanishes. A runAsync whose lambda throws is gone without a trace unless something looks at the outcome. <strong>Fix:</strong> end every chain with a handler.</blockquote><pre>CompletableFuture.runAsync(() -&gt; sendEmail())<br>        .whenComplete((v, ex) -&gt; {<br>            if (ex != null) log.error(&quot;Email failed&quot;, ex);<br>        });</pre><p><strong>🧯 The CompletableFuture Pitfall Checklist<br></strong>Eight traps, eight fixes. Pin this above your monitor before your next code review. 📌</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/977/1*DFAbaGM8-zrLJqX_feNBvA.png" /></figure><h4>6. @Async: let Spring run the method in the background 🌱</h4><p>CompletableFuture is plain Java. If you live in Spring Boot, there&#39;s a shortcut: put @Async on a method, and Spring runs it on another thread so the caller never waits. ⚡</p><p>Two steps:</p><ol><li>Add @EnableAsync to your main class (or any @Configuration class). It switches on Spring&#39;s asynchronous method execution.</li><li>Annotate a method of a Spring bean with @Async.</li></ol><p>First, enable async support and define a small pool:</p><pre>@Configuration<br>@EnableAsync<br>public class AsyncConfig {<br>    @Bean(&quot;employeeExecutor&quot;)<br>    public ThreadPoolTaskExecutor employeeExecutor() {<br>        ThreadPoolTaskExecutor pool = new ThreadPoolTaskExecutor();<br>        pool.setCorePoolSize(2);<br>        pool.setMaxPoolSize(4);<br>        pool.setQueueCapacity(20);<br>        pool.setThreadNamePrefix(&quot;employee-&quot;);<br>        return pool;<br>    }<br>}</pre><p>Spring manages this bean’s initialization and shutdown. The pool sizes are illustrative; tune them for your workload.<br>Now place the fetch in a Spring service:</p><pre>@Service<br>public class EmployeeService {<br>    @Async(&quot;employeeExecutor&quot;)<br>    public CompletableFuture&lt;List&lt;Employee&gt;&gt; fetchEmployees() {<br>        try {<br>            return CompletableFuture.completedFuture(<br>                    EmployeeDatabase.fetchAllEmployees());<br>        } catch (IOException ex) {<br>            throw new CompletionException(&quot;Employee fetch failed&quot;, ex);<br>        }<br>    }<br>}</pre><p>When another bean calls this service through its injected Spring proxy, Spring submits the method to `employeeExecutor` and returns a future. The pool can reuse a worker; not every call needs to create a new thread.</p><blockquote><strong>The common pitfall:</strong> calling an @Async method from another method in the same class bypasses Spring’s proxy (the default mode), so it runs synchronously on the caller’s thread. <strong>Fix:</strong> move the method into a separate bean and call it through the injected reference.</blockquote><p><strong>🏁 Three Words. Everything You Need.</strong></p><p><strong>Chain</strong> dependent steps. ⛓️<br> <strong>Combine</strong> independent ones. 🤝<br> <strong>Catch</strong> every failure. 🛟</p><p>That’s CompletableFuture. Everything else is detail, and you’ve just seen it all.</p><p>Now, confession time. 🙋 We covered a lot of traps in this article. <strong>Which one has burned you the most?</strong></p><p>1️⃣ 🐌 <strong>The </strong><strong>join() in the middle.</strong> You went async, then blocked yourself.<br> 2️⃣ 🤫 <strong>The silent failure.</strong> An exception vanished, and nothing got logged.<br> 3️⃣ 🧟 <strong>The JVM that never exits.</strong> You forgot to shutdown() your executor.</p><p>Vote in the comments with <strong>1, 2, or 3</strong>, or tell me the worst one you’ve ever shipped. 💬 Let’s see which one wins.</p><p>Then do these before you close the tab:</p><ul><li>👏 <strong>Clap</strong> if you learned something</li><li>🔁 <strong>Share</strong> it with the teammate who still writes new Thread()</li><li>➕ <strong>Follow</strong> for more on Java backend development and design</li></ul><p><strong><em>Threads were Level 1. You just unlocked the rest of the game.</em> 🚀</strong></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d36e0fa18c75" width="1" height="1" alt=""><hr><p><a href="https://medium.com/spring-boot-world/threads-are-the-engine-completablefuture-and-async-are-the-steering-wheel-%EF%B8%8F-d36e0fa18c75">Threads are the engine. CompletableFuture and @Async are the steering wheel. 🏎️</a> was originally published in <a href="https://medium.com/spring-boot-world">Spring Boot World</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Why Does Your Java Code Break When You Add Another Thread?]]></title>
            <link>https://medium.com/spring-boot-world/why-does-your-java-code-break-when-you-add-another-thread-962da621b67f?source=rss----61087b1caf41---4</link>
            <guid isPermaLink="false">https://medium.com/p/962da621b67f</guid>
            <category><![CDATA[java]]></category>
            <category><![CDATA[java-concurrency]]></category>
            <category><![CDATA[multithreading]]></category>
            <category><![CDATA[java-thread]]></category>
            <category><![CDATA[concurrency]]></category>
            <dc:creator><![CDATA[Adarsh Vijayakumar]]></dc:creator>
            <pubDate>Sun, 11 Oct 2026 15:01:49 GMT</pubDate>
            <atom:updated>2026-10-11T15:01:47.775Z</atom:updated>
            <content:encoded><![CDATA[<h4><em>From shared-state races to thread pools: a practical walkthrough with a runnable Java project.</em></h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*trhpT0UNxoyv_1JFBV2Lmw.jpeg" /></figure><p>Two threads increment a counter that starts at 10. You expect 12. You get 11.</p><p>Elsewhere, two workers are waiting for each other, and neither finishes. Another program submits seven tasks, but only six get processed.</p><p>These outcomes have explanations: a lost update, a deadlock, and a saturated thread pool. Understanding them makes multithreading much less mysterious.</p><p>I built a small Java project to explore these mechanisms directly. In this article, we’ll follow the same progression: create threads, let them share data, coordinate their work, and control how many tasks run at once.</p><p><strong>Want to experiment as you read? Open this </strong><a href="https://github.com/adarsh-vijayakumar/Multithreading"><strong>Multithreading repository</strong></a><strong>.</strong> It uses <em>Java 17. C</em>oncurrency demonstrations have their own main() methods. Run those classes directly.</p><p>Some examples intentionally expose unsafe behavior so we can understand the fix.</p><h4>1. What Actually Changes When We Add a Thread?</h4><p>A thread is a path of execution inside a process. A Java program can have several of these paths making progress concurrently.</p><p><strong>Concurrency</strong> means work can overlap in time. <strong>Parallelism</strong> means work executes at the same instant, usually on different CPU cores. Multiple threads enable concurrency; they don&#39;t guarantee parallel execution or faster code.</p><p>Threads within a process share its address space, including the Java heap. Each thread has its own execution stack. That shared heap makes communication convenient, but it also lets two threads reach the same mutable object.</p><p>Compared with separate processes, threads typically have lower communication and context-switching costs. Those costs still exist, though, and creating more threads than the workload needs can hurt performance.</p><h4>2. Starting Threads: Thread, Runnable and One Easy Mistake</h4><p>The project demonstrates two basic approaches.</p><p><strong>Extend Thread</strong></p><p>Put the work inside an overridden run() method:</p><pre>class Thread1 extends Thread {<br>    @Override<br>    public void run() {<br>        System.out.println(Thread.currentThread().getName());<br>    }<br>}</pre><p>Create and start it:</p><pre>Thread worker = new Thread1();<br>worker.start();</pre><p><strong>Implement </strong><strong>Runnable</strong></p><p>Here, the task is separate from the thread that executes it:</p><pre>class Thread2 implements Runnable {<br>    @Override<br>    public void run() {<br>        System.out.println(Thread.currentThread().getName());<br>    }<br>}<br><br>new Thread(new Thread2(), &quot;worker-2&quot;).start();</pre><p>For a short task, a lambda is enough:</p><pre>Thread worker = new Thread(() -&gt; {<br>    System.out.println(&quot;Running inside &quot;<br>            + Thread.currentThread().getName());<br>}, &quot;worker-lambda&quot;);<br><br>worker.start();</pre><p>Runnable is usually the better default for describing work. It keeps task logic separate from thread management and leaves your class free to extend another class.</p><p><strong>start() and </strong><strong>run() do different things</strong></p><ul><li>start() starts a new thread, which executes run().</li><li>Calling run() directly is an ordinary method call on the current thread.</li><li>After start(), the caller continues without waiting for the new thread to finish.</li></ul><p>That is why ThreadTester can print its main-thread exit message before worker output finishes.</p><p><strong>Need to wait? Use </strong><strong>join()</strong></p><pre>worker.start();<br>worker.join();             // The calling thread waits for worker to finish.<br>System.out.println(&quot;Worker finished&quot;);</pre><p>worker.join(1000) limits the wait to approximately one second, subject to scheduling. It does not stop the worker when the wait expires. join() itself never starts a thread.</p><h4>3. Thread States and Daemon Threads: Know What You’re Waiting For</h4><p>Java exposes six thread states:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SAIBI-zKHrRlDMERmmoGKQ.png" /></figure><p>Some diagrams split “ready” and “running.” Java’s Thread.State combines those ideas underRUNNABLE; there is no separate SLEEPING enum value.</p><p><strong>Daemon means a different shutdown role</strong></p><p>The project marks one worker as a daemon:</p><pre>Thread background = new Thread(new Thread2(), &quot;background&quot;);<br>background.setDaemon(true);<br>background.start();</pre><p>Set daemon status <strong>before</strong> calling start().</p><p>During normal JVM shutdown, daemon threads do not keep the process alive once all non-daemon threads have finished. Their remaining work is not guaranteed to complete.</p><p>Daemon status is independent of priority. A daemon thread is not inherently low priority, and an ordinary user thread is not inherently high priority.</p><p>Use this distinction when deciding whether background work must finish before the application exits.</p><h4>4. Shared Data: Where the Interesting Bugs Begin</h4><p>The project’s StackOperations gives two threads the same Stack instance: one pushes values while the other pops them.</p><p>The stack deliberately has no synchronization. Its push operation contains this sequence, with an artificial delay omitted here:</p><pre>if (isFull()) {<br>    return false;<br>}<br><br>elements[++top] = element;</pre><p>It looks compact, but it involves three steps: check capacity, updatetop, and write an array element. Another thread can interfere between those steps.</p><p>A <strong>race condition</strong> happens when two or more threads update the same shared value at the same time. The final result then depends on unlucky timing.</p><p>The stack has a broader problem: its index, capacity checks, and array contents must remain consistent together. An unsynchronized execution can violate that relationship.</p><p>The sleeps in the project increase the chances of interleaving. They do not guarantee a particular failure, and a successful run does not prove the code is thread-safe.</p><h4>5. synchronized: Protect the Whole Operation</h4><p>Every Java object has an associated monitor (lock). A synchronized block acquires that monitor before entering its body.</p><p>For the stack, one correction is to synchronize both operations on the same instance.</p><pre>public synchronized boolean push(int element) {<br>    if (top == elements.length - 1) {<br>        return false;<br>    }<br>    elements[++top] = element;<br>    return true;<br>}<br><br>public synchronized int pop() {<br>    if (top == -1) {<br>        return Integer.MIN_VALUE;<br>    }<br>    return elements[top--];<br>}</pre><p>Each capacity check and its corresponding update now happen while holding the same lock.</p><p><strong>Which object is being locked?</strong></p><ul><li>A synchronized <strong>instance method</strong> locks this.</li><li>A synchronized <strong>static method</strong> locks the class’s Class object.</li><li>A synchronized <strong>block</strong> locks the object supplied in its parentheses.</li></ul><p>A block allows a smaller critical section:</p><pre>synchronized (lock) {<br>    // Check and update shared state here.<br>}</pre><p>All accesses that need coordination must use the same locking discipline. Synchronizing push() while leaving pop() unprotected does not solve the problem.</p><p>Synchronization also provides visibility: changes made before releasing a monitor become visible to a thread that subsequently acquires that same monitor.</p><h4>6. volatile and AtomicInteger: Different Guarantees</h4><p><strong>volatile</strong> is a field modifier that guarantees <strong>visibility</strong>: when one thread writes the field, other threads see the new value instead of a stale cached copy. volatile makes shared updates visible.</p><pre>private volatile boolean running = true;</pre><p>The precise guarantee comes from Java’s memory model: a volatile write happens-before subsequent reads of that field. This establishes visibility and ordering. Calling it “always bypassing CPU caches” oversimplifies it.</p><p>But volatile does <strong>not</strong> make this compound operation atomic:</p><pre>count++;</pre><p>Reading, calculating, and writing still form a sequence that can interleave with another thread’s sequence.</p><p><strong>AtomicInteger gives us an atomic increment</strong></p><p>For counters, java.util.concurrent.atomic gives you a lighter tool than synchronized.</p><pre>import java.util.concurrent.atomic.AtomicInteger;<br><br>public class ThreadSafeCounter {<br>    private final AtomicInteger count = new AtomicInteger(0);<br><br>    public void increment() { count.incrementAndGet(); }  // atomic ++count<br>    public int getValue()   { return count.get(); }<br>}</pre><p>It works through <strong>Compare-And-Swap (CAS)</strong>, a hardware-supported instruction, in a non-blocking retry loop:</p><ol><li>Read the current value (say 10)</li><li>Compute the new value (11)</li><li>Write 11 <strong>only if</strong> memory still holds 10</li><li>If another thread got there first, the CAS fails and retries with the fresh value</li></ol><p>No lock, no blocking, and no lost updates. The same package has AtomicLong, AtomicBoolean, AtomicIntegerArray and AtomicLongArray.</p><p><strong>Choose the guarantee your data needs:</strong></p><ul><li>A simple shared status flag: volatile may be enough.</li><li>A counter increment: AtomicInteger fits naturally.</li><li>Several values that must change consistently: protect the whole operation, for example with synchronized.</li></ul><h4>7. Deadlock: Two Locks, Zero Progress</h4><p>Locks solve races, but they can cause a new problem. A <strong>deadlock</strong> is when two or more threads are blocked forever, each waiting for a lock the other holds. This is a circular wait, and the application simply hangs.</p><pre>Object lockA = new Object(), lockB = new Object();<br><br>Thread t1 = new Thread(() -&gt; {<br>    synchronized (lockA) {<br>        Thread.sleep(2000);<br>        synchronized (lockB) { System.out.println(&quot;t1 done&quot;); }<br>    }<br>});<br><br>Thread t2 = new Thread(() -&gt; {<br>    synchronized (lockB) {          // opposite order!<br>        Thread.sleep(2000);<br>        synchronized (lockA) { System.out.println(&quot;t2 done&quot;); }<br>    }<br>});</pre><p>t1 Holds lockAand wants lockB. <br>t2 Holds lockBand wants lockA. <br>Nobody moves.</p><p>In the repo, Deadlock.java reproduces this with opposing lock orders. It&#39;s a nice bug to watch happen once, safely.</p><p><strong>The classic fix:</strong> make every thread acquire locks in the <strong>same order</strong>. That breaks the circular wait.</p><h4><strong>8. </strong><strong>sleep(), </strong><strong>wait(), and </strong><strong>notify(): The Lock Makes the Difference</strong></h4><p>These methods appear together in concurrency discussions, but their effects differ:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/683/1*-e_cKQaJwRuDiwdphCLh0g.png" /></figure><p>You must own the object’s monitor before calling its wait(), notify()or notifyAll() methods. Also, wait() releases only that object&#39;s monitor, not every lock the caller might hold.</p><p>Notification does not guarantee immediate execution. The awakened thread must acquire the monitor and recheck its condition. Similarly, when a sleep duration expires, the thread becomes eligible to resume; it does not promise immediate CPU time.</p><p>These details become much easier to remember in the producer-consumer example below.</p><h4>9. Thread pools: stop creating threads like it’s free</h4><p>Creating a thread isn’t free. The JVM has to allocate its stack, program counter, and more. A <strong>thread pool</strong> is a set of worker threads that get <strong>reused</strong> for many tasks.</p><p><strong>Why use one?</strong></p><ul><li>Saves thread creation time, because threads are reused</li><li>Removes the overhead of managing thread lifecycles by hand</li><li>Limits how many threads exist, which avoids excess context switching</li></ul><h4>The framework</h4><p>Executor → ExecutorService → implemented by ThreadPoolExecutor (TPE) and ForkJoinPool. ScheduledExecutorService extends ExecutorService and is implemented by ScheduledThreadPoolExecutor. TPE is the customizable one, so that&#39;s the one to learn.</p><p>The constructor:</p><pre>ThreadPoolExecutor executor = new ThreadPoolExecutor(<br>    2,                                   // corePoolSize<br>    4,                                   // maximumPoolSize<br>    10, TimeUnit.MINUTES,                // keepAliveTime + unit<br>    new ArrayBlockingQueue&lt;&gt;(2),         // workQueue (bounded!)<br>    Executors.defaultThreadFactory(),    // threadFactory<br>    new CustomRejectionHandler()         // rejection policy<br>);<br><br>executor.allowCoreThreadTimeOut(true);<br><br>class CustomRejectionHandler implements RejectedExecutionHandler {<br>    @Override<br>    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {<br>        System.out.println(&quot;Rejected task by the thread: &quot;<br>            + Thread.currentThread().getName() + &quot; - &quot; + r.toString());<br>    }<br>}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*S868WeANj5pPlJKjt-9adw.png" /><figcaption>Parameters for the ThreadPoolExecutor constructor</figcaption></figure><p>allowCoreThreadTimeOut(true) is a method, not a constructor argument. It lets even core threads die after keepAliveTime when idle.</p><h4>How a task actually flows</h4><p><em>⚠️ </em><strong><em>Correction time.</em></strong><em> </em>A common summary says<em> the pool “first checks if a thread is free.” That’s not what happens. A </em><em>ThreadPoolExecutor </em>decides in this order:</p><ol><li>Fewer than corePoolSize threads running? <strong>Start a new thread</strong> for the task, even if other threads are idle.</li><li>Otherwise, try to put the task in the <strong>queue</strong>.</li><li>Queue full and threads &lt; maximumPoolSize? <strong>Start an extra thread</strong>.</li><li>Otherwise, <strong>reject</strong> the task.</li></ol><p>Core threads are also created lazily as tasks arrive, unless you call<em> </em><em>prestartAllCoreThreads().</em></p><p>Let’s trace it with my example: <strong>core = 2, max = 4, queue capacity = 2, and 7 tasks</strong> that each sleep for 3 seconds.</p><pre>executor.allowCoreThreadTimeOut(true);<br><br>for (int i = 1; i &lt;= 7; i++) {<br>    int taskId = i;<br>    executor.submit(() -&gt; {<br>        try { Thread.sleep(3000); } catch (Exception e) { /* handle */ }<br>        System.out.println(&quot;Task &quot; + taskId + &quot; processed by: &quot;<br>                           + Thread.currentThread().getName());<br>    });<br>}<br>executor.shutdown();</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MPblYoor6kbxvIarhoyPgA.png" /><figcaption>How tasks 1 through 7 are handled by ThreadPoolExecutor</figcaption></figure><h4>The pool’s lifecycle</h4><ul><li><strong>Running</strong>: accepts new tasks via submit().</li><li><strong>Shutdown</strong>: shutdown() stops accepting new tasks and finishes the existing ones.</li><li><strong>Stop</strong>: shutdownNow() stops accepting tasks, <strong>interrupts</strong> running ones, and returns the list of tasks that never started. It can&#39;t force-kill a task that ignores interrupts.</li><li><strong>Terminated</strong>: the end. Check with isTerminated().</li></ul><p>Don’t forget to<strong> </strong><strong>shutdown().</strong> The default factory creates non-daemon threads, so a pool you never shut down can keep your JVM alive.</p><h4>10. The Producer-Consumer problem</h4><p>This is the classic coordination puzzle. <strong>Producers</strong> insert items into a shared, fixed-size buffer and <strong>consumers</strong> remove them. It must work with:</p><ul><li><strong>Empty buffer</strong>: consumers must not try to take</li><li><strong>Full buffer</strong>: producers must not try to add</li><li><strong>Uneven speeds</strong>: nothing gets overwritten, and nobody consumes from empty slots</li></ul><p>There are two approaches:</p><p><strong>Approach 1: manual coordination with </strong><strong>wait() / </strong><strong>notify()</strong></p><pre>private final Queue&lt;Integer&gt; buffer = new LinkedList&lt;&gt;();<br>private static final int CAPACITY = 5;<br><br>// producer<br>synchronized (buffer) {<br>    while (buffer.size() == CAPACITY) {<br>        buffer.wait();               // full? give up the lock and wait<br>    }<br>    buffer.add(item);<br>    buffer.notifyAll();              // wake up waiting consumers<br>}<br><br>// consumer<br>synchronized (buffer) {<br>    while (buffer.isEmpty()) {<br>        buffer.wait();               // empty? wait<br>    }<br>    int item = buffer.remove();<br>    buffer.notifyAll();              // wake up waiting producers<br>}</pre><blockquote>⚠️ Always check the condition in a <em>while</em><strong> </strong>loop, never an <em>if</em>. Threads can wake up spuriously, or another thread may have grabbed the item first. Prefer notifyAll() when producers and consumers share one lock, so you don&#39;t wake the wrong kind of thread.</blockquote><p>In the repo, this approach is demonstrated in UsingThreads.java: synchronized, wait() and notify(). It works, but it&#39;s easy to get subtly wrong.</p><p><strong>Approach 2: let a </strong><strong>BlockingQueue do the work</strong></p><pre>BlockingQueue&lt;Integer&gt; queue = new ArrayBlockingQueue&lt;&gt;(5);<br><br>// producer: blocks automatically when the queue is full<br>queue.put(item);<br><br>// consumer: blocks automatically when the queue is empty<br>int item = queue.take();</pre><p>That’s it. No manual locks, no wait() loops. The queue handles empty and full for you (both methods throw InterruptedException, so handle it). Pair it with the Executor framework to manage the producer and consumer threads, and you have the production-style answer. That&#39;s UsingBlockingQueue.java in the repo.</p><h4>Beyond Manual Synchronization: What’s Next?</h4><p>You’ve now tackled the foundational core of Java concurrency — from orchestrating thread pools and managing synchronization to solving classic concurrency challenges like the Producer-Consumer problem. You have full control over low-level thread execution.</p><p>But raw threads and blocking queues are only the beginning. Modern Java applications often demand non-blocking, declarative asynchronous code without the headache of manual synchronization locks.</p><p>Java addresses these challenges through higher-level abstractions like CompletableFuture and Spring’s @Async capabilities — allowing you to chain asynchronous tasks, handle errors gracefully, and offload background operations seamlessly without blocking the main execution path.</p><p>Interested in deep diving into these modern, non-blocking asynchronous patterns? I’ll be covering them in my next article! Follow me on Medium so you don’t miss the next drop, and check out the complete companion code for this guide in my <a href="https://github.com/adarsh-vijayakumar/Multithreading">GitHub Multithreading</a> repository.</p><p>Which of these bugs has bitten you the hardest: the lost update, the deadlock, or the pool that wouldn’t shut down?</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=962da621b67f" width="1" height="1" alt=""><hr><p><a href="https://medium.com/spring-boot-world/why-does-your-java-code-break-when-you-add-another-thread-962da621b67f">Why Does Your Java Code Break When You Add Another Thread?</a> was originally published in <a href="https://medium.com/spring-boot-world">Spring Boot World</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Claude Code Skills: Why Yours Never Fires]]></title>
            <link>https://medium.com/spring-boot-world/claude-code-skills-why-yours-never-fires-3864aa364575?source=rss----61087b1caf41---4</link>
            <guid isPermaLink="false">https://medium.com/p/3864aa364575</guid>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[claude-code]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[software-engineering]]></category>
            <dc:creator><![CDATA[Lucas Fernandes ‍]]></dc:creator>
            <pubDate>Thu, 08 Oct 2026 11:02:39 GMT</pubDate>
            <atom:updated>2026-10-08T11:02:39.756Z</atom:updated>
            <content:encoded><![CDATA[<h4>And the Line That Fixes It</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*EPIuNwN-mG9Uc0mo6nnhaw.png" /></figure><h4>The One Line That Decides Whether the Skill Exists</h4><p>You spent an afternoon writing a skill for your backend service. Three hundred lines describing exactly how a REST endpoint gets built in this codebase: the controller, the DTOs, the validation, the error mapping.</p><p>Then you typed <em>“add an endpoint to list orders.”</em> Claude Code improvised. It didn’t follow a single step.</p><p>Here’s what actually happened. <strong>Your skill wasn’t ignored. It was never opened.</strong> And the reason fits in one line of that file.</p><p>This article explains why, and what to do about it:</p><ul><li>how a skill actually loads, and why the body is invisible until it’s chosen</li><li>why a vague description fails, and a three-part template that routes reliably</li><li>the frontmatter fields that change behavior, including one typo that fails silently</li><li>when a skill is the wrong tool altogether</li></ul><blockquote><em>Prefer video? I walk through the same file, with the before-and-after running live, </em><a href="https://youtu.be/UPgaIYMKYVA"><em>in this episode</em></a></blockquote><h3>Three layers, three loading moments</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*je09Ou2tDdy_cChv0P8fJw.png" /></figure><p>A skill isn’t one thing that loads all at once. It’s three layers, and each one loads at a different moment.</p><p><strong>Layer 1: the name and the description.</strong> This is the only part that sits in context all the time. Each skill gets one entry in a listing that the model scans on every request.</p><p><strong>Layer 2: the body of SKILL.md.</strong> It enters the conversation only when the skill is invoked: by you, typing the slash command, or by the model, deciding the skill is relevant. Once it&#39;s in, it stays in. Claude Code doesn&#39;t re-read the file on later turns.</p><p><strong>Layer 3: everything else in the folder.</strong> Reference docs, examples, scripts. These load only when the body tells the model they exist. And scripts get <em>executed</em>, not read.</p><p>If you write backend code, you already know this pattern: it’s lazy loading. In Spring terms, the skill is a <em>@Lazy</em> bean. The container knows it exists from startup, but nothing is built until someone asks for it.</p><p>That design is why skills are cheap. You can have twenty of them and pay context only for the one-line entries. But it has a consequence that’s easy to miss:</p><blockquote><strong><em>The model decides whether to open a skill without ever seeing its body.</em></strong><em> Layer 1 is all it has.</em></blockquote><h3>What the model sees when it picks a skill</h3><p>Here’s roughly what reaches the model when you send <em>“add an endpoint to list orders”</em> in a project with a handful of skills:</p><pre>Available skills:<br>  db-migration     Writes Flyway migrations for schema changes...<br>  kafka-consumer   Adds a Kafka consumer with retry and DLQ...<br>  rest-endpoint    Api Rest Helper<br>  test-slice       Creates @WebMvcTest and @DataJpaTest slices...<br>  openapi-sync     Regenerates the OpenAPI spec from controllers...<br><br>User: add an endpoint to list orders</pre><p>Read it the way the model does. Your request talks about <em>endpoint</em>, <em>list</em>, <em>orders</em>. The entry says <em>Api</em>, <em>Rest</em>, <em>Helper</em>. There’s no shared word and no clear intent. The model moves on and writes the code from its own defaults, and your procedure stays on disk.</p><p>So stop thinking of the description as documentation. <strong>It’s a routing rule, written for a reader that only ever sees this one line.</strong></p><h4>A description template that routes</h4><p>A description that routes well answers three questions, in this order.</p><p><strong>1. What does it do, concretely?</strong> Name the output, not the topic. “Creates a new REST endpoint in this Spring Boot service: controller, request and response DTOs, validation, error mapping, OpenAPI annotations.”</p><p><strong>2. When should it be used, in the user’s words?</strong> List the verbs and nouns people actually type. “Use when asked to add, create, or expose an endpoint, route, or API for a resource.”</p><p><strong>3. When should it <em>not</em> be used?</strong> Name the nearest neighbor that could steal the request. “Not for calling external APIs from this service.”</p><p>Here’s the before and after for rest-endpoint:</p><pre>---<br>name: rest-endpoint<br>description: Api Rest Helper<br>---</pre><pre>---<br>name: rest-endpoint<br>description: &gt;-<br>  Creates a new REST endpoint in this Spring Boot service — controller,<br>  request and response DTOs, validation, error mapping, OpenAPI annotations.<br>  Use when asked to add, create, or expose an endpoint, route, or API for a<br>  resource. Not for calling external APIs from this service.<br>---</pre><p>If you want a fill-in-the-blanks version for your own skills:</p><pre>&lt;Verb&gt;s &lt;concrete output&gt; in &lt;scope&gt;. Use when asked to &lt;verb&gt;, &lt;verb&gt;, or<br>&lt;verb&gt; &lt;noun&gt;, &lt;noun&gt;, or &lt;noun&gt;. Not for &lt;nearest neighbor&gt;.</pre><p>Two rules make the template hold up:</p><ul><li><strong>Key use case first.</strong> If the listing truncates anything, it truncates the tail.</li><li><strong>Use synonyms on purpose.</strong> “Endpoint, route, or API” costs three words and covers three ways to say the same thing. That’s not filler; that’s the routing table.</li></ul><h4>Test it like a method signature</h4><p>Here’s the mental model I use. When you decide whether to call a method, you don’t read its body. You read its signature:</p><pre>List&lt;OrderResponse&gt; listOrders(OrderFilter filter);</pre><p>Name, input, output: enough to decide. <strong>The description is your skill’s signature</strong>, and the caller is a model that never sees the implementation.</p><p>So the test is simple. Cover the body. Put your description next to the other entries in the listing, with a real user request in front of you. Would <em>you</em> pick it? If you hesitate, the model will too, and every line below the frontmatter might as well not exist.</p><h3>The frontmatter fields worth knowing</h3><p>The description decides <em>whether</em> a skill opens. A few other fields decide <em>who</em> can open it and <em>how</em> it runs.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JZT0TcAqgWUwCegTXJ_2wQ.png" /></figure><p>Three structural rules, and one trap:</p><ul><li>The frontmatter is YAML between two --- lines, and <strong>the opening </strong><strong>--- must be the very first line of the file.</strong> Not after a comment, not after a blank line.</li><li><em>name</em> becomes the slash command. Leave it out and the folder name is used.</li><li>Old <em>.claude/commands/</em> files still work as skills, in the older layout.</li></ul><p>Now the trap. Write disable-model-invokation: true, with a <em>k</em>, and Claude Code won&#39;t recognize the field. It won&#39;t complain either. <strong>No error, no warning.</strong> The skill loads as if the line weren&#39;t there, so the model <em>can</em> invoke the side-effecting skill you meant to lock down.</p><p>The typo is just the most visible case of a broader rule. <strong>Claude Code only acts on the frontmatter fields it officially supports.</strong> Anything else, whether misspelled or invented on purpose (priority: high, owner: payments-team, run-tests: always), has no runtime behavior at all. Nothing enforces it, nothing validates it, nothing reads it as configuration. At best, the line reaches the model as plain text, and then it&#39;s up to the model to infer what you meant, or not. That&#39;s a hope, not a guarantee.</p><p>So the rule is simple: <strong>use only the official fields.</strong> If a behavior isn’t in the <a href="https://code.claude.com/docs/en/skills">skills documentation</a>, a custom key won’t create it. Put the intent where something will honor it: in the description, in the body as an explicit instruction, or in a hook if it has to happen every time.</p><h3>Write the body as standing instructions</h3><p>Remember layer 2: the body is read once, when the skill is invoked, and never re-read. That changes how you phrase it.</p><p>“Run the tests” is a one-time step the model may consider done after the first edit. “Run the tests after every edit” is a standing instruction that survives the rest of the turn.</p><p>And if something must happen every single time, with no exceptions, a skill is the wrong place for it. A skill can only ask. Guarantees belong to hooks, which run as code outside the model. (That’s the argument of the previous article in this series, <em>Rules, Skills, Agents, Hooks: Which Claude Code Primitive Is Law?</em>)</p><h3>When a skill is the wrong tool</h3><p>A perfect description won’t save a skill that shouldn’t exist. Before you write one, check what problem you’re actually solving:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*y62x_bARdUmP5G_xFBINlQ.png" /></figure><h3>Key takeaways</h3><ul><li><strong>A skill that misfires wasn’t ignored. It was never opened.</strong> The model chooses from the one-line listing, without seeing the body.</li><li><strong>Write the description as a routing rule:</strong> what it does, when to use it in the user’s words, and when not to. Key use case first; 1,536 characters max.</li><li><strong>Use only official frontmatter fields, and validate them.</strong> An unknown field is silently ignored: no runtime behavior, at most a hint the model may or may not pick up. A typo in <em>disable-model-invocation</em> can unlock a skill with side effects.</li><li><strong>Pick the right primitive.</strong> If it must always happen, it’s a hook, not a skill.</li></ul><p>Here’s a five-minute exercise: open your <em>.claude/skills/</em> folder and read <em>only</em> the descriptions. I&#39;d bet at least one of them is a caption, not a routing rule.</p><p>Thank you for making it this far and for your valuable time reading this article.</p><p>Before you go:</p><ul><li>Be sure to clap 👏</li><li>Share with other developers who might benefit from this solution!</li><li>Follow the author to support: <a href="https://lucas-fernandes.medium.com/subscribe">https://lucas-fernandes.medium.com/subscribe</a></li><li>Donate: <a href="http://buymeacoffee.com/lucasfernandes93">Buy me a Coffee</a> ☕</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3864aa364575" width="1" height="1" alt=""><hr><p><a href="https://medium.com/spring-boot-world/claude-code-skills-why-yours-never-fires-3864aa364575">Claude Code Skills: Why Yours Never Fires</a> was originally published in <a href="https://medium.com/spring-boot-world">Spring Boot World</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Your CLAUDE.md Rule Got Ignored. You Picked the Wrong Primitive.]]></title>
            <link>https://medium.com/spring-boot-world/your-claude-md-rule-got-ignored-you-picked-the-wrong-primitive-29836de21c29?source=rss----61087b1caf41---4</link>
            <guid isPermaLink="false">https://medium.com/p/29836de21c29</guid>
            <category><![CDATA[claude]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[claude-code]]></category>
            <dc:creator><![CDATA[Lucas Fernandes ‍]]></dc:creator>
            <pubDate>Sat, 03 Oct 2026 00:55:31 GMT</pubDate>
            <atom:updated>2026-10-03T00:55:32.996Z</atom:updated>
            <content:encoded><![CDATA[<h4>Rules, Skills, Agents, Hooks: Which one should I create?</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6d5QZLRGtrKBsVoUnHpd7g.jpeg" /></figure><h3>Introduction</h3><p>You open the Claude.md of your Spring Boot project and write, in capital letters:</p><pre>AFTER ANY CODE WRITTEN UNDER SOURCE MAIN FOLDER, RUN THE PROJECT&#39;S TESTS.</pre><p>Claude Code finishes the change, marks the task as done, and never runs a single test.</p><p>The first reaction is always the same: the model is dumb, or lazy, or it ignored you on purpose. But none of it is true. The model didn’t disrespect you. <strong>You used the wrong tool.</strong></p><p>Writing a rule in prose and expecting it to be obeyed every time is the most common mistake in the whole Claude Code ecosystem, and it happens because nobody shows you how the pieces relate to each other.</p><p>That’s what this article does. I’ll put the four Claude Code main extensions file on one map, give you a single question that picks the right one every time, and then show what happened when I let Claude Code work inside a small Spring Boot project built around that map.</p><blockquote><em>This is the written version of the </em><a href="https://youtu.be/ZFhQubZU-0k"><em>first episode of my YouTube</em></a><em> playlist on the Claude Code ecosystem, on the </em><a href="https://www.youtube.com/@JavaPlusAI"><em>Java Plus AI</em></a><em> channel. In the video I draw the map piece by piece and run the demo live.</em></blockquote><h3>The map nobody gave you</h3><p>Claude Code has four primitives you write yourself:</p><ul><li><strong>Rules</strong>: your CLAUDE.md files</li><li><strong>Skills</strong>: .claude/skills/&lt;name&gt;/SKILL.md</li><li><strong>Subagents</strong>: files in .claude/agents/</li><li><strong>Hooks</strong>: commands configured in .claude/settings.json</li></ul><p>The official docs explain each one well, but in isolation. They don’t put them side by side, and that gap is exactly where people pick the wrong one.</p><p>So instead of four definitions, I use two axes. Every primitive answers two questions, and only two.</p><p><strong>When does this enter the context?</strong> Always, on demand, or never, because it runs outside the model. In practice this is a <strong>cost axis</strong>. Anything that is always loaded, you pay for in tokens, on every message, for the whole session.</p><p><strong>Who decides whether this is respected?</strong> The model or the computer. This is the axis that matters. At the top, behavior is <strong>heuristic</strong>: the model reads, interprets and decides. At the bottom, behavior is <strong>deterministic</strong>: code runs, and the result doesn’t depend on anyone’s interpretation.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*s5QeplutsDKKH5_wS5CXxQ.png" /></figure><p>Keep the word <em>deterministic</em> in mind. Three of the four primitives live at the top. Only one lives at the bottom, and it’s the one almost nobody uses properly.</p><h4>Rules: always on the table, never guaranteed</h4><p>Rules are your <em>CLAUDE.md</em>. It&#39;s a Markdown file loaded into context when the session starts, and it stays there. There is a hierarchy:</p><p><em>~/.claude/CLAUDE.md</em> covers every project you touch, <em>CLAUDE.md </em>at the project root goes into git so the whole team gets it, and a <em>CLAUDE.md</em> in a subdirectory loads only when the agent works in that folder.</p><p>Rules are great for things that <strong>always</strong> apply: your stack, your style, what this repository is.</p><p>They’re bad at anything long and anything conditional. Every line is paid on every message, and it’s text the model <em>reads</em>, not a command it <em>executes</em>. Reading is not obeying. That makes rules the most expensive primitive you have <em>and</em> the weakest guarantee. It’s also why a 400-line <em>CLAUDE.md </em>stops working. The model didn&#39;t get dumber. The file became noise.</p><h4>Skills: knowledge that shows up only when needed</h4><p>A skill is a folder under .<em>claude/skills/</em> a <em>SKILL.md</em> inside. The frontmatter has two fields that really matter: name and description.</p><p>Here’s the trick. The only thing that stays loaded all the time is the description, one line per skill. The body, with its steps, scripts and examples, doesn&#39;t touch the context until the model decides the skill is relevant. That&#39;s how you document twenty procedures without paying for twenty procedures on every message.</p><p>And here’s the part almost nobody takes seriously: <strong>the description isn&#39;t documentation. It&#39;s the trigger.</strong> Write it vaguely and the skill never loads. You&#39;ll swear the agent ignored your procedure, when in fact it never read it.</p><p>A skill is still heuristic. The model chooses whether to load it and whether to follow it. But it’s <strong>cheap</strong> heuristic, and that puts it in a different category from a rule.</p><h4>Subagents: a worker with its own context</h4><p>A subagent is a file in <em>.claude/agents/</em>. The difference from a skill is one thing, and it&#39;s a big one: <strong>a subagent runs in its own context window.</strong></p><p>It gets a task and works alone. It can read fifty files, take the wrong path, make a mess. Only the result comes back to the main agent, so the mess never pollutes your context. It can also run with a restricted tool set or on a different model.</p><p>That’s why I apply this rule without exceptions:</p><blockquote><strong>A subagent only justifies its existence for one of three reasons: to preserve context, to restrict tools, or to switch models. If none applies, it should have been a skill.</strong></blockquote><p>This isn’t pedantry. A subagent costs a new context window, duplicated tokens and a narrow return channel: you get a summary back, not everything it saw. Without one of those three reasons, you’re paying for isolation and throwing information away.</p><p>Rules, skills and subagents differ in cost and in isolation. On the vertical axis, though, they’re identical. All three sit above the line.</p><h4>Hooks: the only part that doesn’t negotiate</h4><p>A hook is a command you configure in <em>.claude/settings.json</em>. Claude Code runs it on lifecycle events: before a tool runs (<em>PreToolUse</em>), after it runs (<em>PostToolUse</em>), when you submit a prompt (<em>PreToolUse</em>), when a session starts (<em>SessionStart)</em>, when the agent tries to stop (<em>Stop</em>). The command <strong>always</strong> runs. The model has no vote. It isn&#39;t even consulted.</p><p>What makes hooks worth it is the <strong>exit code</strong>. Exit 0 and everything continues. Exit 2 and the action is blocked, and whatever the hook wrote to <em>stderr</em> goes back to the model as a correction.</p><blockquote><strong>That&#39;s the difference between &quot;please don&#39;t do this&quot; and &quot;this won&#39;t happen.&quot;</strong></blockquote><p>My rule of thumb: <strong>a hook verifies; it doesn’t teach.</strong> If you can check it with a command (a forbidden import, a class in the wrong layer, a broken build), don’t write it in prose. Write it as a hook.</p><p><strong>Prose is a request. A hook is law.</strong></p><h3>The single question</h3><p>With the map complete, you can see what I wanted to show from the start. Rules, skills and subagents all depend on the model reading, interpreting and agreeing. Their guarantee is the same: <em>probably</em>. Hooks sit below the line, and below means it happens no matter what the model thought.</p><p>So here’s the question that replaces the whole docs page:</p><blockquote><strong>Can this fail without anyone noticing?</strong></blockquote><p>If yes (an architecture rule, a forbidden import, a secret that must never leak, a commit that can’t happen without review), it goes below the line. It’s a hook. End of discussion.</p><p>If not, because it’s judgment, style or procedure, it stays above the line, and the other axes decide for you:</p><ul><li>applies <strong>always</strong>: rule</li><li>applies <strong>sometimes</strong>: skill</li><li>needs <strong>its own context, restricted tools or another model</strong>: subagent</li></ul><p>I spent years rejecting pull requests from people who solved with a code comment what the compiler should have guaranteed. This is the same mistake with a new tool. <strong>Prose doesn’t compile.</strong></p><h3>Hands on: the map in a real Spring Boot project</h3><p>Theory is cheap, so I built a minimal Spring Boot 4 / Java 21 REST app with one real file for each case: <a href="https://github.com/ice-lfernandes/demo-project-java-plus-ai-example">demo-project-java-plus-ai-example</a>.</p><p>No database, a couple of endpoints, just enough app for something real to sit behind a rule, a skill and a hook.</p><h4>“The project uses Java 21 and Spring Boot 4”: rule</h4><p>It always applies and it’s context, so it lives in <em>CLAUDE.md</em>:</p><pre>## Stack<br><br>This project uses Java 21 and Spring Boot 4. Always target that version —<br>don&#39;t downgrade the parent POM or the `java.version` property to make a<br>dependency fit.</pre><p>Short, unconditional, true for every task. That’s the whole job of a rule.</p><h4>“How to create a REST endpoint here, in eight steps”: skill</h4><p>A long procedure that applies only sometimes. Look at the description: it says <em>what</em> the skill does and <em>when</em> to use it, because that line is all the model sees until it loads the skill.</p><pre>---<br>name: rest-endpoint<br>description: How to create a REST endpoint in this project, in eight steps. Use when asked to add, create, or expose a new REST endpoint or API route in this Spring Boot app.<br>---<br><br># Creating a REST endpoint here<br><br>1. Decide the resource and the HTTP method — one verb per endpoint, no<br>   verbs in the URL (`/api/greetings/{name}`, not `/api/getGreeting`).<br>2. Add a `@RestController` class in `src/main/java/com/javaplusai/demo/`,<br>   named after the resource (`GreetingController`, not `GreetingApi` or<br>   `GreetingService`).<br>3. Map the method with `@GetMapping`/`@PostMapping`/etc., not the generic<br>   `@RequestMapping`.<br>4. Model the response as a `record`, nested in the controller unless it&#39;s<br>   reused elsewhere.<br>5. No persistence. This project has no database — if the endpoint needs<br>   state, it&#39;s out of scope here.<br>6. Constructor injection only for any collaborator, per `CLAUDE.md`. Never<br>   field injection.<br>7. Add a `@WebMvcTest` for the controller with `MockMvc`, asserting status<br>   and body via `jsonPath`.<br>8. Run the project&#39;s tests before considering the endpoint done — see the<br>   `Stop` hook in `.claude/settings.json`, which does this automatically.</pre><h4>“After any code under /src, run the tests&quot;: hook</h4><p>That capital-letters rule from the opening? Wrong primitive. It’s an invariant, so it belongs in a <em>Stop</em> hook that runs the test suite before the agent may finish. The wiring in <em>.claude/settings.json</em>:</p><pre>{<br>  &quot;hooks&quot;: {<br>    &quot;Stop&quot;: [<br>      {<br>        &quot;hooks&quot;: [<br>          {<br>            &quot;type&quot;: &quot;command&quot;,<br>            &quot;command&quot;: &quot;$CLAUDE_PROJECT_DIR/.claude/hooks/run-tests.sh&quot;<br>          }<br>        ]<br>      }<br>    ]<br>  }<br>}</pre><p>And the core of <em>.claude/hooks/run-tests.sh</em> (full file in the repo):</p><pre>CURRENT_HASH=&quot;$(find &quot;$SRC_DIR&quot; -type f -name &#39;*.java&#39; -exec sha256sum {} \; | sort | sha256sum | awk &#39;{print $1}&#39;)&quot;<br>PREVIOUS_HASH=&quot;$(cat &quot;$HASH_FILE&quot; 2&gt;/dev/null || echo &quot;&quot;)&quot;<br><br>if [ &quot;$CURRENT_HASH&quot; = &quot;$PREVIOUS_HASH&quot; ]; then<br>    exit 0<br>fi<br><br>if ! ./mvnw -q test 2&gt;test-output.log; then<br>    echo &quot;Tests failed after a change under /src. Fix the failures below before finishing:&quot; &gt;&amp;2<br>    cat test-output.log &gt;&amp;2<br>    rm -f test-output.log<br>    exit 2<br>fi<br><br>echo &quot;$CURRENT_HASH&quot; &gt; &quot;$HASH_FILE&quot;<br>rm -f test-output.log<br>exit 0</pre><p>The hash check is the detail most people miss. <em>Stop</em> doesn&#39;t fire only when a <em>task</em> ends. It fires every time Claude finishes a <em>response</em>, including when it just answers a question. Without the guard, &quot;what does this class do?&quot; costs you a full Maven run. With it, the suite runs only when Java code under <em>src/main/java</em> changed since the last green run.</p><h3>What Claude Code did inside this project</h3><p>In the <a href="https://youtu.be/ZFhQubZU-0k">video</a>, I open Claude Code in the project and type, typo included:</p><pre>create new ednpoint rest example</pre><p>Here’s what the recording shows:</p><ol><li><strong>The skill loaded on its own.</strong> First line of the transcript: <em>Skill(rest-endpoint) — Successfully loaded skill</em>. I never named the skill. The descritpion matched, and that was enough.</li><li><strong>It followed the procedure.</strong> It read <em>GreetingController</em> for the pattern and created <em>TimeController</em> with <em>GET /api/time</em> a nested <em>TimeResponse</em> record.</li><li><strong>It respected the rule.</strong> The controller needs a <em>java.time.Clock</em>. No Clock bean existed, so it added one to DemoApplication and injected it through the constructor, as <em>CLAUDE.md</em> says.</li><li><strong>It wrote a <em>@WebMvcTest</em></strong> <strong>with a fixed clock</strong> (step 7) and ran the tests (step 8).</li><li><strong>The tests failed.</strong> Its diagnosis: <em>“Bean clash: DemoApplication’s clock bean conflicts with test override.”</em></li><li><strong>It fixed the test</strong>, renaming the test bean to <em>fixedClock()</em> and marking it <em>@Primary</em>, then re-ran <em>./mvnw -q test</em>. All green.</li></ol><p>The fix, as committed:</p><pre>@TestConfiguration<br>static class FixedClockConfig {<br><br>    @Bean<br>    @Primary<br>    Clock fixedClock() {<br>        return Clock.fixed(FIXED_INSTANT, ZoneOffset.UTC);<br>    }<br>}</pre><h4>The hook had nothing to block, and that’s the point</h4><p>Notice who caught the failing test: Claude, because step 8 of the skill told it to. This time, the heuristic layer did its job.</p><p>But <em>this time</em> is the problem. Step 8 is still prose. On another day, with a longer session, a fuller context or a vaguer prompt, the model may skip it, exactly like it skipped the capital-letters rule. The skill makes running the tests <strong>likely</strong>. The hook makes it <strong>certain</strong>. Had Claude tried to finish with a red suite, the Stop hook would have exited 2, handed the Maven output back, and the session couldn&#39;t end until the tests passed.</p><p>That’s the division of labor I want in every project. The layers above the line make good behavior probable. The layer below the line makes bad behavior impossible.</p><h3>What to take away</h3><p>Don’t memorize the four names. Remember <strong>the line in the middle of the map</strong>. Almost every frustration I see with AI agents (the ignored rule, the skill that never loads, the subagent that burned a fortune to return three lines) comes from someone putting something on the wrong side of it.</p><p>Before your next instruction, ask: <em>can this fail without anyone noticing?</em> If yes, write a hook. If no, choose between rule, skill and subagent by when it applies and whether it needs isolation.</p><p>Then open your <em>CLAUDE.md</em> and find the line that should have been a hook all along. There&#39;s usually at least one. Tell me in the comments which one it was in yours.</p><ul><li><strong>Code from the video:</strong> <a href="https://github.com/ice-lfernandes/demo-project-java-plus-ai-example">demo-project-java-plus-ai-example</a></li><li><strong>More on YouTube:</strong> subscribe to <a href="https://www.youtube.com/@JavaPlusAI">Java Plus AI</a> for the full series.</li></ul><p>Thank you for making it this far and for your valuable time reading this article.</p><p>Before you go:</p><ul><li>Be sure to clap 👏</li><li>Share with other developers who might benefit from this solution!</li><li>Follow the author to support: <a href="https://lucas-fernandes.medium.com/subscribe">https://lucas-fernandes.medium.com/subscribe</a></li><li>Donate: <a href="http://buymeacoffee.com/lucasfernandes93">Buy me a Coffee</a> ☕</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=29836de21c29" width="1" height="1" alt=""><hr><p><a href="https://medium.com/spring-boot-world/your-claude-md-rule-got-ignored-you-picked-the-wrong-primitive-29836de21c29">Your CLAUDE.md Rule Got Ignored. You Picked the Wrong Primitive.</a> was originally published in <a href="https://medium.com/spring-boot-world">Spring Boot World</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Build an OpenAI-Compatible LLM Gateway with Spring Boot and Spring AI]]></title>
            <link>https://medium.com/spring-boot-world/build-an-openai-compatible-llm-gateway-with-spring-boot-and-spring-ai-b9614894dbdc?source=rss----61087b1caf41---4</link>
            <guid isPermaLink="false">https://medium.com/p/b9614894dbdc</guid>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[spring-ai]]></category>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[openai]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <dc:creator><![CDATA[Deva Prakash]]></dc:creator>
            <pubDate>Thu, 24 Sep 2026 10:33:05 GMT</pubDate>
            <atom:updated>2026-09-24T10:33:04.477Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vAj6yM55lqLtVx_N3O7knQ.png" /></figure><h3>Your First Chat Endpoint — Zero Cloud Cost, Fully Local</h3><p>Large Language Models are everywhere, but every provider speaks a slightly different dialect. OpenAI, Anthropic, Google Gemini, Ollama — each has its own SDK, authentication scheme, and response format. What if you could stand up a single gateway that unifies them all behind one API?</p><p>In this series, we’ll build exactly that: a <strong>production-grade LLM gateway</strong> using Spring Boot and Spring AI. By the end, you’ll have rate limiting, API key auth, PII guardrails, caching, streaming, and a pluggable provider system.</p><p>But we start simple. In this first article, we’ll build a working gateway that:</p><ul><li>Exposes an <strong>OpenAI-compatible</strong> POST /v1/chat/completions endpoint</li><li>Routes requests to a <strong>local Ollama</strong> instance (zero cost)</li><li>Returns responses in the <strong>exact format</strong> the OpenAI SDK expects</li><li>Handles validation and errors gracefully</li></ul><p>Let’s get started.</p><h3>Prerequisites</h3><ul><li><strong>Java JDK 21+</strong> — Runtime</li><li><strong>Maven 3.9+</strong> — Build tool</li><li><strong>Ollama (latest)</strong> — Local LLM runtime</li><li><strong>IDE (IntelliJ / VS Code)</strong> — Development environment</li></ul><p>Pull a model before we begin:</p><pre>ollama pull llama3</pre><h3>Project Setup</h3><p>We’ll use Spring Boot 3.5.x with Spring AI 1.1.x. Here’s the core of our pom.xml:</p><pre>&lt;parent&gt;<br>    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;<br>    &lt;artifactId&gt;spring-boot-starter-parent&lt;/artifactId&gt;<br>    &lt;version&gt;3.5.14&lt;/version&gt;<br>&lt;/parent&gt;<br><br>&lt;properties&gt;<br>    &lt;java.version&gt;21&lt;/java.version&gt;<br>    &lt;spring-ai.version&gt;1.1.5&lt;/spring-ai.version&gt;<br>&lt;/properties&gt;<br>&lt;dependencies&gt;<br>    &lt;dependency&gt;<br>        &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;<br>        &lt;artifactId&gt;spring-boot-starter-web&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-validation&lt;/artifactId&gt;<br>    &lt;/dependency&gt;<br>    &lt;dependency&gt;<br>        &lt;groupId&gt;org.springframework.ai&lt;/groupId&gt;<br>        &lt;artifactId&gt;spring-ai-starter-model-ollama&lt;/artifactId&gt;<br>    &lt;/dependency&gt;<br>    &lt;dependency&gt;<br>        &lt;groupId&gt;org.projectlombok&lt;/groupId&gt;<br>        &lt;artifactId&gt;lombok&lt;/artifactId&gt;<br>        &lt;optional&gt;true&lt;/optional&gt;<br>    &lt;/dependency&gt;<br>&lt;/dependencies&gt;<br>&lt;dependencyManagement&gt;<br>    &lt;dependencies&gt;<br>        &lt;dependency&gt;<br>            &lt;groupId&gt;org.springframework.ai&lt;/groupId&gt;<br>            &lt;artifactId&gt;spring-ai-bom&lt;/artifactId&gt;<br>            &lt;version&gt;${spring-ai.version}&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><p>Key points:</p><ul><li><strong>Spring AI BOM</strong> manages all AI-related dependency versions</li><li><strong>spring-ai-starter-model-ollama</strong> auto-configures a ChatModel bean that talks to Ollama</li><li><strong>Validation starter</strong> gives us @Valid + Jakarta annotations</li></ul><h3>Application Configuration</h3><pre>spring:<br>  application:<br>    name: spring-llm-gateway<br>  ai:<br>    ollama:<br>      base-url: http://localhost:11434<br>      chat:<br>        options:<br>          model: llama3<br>          temperature: 0.7<br>server:<br>  port: 8080</pre><p>Spring AI picks this up automatically — no manual bean wiring needed. The ChatModel is ready to inject.</p><h3>Defining the API Contract</h3><p>We want our gateway to be a <strong>drop-in replacement</strong> for the OpenAI API. That means snake_case JSON and the exact same response shape. Here are our DTOs:</p><h3>Request</h3><pre>@Data<br>@Builder<br>@NoArgsConstructor<br>@AllArgsConstructor<br>public class GatewayRequest {<br>    <br>    @NotBlank(message = &quot;Model cannot be blank&quot;)<br>    private String model;<br><br>    @NotEmpty(message = &quot;Messages cannot be empty&quot;)<br>    @Valid<br>    private List&lt;ChatMessage&gt; messages;<br><br>    private Double temperature;<br><br>    @JsonProperty(&quot;max_tokens&quot;)<br>    private Integer maxTokens;<br><br>    @JsonProperty(&quot;top_p&quot;)<br>    private Double topP;<br>}</pre><h3>Message</h3><pre>@Data<br>@Builder<br>@NoArgsConstructor<br>@AllArgsConstructor<br>public class ChatMessage {<br>    <br>    @NotBlank(message = &quot;Role cannot be blank&quot;)<br>    private String role;<br>    <br>    @NotBlank(message = &quot;Content cannot be blank&quot;)<br>    private String content;<br>}</pre><h3>Response</h3><pre>@Data<br>@Builder<br>@NoArgsConstructor<br>@AllArgsConstructor<br>public class GatewayResponse {<br>    <br>    private String id;<br>    private String object;<br>    private Long created;<br>    private String model;<br>    private List&lt;ChatChoice&gt; choices;<br>    private TokenUsage usage;<br><br>    @JsonProperty(&quot;system_fingerprint&quot;)<br>    private String systemFingerprint;<br>}</pre><h3>Choice &amp; Usage</h3><pre>@Data<br>@Builder<br>@NoArgsConstructor<br>@AllArgsConstructor<br>public class ChatChoice {<br>    <br>    private Integer index;<br>    private ChatMessage message;<br><br>    @JsonProperty(&quot;finish_reason&quot;)<br>    private String finishReason;<br>}</pre><pre>@Data<br>@Builder<br>@NoArgsConstructor<br>@AllArgsConstructor<br>public class TokenUsage {<br>    @JsonProperty(&quot;prompt_tokens&quot;)<br>    private Long promptTokens;<br>    @JsonProperty(&quot;completion_tokens&quot;)<br>    private Long completionTokens;<br>    @JsonProperty(&quot;total_tokens&quot;)<br>    private Long totalTokens;<br>}</pre><p>The @JsonProperty annotations ensure our Java camelCase maps to OpenAI&#39;s snake_case on the wire. Any OpenAI SDK or Spring AI OpenAI client can consume this without modification.</p><h3>The Chat Service — Bridging Spring AI</h3><p>This is where the magic happens. Spring AI’s ChatModel abstraction lets us call any LLM provider through a unified interface. We map our DTOs to Spring AI&#39;s internal types, call the model, and map back.</p><pre>@Service<br>@RequiredArgsConstructor<br>public class ChatService {<br>    <br>    private final ChatModel chatModel;<br><br>    public GatewayResponse complete(GatewayRequest request) {<br>        List&lt;Message&gt; messages = request.getMessages().stream()<br>            .map(this::toMessage)<br>            .toList();<br>        <br>        OllamaChatOptions.Builder optionsBuilder = OllamaChatOptions.builder()<br>            .model(request.getModel());<br>        if (request.getTemperature() != null) {<br>            optionsBuilder.temperature(request.getTemperature());<br>        }<br>        if (request.getMaxTokens() != null) {<br>            optionsBuilder.numPredict(request.getMaxTokens());<br>        }<br>        if (request.getTopP() != null) {<br>            optionsBuilder.topP(request.getTopP());<br>        }<br>        Prompt prompt = new Prompt(messages, optionsBuilder.build());<br>        ChatResponse response = chatModel.call(prompt);<br>        return mapToGatewayResponse(request.getModel(), response);<br>    }<br><br>    private Message toMessage(ChatMessage message) {<br>        return switch (message.getRole().toLowerCase()) {<br>            case &quot;system&quot; -&gt; new SystemMessage(message.getContent());<br>            case &quot;user&quot; -&gt; new UserMessage(message.getContent());<br>            case &quot;assistant&quot; -&gt; new AssistantMessage(message.getContent());<br>            default -&gt; throw new IllegalArgumentException(&quot;Invalid role: &quot; + message.getRole());<br>        };<br>    }<br><br>    private GatewayResponse mapToGatewayResponse(String model, ChatResponse response) {<br>        List&lt;ChatChoice&gt; choices = IntStream.range(0, response.getResults().size())<br>                .mapToObj(i -&gt; {<br>                    var generation = response.getResults().get(i);<br>                    return ChatChoice.builder()<br>                            .index(i)<br>                            .message(ChatMessage.builder()<br>                                    .role(&quot;assistant&quot;)<br>                                    .content(generation.getOutput().getText())<br>                                    .build())<br>                            .finishReason(generation.getMetadata() != null<br>                                    ? generation.getMetadata().getFinishReason()<br>                                    : null)<br>                            .build();<br>                })<br>                .toList();<br>        var usage = response.getMetadata() != null &amp;&amp; response.getMetadata().getUsage() != null<br>                ? response.getMetadata().getUsage()<br>                : null;<br>        return GatewayResponse.builder()<br>                .id(&quot;chatcmpl-&quot; + UUID.randomUUID().toString().replace(&quot;-&quot;, &quot;&quot;).substring(0, 24))<br>                .object(&quot;chat.completion&quot;)<br>                .created(Instant.now().getEpochSecond())<br>                .model(model)<br>                .choices(choices)<br>                .usage(usage != null<br>                        ? TokenUsage.builder()<br>                                .promptTokens(usage.getPromptTokens().longValue())<br>                                .completionTokens(usage.getCompletionTokens().longValue())<br>                                .totalTokens(usage.getTotalTokens().longValue())<br>                                .build()<br>                        : null)<br>                .build();<br>    }<br>}</pre><h3>What’s happening here:</h3><ol><li><strong>Message mapping</strong> — We convert our simple role/content DTOs into Spring AI’s typed message objects (UserMessage, SystemMessage, AssistantMessage)</li><li><strong>Options building</strong> — Ollama-specific options like numPredict (their name for max_tokens) are set conditionally</li><li><strong>Response mapping</strong> — We extract generations, token counts, and metadata into our OpenAI-compatible response shape</li><li><strong>ID generation</strong> — We follow OpenAI’s chatcmpl- prefix convention</li></ol><h3>Error Handling</h3><p>A production gateway needs structured error responses. We create a base exception and a global handler:</p><pre>@Getter<br>public class GatewayException extends RuntimeException {<br>    private final int status;<br>    private final String code;<br>    public GatewayException(String code, int status, String message) {<br>        super(message);<br>        this.code = code;<br>        this.status = status;<br>    }<br>    public GatewayException(String code, int status, String message, Throwable cause) {<br>        super(message, cause);<br>        this.code = code;<br>        this.status = status;<br>    }<br>}</pre><pre>@RestControllerAdvice<br>public class GlobalExceptionHandler {<br>    <br>    @ExceptionHandler(MethodArgumentNotValidException.class)<br>    public ResponseEntity&lt;Map&lt;String, Object&gt;&gt; handleValidation(MethodArgumentNotValidException ex) {<br>        String message = ex.getBindingResult().getFieldErrors().stream()<br>            .map(error -&gt; String.format(&quot;%s: %s&quot;, error.getField(), error.getDefaultMessage()))<br>            .reduce((a, b) -&gt; a + &quot;; &quot; + b)<br>            .orElse(&quot;Validation failed&quot;);<br>        return ResponseEntity.status(HttpStatus.BAD_REQUEST)<br>            .body(Map.of(&quot;error&quot;, Map.of(&quot;code&quot;, &quot;VALIDATION_ERROR&quot;, &quot;message&quot;, message)));<br>    }<br><br>    @ExceptionHandler(GatewayException.class)<br>    public ResponseEntity&lt;Map&lt;String, Object&gt;&gt; handleGatewayException(GatewayException ex) {<br>        HttpStatus status = switch (ex.getCode()) {<br>            case &quot;MODEL_NOT_FOUND&quot; -&gt; HttpStatus.NOT_FOUND;<br>            case &quot;PROVIDER_ERROR&quot; -&gt; HttpStatus.BAD_GATEWAY;<br>            default -&gt; HttpStatus.INTERNAL_SERVER_ERROR;<br>        };<br>        return ResponseEntity.status(status)<br>                .body(Map.of(&quot;error&quot;, Map.of(&quot;code&quot;, ex.getCode(), &quot;message&quot;, ex.getMessage())));<br>    }<br><br>    @ExceptionHandler(IllegalArgumentException.class)<br>    public ResponseEntity&lt;Map&lt;String, Object&gt;&gt; handleIllegalArgument(IllegalArgumentException ex) {<br>        return ResponseEntity.status(HttpStatus.BAD_REQUEST)<br>                .body(Map.of(&quot;error&quot;, Map.of(&quot;code&quot;, &quot;BAD_REQUEST&quot;, &quot;message&quot;, ex.getMessage())));<br>    }<br><br>    @ExceptionHandler(Exception.class)<br>    public ResponseEntity&lt;Map&lt;String, Object&gt;&gt; handleGeneric(Exception ex) {<br>        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)<br>                .body(Map.of(&quot;error&quot;, Map.of(&quot;code&quot;, &quot;INTERNAL_ERROR&quot;, &quot;message&quot;, &quot;An unexpected error occurred&quot;)));<br>    }<br>}</pre><p>This gives us:</p><ul><li><strong>400</strong> for validation failures (missing model, empty messages, invalid role)</li><li><strong>404</strong> for model-not-found scenarios</li><li><strong>502</strong> for upstream provider errors</li><li><strong>500</strong> as a safe catch-all (never leaks internal details)</li></ul><h3>The Controller — Wiring It Together</h3><p>The simplest layer. One endpoint, one method:</p><pre>@RestController<br>@RequestMapping(&quot;/v1&quot;)<br>@RequiredArgsConstructor<br>public class ChatController {<br>    private final ChatService chatService;<br>    @PostMapping(&quot;/chat/completions&quot;)<br>    public ResponseEntity&lt;GatewayResponse&gt; chatCompletions(@Valid @RequestBody GatewayRequest request) {<br>        GatewayResponse response = chatService.complete(request);<br>        return ResponseEntity.ok(response);<br>    }<br>}</pre><p>Note the path: /v1/chat/completions — identical to OpenAI&#39;s. Any client configured to point at http://localhost:8080 as the base URL will work seamlessly.</p><h3>Testing It</h3><p>Start Ollama and the gateway:</p><pre>ollama serve<br>mvn spring-boot:run</pre><h3>Happy path</h3><pre>curl -s http://localhost:8080/v1/chat/completions \<br>  -H &quot;Content-Type: application/json&quot; \<br>  -d &#39;{<br>    &quot;model&quot;: &quot;llama3&quot;,<br>    &quot;messages&quot;: [<br>      {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Say hello in one word&quot;}<br>    ]<br>  }&#39; | jq .</pre><p>Response:</p><pre>{<br>  &quot;id&quot;: &quot;chatcmpl-ee0b0603f6c346799756417f&quot;,<br>  &quot;object&quot;: &quot;chat.completion&quot;,<br>  &quot;created&quot;: 1777552454,<br>  &quot;model&quot;: &quot;llama3&quot;,<br>  &quot;choices&quot;: [<br>    {<br>      &quot;index&quot;: 0,<br>      &quot;message&quot;: {<br>        &quot;role&quot;: &quot;assistant&quot;,<br>        &quot;content&quot;: &quot;Hello!&quot;<br>      },<br>      &quot;finish_reason&quot;: &quot;stop&quot;<br>    }<br>  ],<br>  &quot;usage&quot;: {<br>    &quot;prompt_tokens&quot;: 15,<br>    &quot;completion_tokens&quot;: 3,<br>    &quot;total_tokens&quot;: 18<br>  },<br>  &quot;system_fingerprint&quot;: null<br>}</pre><h3>Validation error</h3><pre>curl -s http://localhost:8080/v1/chat/completions \<br>  -H &quot;Content-Type: application/json&quot; \<br>  -d &#39;{<br>    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Hello&quot;}]<br>  }&#39; | jq .</pre><pre>{<br>  &quot;error&quot;: {<br>    &quot;code&quot;: &quot;VALIDATION_ERROR&quot;,<br>    &quot;message&quot;: &quot;model: Model cannot be blank&quot;<br>  }<br>}</pre><h3>Using It as an OpenAI-Compatible Backend</h3><p>Here’s the payoff. Any application that speaks the OpenAI protocol can now point at your gateway. For example, another Spring Boot app using Spring AI’s OpenAI starter:</p><pre>spring:<br>  ai:<br>    openai:<br>      base-url: http://localhost:8080<br>      api-key: not-needed<br>      chat:<br>        options:<br>          model: llama3</pre><p>No code changes in the consumer. Just config. It will call your gateway, which routes to Ollama locally, and everything works.</p><p>This also means tools like <strong>LangChain</strong>, <strong>LiteLLM</strong>, or any OpenAI-compatible client library can consume your gateway out of the box.</p><h3>Project Structure</h3><pre>com.llmgateway<br>├── SpringLlmGatewayApplication.java<br>├── controller/<br>│   └── ChatController.java<br>├── model/<br>│   └── dto/<br>│       ├── GatewayRequest.java<br>│       ├── GatewayResponse.java<br>│       ├── ChatMessage.java<br>│       ├── ChatChoice.java<br>│       └── TokenUsage.java<br>├── service/<br>│   └── ChatService.java<br>└── exception/<br>    ├── GatewayException.java<br>    └── GlobalExceptionHandler.java</pre><h3>What’s Next</h3><p>We have a working, OpenAI-compatible LLM gateway running entirely on your machine. In the next article, we’ll add:</p><ul><li><strong>Multi-provider support</strong> — OpenAI, Anthropic, Google Gemini alongside Ollama</li><li><strong>Provider registry</strong> — route requests to the right provider based on model name</li><li><strong>Dynamic configuration</strong> — add providers via YAML without code changes</li></ul><p>The foundation is solid. Time to make it scale.</p><p><em>All code is available on </em><a href="https://github.com/deva-prakash-j/spring-llm-gateway/tree/basic-setup"><em>GitHub</em></a><em>. Star the repo and follow along as we build this out.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b9614894dbdc" width="1" height="1" alt=""><hr><p><a href="https://medium.com/spring-boot-world/build-an-openai-compatible-llm-gateway-with-spring-boot-and-spring-ai-b9614894dbdc">Build an OpenAI-Compatible LLM Gateway with Spring Boot and Spring AI</a> was originally published in <a href="https://medium.com/spring-boot-world">Spring Boot World</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>