<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Praveen Yadav</title>
    <description>The latest articles on DEV Community by Praveen Yadav (@ykpraveen).</description>
    <link>https://dev.to/ykpraveen</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F569972%2F089d56a9-b0f6-4d91-ba28-0b5cb92248cd.png</url>
      <title>DEV Community: Praveen Yadav</title>
      <link>https://dev.to/ykpraveen</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ykpraveen"/>
    <language>en</language>
    <item>
      <title>Building a Multi-Agent Hiring Workflow with LangChain4j and LangGraph4j on Spring Boot</title>
      <dc:creator>Praveen Yadav</dc:creator>
      <pubDate>Wed, 05 Aug 2026 06:57:32 +0000</pubDate>
      <link>https://dev.to/ykpraveen/building-a-multi-agent-hiring-workflow-with-langchain4j-and-langgraph4j-on-spring-boot-2a15</link>
      <guid>https://dev.to/ykpraveen/building-a-multi-agent-hiring-workflow-with-langchain4j-and-langgraph4j-on-spring-boot-2a15</guid>
      <description>&lt;p&gt;I wanted to actually learn &lt;a href="https://github.com/langchain4j/langchain4j" rel="noopener noreferrer"&gt;LangChain4j&lt;/a&gt; and &lt;a href="https://github.com/langgraph4j/langgraph4j" rel="noopener noreferrer"&gt;LangGraph4j&lt;/a&gt; beyond the "hello world" chat example, so I built something with enough moving parts to force real decisions: a hiring workflow where several AI agents independently score a candidate, a graph aggregates their opinions into a routing decision, and a human gets pulled in whenever the signals aren't clean. It's &lt;a href="https://github.com/ykpraveen/langchain4j-sample" rel="noopener noreferrer"&gt;langchain4j-sample&lt;/a&gt; on Spring Boot, with LangGraph4j's Postgres checkpointer for state.&lt;/p&gt;

&lt;p&gt;This post is two things: first, how the framework pieces fit together and what I learned exploring them; second — because I didn't stop at "works against a mock LLM" — what happened when I pointed the same graph at a real, local, CPU-only Ollama model, and what that taught me about running agentic workflows outside a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why LangGraph4j instead of just chaining LangChain4j calls
&lt;/h2&gt;

&lt;p&gt;The workflow needed three things a plain sequence of LLM calls doesn't give you for free: independent agents that run at the same time, a pause point that waits for a human and resumes later (possibly minutes or days later, possibly after a restart), and a durable record of exactly what happened at each step. That's what pushed me toward LangGraph4j's &lt;code&gt;StateGraph&lt;/code&gt; rather than just wiring LangChain4j calls together by hand.&lt;/p&gt;

&lt;p&gt;The graph itself reads close to how I'd describe the process out loud:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;parseResume
     │
     ├─ unreadable? ──────────────► humanReview
     │
     ▼ (fan out, 4 concurrent agents)
skills · experience · cultureFit · redFlags
     │
     ▼
aggregateScores ──► automated? ──► finalDecision ──► notification
     │
     └─ not automated ──► humanReview ──(approve/reject/re-analyze)──► finalDecision
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ParseResumeNode&lt;/code&gt; extracts structured data from raw resume text. If it comes back unreadable, there's no point running four scoring agents against nothing, so the graph short-circuits straight to human review. Otherwise it fans out to four independent agents — skills, experience, culture fit, red flags — each reasoning about a different dimension of fit. &lt;code&gt;AggregateScoresNode&lt;/code&gt; combines their verdicts with a weighted formula, and anything that isn't a clean auto-advance or auto-reject drops into a &lt;code&gt;humanReview&lt;/code&gt; interrupt. A human can approve, reject, or ask for a targeted re-analysis, which loops back into review until someone makes a final call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AiServices made pleasant
&lt;/h2&gt;

&lt;p&gt;Each agent is a plain Java interface — no client boilerplate, no manual JSON parsing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;SkillsAgent&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@UserMessage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""
            Candidate ID: {{candidateId}}
            Job Requisition ID: {{requisitionId}}

            Use the available tools to fetch the job requisition and the candidate. Assess how
            well the candidate's skills (from their resume text) match the requisition's required
            skills. Score 0-100. Flag any required skill that's missing (e.g.
            "MISSING_REQUIRED_SKILL:Kubernetes"). Recommend ADVANCE, CAP, or REJECT.
            """&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;AgentScoreResult&lt;/span&gt; &lt;span class="nf"&gt;analyzeSkills&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@V&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"candidateId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;candidateId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nd"&gt;@V&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"requisitionId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;requisitionId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AgentScoreResult&lt;/code&gt; is a plain record — &lt;code&gt;score&lt;/code&gt;, &lt;code&gt;flags&lt;/code&gt;, &lt;code&gt;recommendation&lt;/code&gt; — and LangChain4j handles getting the model to fill it in and parsing the result back into that shape. Wiring in tools was just as low-friction: &lt;code&gt;AiServices.builder(SkillsAgent.class).chatModel(...).tools(jobRequisitionTool, candidateTool).build()&lt;/code&gt;, and the model decides for itself when to call &lt;code&gt;fetchJobRequisition&lt;/code&gt; or &lt;code&gt;fetchCandidate&lt;/code&gt; mid-conversation. No manual function-calling loop to write.&lt;/p&gt;

&lt;p&gt;The part I liked most: swapping LLM backends is pure config, not code. One &lt;code&gt;ChatModel&lt;/code&gt; bean is conditional on &lt;code&gt;llm.provider=mock&lt;/code&gt; and returns fixture data (great for tests and for exploring the graph's routing logic without burning tokens); another is conditional on &lt;code&gt;llm.provider=ollama&lt;/code&gt; and builds a real &lt;code&gt;OllamaChatModel&lt;/code&gt;. Every agent is wired against whichever bean is active — the agent interfaces and the graph don't know or care which one it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What LangGraph4j made pleasant — and one thing that wasn't obvious
&lt;/h2&gt;

&lt;p&gt;The four scoring agents run through a &lt;code&gt;ParallelNode&lt;/code&gt; — you point several edges out of the same source node and LangGraph4j fans them out concurrently. What &lt;em&gt;wasn't&lt;/em&gt; obvious from the docs: registering an executor once at graph-compile time isn't enough. Every single invocation needs the executor attached to its own &lt;code&gt;RunnableConfig&lt;/code&gt;, or the "parallel" branches just run one after another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;RunnableConfig&lt;/span&gt; &lt;span class="nf"&gt;runnableConfig&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;threadId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;RunnableConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;threadId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;threadId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addParallelNodeExecutor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HiringWorkflowGraph&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ANALYSIS_FAN_OUT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;analysisFanOutExecutor&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other genuinely nice piece is &lt;code&gt;interruptBefore(HUMAN_REVIEW)&lt;/code&gt; paired with a &lt;code&gt;PostgresSaver&lt;/code&gt; checkpointer. The graph pauses &lt;em&gt;inside&lt;/em&gt; an in-progress execution, persists its exact state, and returns control to the HTTP request — then, whenever a human decision comes in (hours or days later, possibly against a different app instance after a restart), &lt;code&gt;compiledGraph.invoke(GraphInput.resume(...), sameThreadId)&lt;/code&gt; picks the graph back up exactly where it left off. I didn't have to build any of that resumability myself.&lt;/p&gt;

&lt;p&gt;One thing that cost me some trial and error: checkpointing serializes state to plain JSON with no type tagging, so a value read back after a reload comes back as a generic &lt;code&gt;Map&lt;/code&gt;/&lt;code&gt;String&lt;/code&gt;, not the original record. Every accessor on my &lt;code&gt;WorkflowState&lt;/code&gt; normalizes through &lt;code&gt;ObjectMapper.convertValue&lt;/code&gt; so callers get a consistently-typed result whether the value just came from the same in-memory node-to-node handoff or from a checkpoint reload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Class&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isInstance&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cast&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;OBJECT_MAPPER&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;convertValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in place, and the mock provider driving deterministic fixture data, the whole graph — fan-out, aggregation, human-review interrupt, resume, final decision — worked exactly as designed. That's usually where a learning project like this stops. I kept going, because "works against canned fixtures" and "works against a real model" turned out to be very different claims.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I pointed it at a real, local, CPU-only model
&lt;/h2&gt;

&lt;p&gt;I didn't want a cloud API key for a side project, so I ran &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; locally on an old CPU-only laptop. This is where the framework knowledge from above collided with the actual, physical limits of local inference — and where I learned the most.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson 1: a synchronous call and a busy backend interact in a non-obvious way
&lt;/h3&gt;

&lt;p&gt;My smoke-test script submits eight candidate/requisition pairs with a 900-second timeout per case — generous, I assumed, for a 3B model. Case 1 blew straight through it with no response.&lt;/p&gt;

&lt;p&gt;The instinct is to suspect the model is stuck. The real interaction was between two things I'd built independently and never thought to consider together: &lt;code&gt;WorkflowController.submit&lt;/code&gt; runs the &lt;em&gt;entire&lt;/em&gt; graph invocation synchronously on the request thread (&lt;code&gt;compiledGraph.invoke(...)&lt;/code&gt;, no timeout of its own), and &lt;code&gt;ollama serve&lt;/code&gt; runs with a single execution slot by default (&lt;code&gt;-np 1&lt;/code&gt;) — so the four "concurrent" fan-out agents don't run concurrently against it at all, they queue and execute one at a time. When curl gave up at 900 seconds, nothing told the server thread to stop; it kept running, kept holding its place in Ollama's queue, for a client that had already left. Every subsequent request just queued up behind it, since nothing had cancelled it.&lt;/p&gt;

&lt;p&gt;The fix taught me something reusable well beyond this project: run the invocation on its own executor with a bounded &lt;code&gt;Future.get(timeout)&lt;/code&gt;, and call &lt;code&gt;future.cancel(true)&lt;/code&gt; on timeout. For a thread blocked inside a JDK &lt;code&gt;HttpClient.send()&lt;/code&gt;, that interrupt actually propagates and releases the connection — it's not just a client-side illusion of giving up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;WorkflowState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;invokeWithTimeout&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;threadId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;GraphInvocation&lt;/span&gt; &lt;span class="n"&gt;invocation&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;WorkflowState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;future&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;workflowInvocationExecutor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;submit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;invocation:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;invoke&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;future&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;runTimeoutSeconds&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SECONDS&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TimeoutException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;future&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cancel&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ResponseStatusException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;GATEWAY_TIMEOUT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;...);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generalizable lesson: "parallel" describes your code's intent, not a guarantee about the backend underneath it. Fan four calls out against something with one execution slot, and you've built a queue with extra latency dressed up as concurrency — and if nothing can cancel a request once its caller stops waiting, that queue only grows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson 2: a "thinking" model taxes you even when you didn't ask it to think
&lt;/h3&gt;

&lt;p&gt;I benchmarked the model I'd configured — &lt;code&gt;qwen3.5:4b&lt;/code&gt; — with the simplest possible prompt: &lt;em&gt;"Reply with exactly: OK."&lt;/em&gt; It took 35.2 seconds. The response carried a 158-token internal reasoning trace ahead of the two-word answer — this is a reasoning model, and it pays that cost on every call, regardless of how trivial the task is. Switching to &lt;code&gt;qwen2.5:3b&lt;/code&gt;, a similarly-sized non-reasoning model I already had pulled, the identical prompt came back in 7.4 seconds with no reasoning overhead at all.&lt;/p&gt;

&lt;p&gt;For a real resume-scoring prompt the gap held: &lt;code&gt;qwen2.5:3b&lt;/code&gt; answered in 14.9 seconds with 56 tokens of correctly-shaped JSON. Out of curiosity I tried &lt;code&gt;phi3:mini&lt;/code&gt; too — 22.9 seconds, 149 tokens, and the JSON was invalid: wrapped in markdown fences despite being told not to, with fields that weren't even in the schema I'd asked for.&lt;/p&gt;

&lt;p&gt;What I took from this: for a task with a strict output contract, a model's willingness to reason out loud isn't a bonus, it's overhead you're paying for on every single call whether you wanted it or not. Model &lt;em&gt;choice&lt;/em&gt; mattered more here than any amount of prompt tuning afterward.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson 3: an agent that can call tools can also just... not
&lt;/h3&gt;

&lt;p&gt;Once the faster model was in, I re-ran the smoke test and got something odder: every one of eight very different candidates came back with an identical, critical, human-review-forcing flag — &lt;code&gt;POLICY_VIOLATION&lt;/code&gt;. Including candidates whose resumes had nothing resembling a policy issue.&lt;/p&gt;

&lt;p&gt;I turned on LangChain4j's request/response logging and timestamp-correlated every call in one run to see exactly what each agent actually did. Three of the four scoring agents called their tools (&lt;code&gt;fetchCandidate&lt;/code&gt;, &lt;code&gt;fetchJobRequisition&lt;/code&gt;, &lt;code&gt;fetchCompanyPolicies&lt;/code&gt;) before answering. The red-flags agent's very first response — before any tool call — was already its final answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"redFlags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POLICY_VIOLATION"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CRITICAL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"forcesHumanReview"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It never looked at any data. Comparing prompts explained why &lt;em&gt;this&lt;/em&gt; agent specifically: its example code, &lt;code&gt;"POLICY_VIOLATION"&lt;/code&gt;, is a complete, plausible-sounding answer with nothing candidate-specific to fill in. The skills agent's example, &lt;code&gt;"MISSING_REQUIRED_SKILL:Kubernetes"&lt;/code&gt;, isn't copyable the same way — the model has to supply a real, specific skill name for it to make sense. A model that's uncertain about tool use can lazily echo the first kind of example wholesale; it can't do that with the second.&lt;/p&gt;

&lt;p&gt;The fix was making the tool-use requirement explicit and making the example un-copyable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- Use the available tools to fetch the job requisition, the candidate, and the
- company policies applicable to the requisition's department. ... give a short code
- (e.g. "EMPLOYMENT_GAP", "POLICY_VIOLATION")
&lt;/span&gt;&lt;span class="gi"&gt;+ You must call fetchJobRequisition, fetchCandidate, and fetchCompanyPolicies before
+ forming any opinion — never answer from the candidate ID and requisition ID alone.
+ ... give a short code naming the specific policy or issue (e.g. "EMPLOYMENT_GAP",
+ "POLICY_VIOLATION:NON_COMPETE"). If the fetched data shows no actual gap or policy
+ conflict, return an empty redFlags array — never report a flag you can't tie to
+ something the tools actually returned.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fabricated critical violations disappeared. Being honest about the rest of the story: re-tracing calls afterward, the model still occasionally skipped tool-calling on its first turn — it just now defaults to a safe &lt;code&gt;{"score": 0, "redFlags": []}&lt;/code&gt; instead of a dangerous fabricated one. The prompt change narrowed the blast radius of the underlying behavior; it didn't eliminate it. That's a genuinely useful thing to learn about steering a small model through instructions alone: it has a ceiling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson 4: knowing where that ceiling actually is
&lt;/h3&gt;

&lt;p&gt;The obvious next step past "ask nicely" is "force it." LangChain4j has exactly the primitive for that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;OllamaChatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defaultRequestParameters&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;ChatRequestParameters&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toolChoice&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ToolChoice&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;REQUIRED&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This didn't get silently ignored — it failed fast, at startup, before ever reaching Ollama:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UnsupportedFeatureException: ToolChoice.REQUIRED is not supported yet by this model provider
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LangChain4j's own client-side validation refuses to construct the model. As of the version I'm on (1.18.0), forced tool choice just isn't wired up for the Ollama integration yet. Useful to know precisely, rather than spending an afternoon guessing why a "fix" wasn't taking effect: if I want that guarantee today, the actual answer is architectural, not a config flag — fetch the data in Java myself and hand it to the model directly, rather than asking the model to decide whether to fetch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping it without asking anyone to install Ollama by hand
&lt;/h2&gt;

&lt;p&gt;None of the above is worth much if trying the project means manually pulling a model before anything works. The Compose setup ended up as three long-running services plus one one-shot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ollama&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ollama/ollama&lt;/span&gt;
  &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ollama"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
    &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;

&lt;span class="c1"&gt;# One-shot: pulls the model into ollama's own volume, cached across restarts,&lt;/span&gt;
&lt;span class="c1"&gt;# so the app never races a still-downloading model on first startup.&lt;/span&gt;
&lt;span class="na"&gt;ollama-pull&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ollama/ollama&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;OLLAMA_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ollama:11434&lt;/span&gt;
  &lt;span class="na"&gt;entrypoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ollama"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pull"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;qwen2.5:3b"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;ollama&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;

&lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;LLM_PROVIDER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ollama&lt;/span&gt;
    &lt;span class="na"&gt;OLLAMA_MODEL_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;qwen2.5:3b&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;ollama-pull&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_completed_successfully&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ollama-pull&lt;/code&gt; is a throwaway container whose only job is telling the already-running &lt;code&gt;ollama&lt;/code&gt; service to pull a model over the network. The model lands in &lt;code&gt;ollama&lt;/code&gt;'s own volume, not the puller's, so it's cached across every future &lt;code&gt;docker compose up&lt;/code&gt; — confirmed by watching a re-run finish in under a second instead of re-downloading. &lt;code&gt;app&lt;/code&gt; waits for that pull to fully &lt;em&gt;complete&lt;/em&gt;, not just for Ollama to answer a healthcheck, so there's no race against a half-downloaded model on first boot.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd want to remember from this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;LangChain4j's &lt;code&gt;AiServices&lt;/code&gt; + tool-calling genuinely removes boilerplate — the agent interfaces stayed clean even once real tool use and structured output were involved.&lt;/li&gt;
&lt;li&gt;LangGraph4j's interrupt/resume + Postgres checkpointing is the feature I'd reach for again immediately; building durable human-in-the-loop pauses by hand would've been a project on its own.&lt;/li&gt;
&lt;li&gt;A synchronous call with no timeout is a promise you can't keep once a caller stops waiting — and if nothing can cancel the underlying work, "the caller gave up" and "the work is still running" quietly become two different facts.&lt;/li&gt;
&lt;li&gt;Concurrency is a property of your code's &lt;em&gt;intent&lt;/em&gt;; whether it's real depends entirely on what's on the other end of the call.&lt;/li&gt;
&lt;li&gt;For strict-output tasks, a reasoning model's internal monologue is a cost you pay on every call, not a quality feature.&lt;/li&gt;
&lt;li&gt;A few-shot example that's copyable as a complete, plausible answer is a hallucination surface, especially for a model that's unsure what else to do.&lt;/li&gt;
&lt;li&gt;Prompt engineering has a ceiling with small models — it's worth knowing exactly where the framework's own capabilities stop, rather than assuming a wording tweak will eventually close the gap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repo — graph, agents, the timeout/fallback fixes, and the Compose setup — is at &lt;a href="https://github.com/ykpraveen/langchain4j-sample" rel="noopener noreferrer"&gt;github.com/ykpraveen/langchain4j-sample&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>java</category>
      <category>ai</category>
      <category>llm</category>
      <category>springboot</category>
    </item>
    <item>
      <title>What a fake production stack taught me about CI/CD</title>
      <dc:creator>Praveen Yadav</dc:creator>
      <pubDate>Tue, 28 Jul 2026 14:13:31 +0000</pubDate>
      <link>https://dev.to/ykpraveen/what-a-fake-production-stack-taught-me-about-cicd-2j1d</link>
      <guid>https://dev.to/ykpraveen/what-a-fake-production-stack-taught-me-about-cicd-2j1d</guid>
      <description>&lt;p&gt;I recently built &lt;a href="https://github.com/ykpraveen/jenkins-ansible-sample" rel="noopener noreferrer"&gt;jenkins-ansible-sample&lt;/a&gt;, a self-contained sandbox that simulates a real deployment pipeline: Jenkins builds and tests an app, Ansible rolls it out across a fake dev/staging/prod fleet over SSH, and a health-check-driven rollback gets exercised by a deliberately broken deploy. Everything runs as Docker containers on a single machine — no cloud account, no real servers.&lt;/p&gt;

&lt;p&gt;The point of the project wasn't the app (a two-endpoint Flask service). It was to force myself through the parts of CI/CD that tutorials usually skip: real host topology, secrets that have to flow through three different execution contexts, and a rollback path that has to actually fire, not just exist in a diagram. This post is less "here's how to configure Jenkins" and more "here's what I'd tell someone before they start" — the general principles, with the code that fell out of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Simulate the real topology, not a shortcut
&lt;/h2&gt;

&lt;p&gt;It's tempting, when building a demo, to have your "deploy" step just be a &lt;code&gt;docker exec&lt;/code&gt; into a sibling container. It's fast and it works. It also teaches you nothing about the failure modes of a real deploy, because &lt;code&gt;docker exec&lt;/code&gt; doesn't have host keys, network partitions, or a login shell.&lt;/p&gt;

&lt;p&gt;So the fleet here is plain containers running &lt;code&gt;sshd&lt;/code&gt; as PID 1, reachable only over real SSH:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;fleet-dev&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/ssh-fleet&lt;/span&gt;
  &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dev&lt;/span&gt;
  &lt;span class="na"&gt;privileged&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;fleet_net&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;aliases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;dev&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2201:22"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ansible talks to &lt;code&gt;dev&lt;/code&gt; exactly the way it would talk to a real VM — inventory groups, an SSH key, &lt;code&gt;become: true&lt;/code&gt; for privilege escalation. The theoretical point: &lt;strong&gt;the fidelity of your test environment should match the fidelity of the failure modes you actually want to catch.&lt;/strong&gt; A shortcut that skips SSH also skips every SSH-shaped problem (host key changes, key permissions, connection timeouts) you'll eventually hit for real.&lt;/p&gt;

&lt;p&gt;The same idea shows up in &lt;code&gt;docker_engine&lt;/code&gt;, a role that installs an actual Docker daemon onto each of these simulated hosts (&lt;code&gt;privileged: true&lt;/code&gt;, its own nested dockerd) rather than assuming Docker is already there. It's more setup, but it means the role is exercised the same way it would run against a freshly provisioned VM, not against an environment that already has half the prerequisites baked in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker-outside-of-Docker: a tradeoff, not a hack
&lt;/h2&gt;

&lt;p&gt;Any CI step that needs to build or run containers has to reach a Docker daemon somehow. There are two common answers: run a nested &lt;code&gt;dind&lt;/code&gt; (Docker-in-Docker) daemon inside the CI container, or mount the &lt;em&gt;host's&lt;/em&gt; &lt;code&gt;docker.sock&lt;/code&gt; into it (Docker-outside-of-Docker, DooD). This project uses DooD everywhere — the Jenkins agent, the &lt;code&gt;ansible-control&lt;/code&gt; container, Molecule's test runner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ansible-control&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/ansible-control&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DooD is simpler and has none of dind's storage-driver quirks, but the tradeoff is real and worth stating plainly: a container with the host's &lt;code&gt;docker.sock&lt;/code&gt; mounted has root-equivalent access to that host. Any process inside it can launch a privileged container and walk straight out of its own sandbox. That's an acceptable tradeoff for a single-tenant CI box you control; it would not be an acceptable default for, say, a shared multi-tenant build service running arbitrary user code.&lt;/p&gt;

&lt;p&gt;The lesson generalizes: &lt;strong&gt;infrastructure shortcuts aren't inherently bad, but they need to be a named decision with a stated blast radius&lt;/strong&gt;, not an implementation detail nobody thought about. If you can't articulate what a shortcut costs you, you haven't finished evaluating it.&lt;/p&gt;

&lt;p&gt;One consequence of DooD worth knowing about: when a container spawned via &lt;code&gt;docker.sock&lt;/code&gt; is a &lt;em&gt;sibling&lt;/em&gt; of the container that launched it, not something nested inside it, cleanup can't rely on the parent's lifecycle to take the child down with it. Molecule's test container in this project is launched exactly that way, which is why the CI stage runs it with &lt;code&gt;--destroy=always&lt;/code&gt; — without that, a failed step earlier in the test sequence would skip teardown, and the test container would happily outlive the short-lived agent that spawned it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollback is a state machine, not an &lt;code&gt;if&lt;/code&gt; statement
&lt;/h2&gt;

&lt;p&gt;The centerpiece of the project is the deploy role's rollback logic, and it's worth describing as what it actually is: a small state machine with three states (deploying, healthy, rolling back), not a single try/catch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Wait for the new container to pass its post-deploy health check&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.uri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app_port&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/health"&lt;/span&gt;
    &lt;span class="na"&gt;status_code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
  &lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;health_result&lt;/span&gt;
  &lt;span class="na"&gt;until&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;health_result.status | default(0) == &lt;/span&gt;&lt;span class="m"&gt;200&lt;/span&gt;
  &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;health_check_retries&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
  &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;health_check_delay&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
  &lt;span class="na"&gt;failed_when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Roll back to the last-known-good image if the health check failed&lt;/span&gt;
  &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;health_result.status | default(0) != &lt;/span&gt;&lt;span class="m"&gt;200&lt;/span&gt;
  &lt;span class="na"&gt;block&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Start the last-known-good image in its place&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docker&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app_name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;rollback_image&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
      &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rollback_image | length &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Wait for the rolled-back container to pass its health check&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.uri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app_port&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/health"&lt;/span&gt;
        &lt;span class="na"&gt;status_code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
      &lt;span class="s"&gt;...&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Fail the play if the post-deploy health check never passed&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.fail&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;msg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;..."&lt;/span&gt;
  &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;health_result.status | default(0) != &lt;/span&gt;&lt;span class="m"&gt;200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few decisions here are more important than the YAML makes them look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The "last known good" reference is captured &lt;em&gt;before&lt;/em&gt; touching anything&lt;/strong&gt;, not reconstructed after a failure. By the time you know a deploy is bad, it's too late to reliably ask "what was running a minute ago" — you have to have written it down while it was still true.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rolled-back deploy still fails the pipeline.&lt;/strong&gt; The host ends up healthy, but the &lt;em&gt;build&lt;/em&gt; is still reported red. Silently "fixing itself" and reporting green would hide a real regression behind a rollback that happened to work — the whole point of the signal is to make someone look at it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollback failure is a distinct, more serious outcome than deploy failure&lt;/strong&gt;, and the code actually distinguishes it (a rollback that itself fails its health check gets a different message than a clean rollback). Collapsing both into "deploy failed" throws away the information an on-call engineer needs most: is this a bad release, or is the host actually down?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;any_errors_fatal: true&lt;/code&gt; plus per-environment plays&lt;/strong&gt; means a bad dev deploy never even attempts staging or prod. Promotion gates only work if a failure upstream actually stops the pipeline instead of merely being logged.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One secret, one indirection, three consumers
&lt;/h2&gt;

&lt;p&gt;Secrets management often turns into a different bespoke mechanism per environment: a &lt;code&gt;.env&lt;/code&gt; file locally, a different secret store in CI, yet another one in the deployment tool. This project instead puts a single layer of indirection in front of one password and lets every consumer go through it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ansible.cfg&lt;/code&gt; points at a script instead of a static file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[defaults]&lt;/span&gt;
&lt;span class="py"&gt;vault_password_file&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;vault_pass.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-eu&lt;/span&gt;
: &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ANSIBLE_VAULT_PASSWORD&lt;/span&gt;:?ANSIBLE_VAULT_PASSWORD&lt;span class="p"&gt; must be set&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ANSIBLE_VAULT_PASSWORD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Locally, that's an exported shell variable. In GitHub Actions, it's a repository secret injected as an env var on the steps that need it. In Jenkins, it's a credential bound via &lt;code&gt;withCredentials&lt;/code&gt; in the pipeline. All three paths terminate at the exact same script reading the exact same variable name — nothing about &lt;em&gt;how&lt;/em&gt; Ansible reads its vault password changes depending on who's driving it. The theoretical payoff: &lt;strong&gt;the number of places that need to know "how do I get the secret" should be one, regardless of how many places need the secret.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That same discipline shows up in how the Jenkinsfile passes the SSH key through to a shell command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s2"&gt;"ansible-playbook deploy.yml --limit dev -e app_deploy_app_image_tag=${IMAGE_TAG} "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
   &lt;span class="s2"&gt;"-e fleet_ssh_private_key_file="&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s1"&gt;'$FLEET_SSH_KEY'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$FLEET_SSH_KEY&lt;/code&gt; is deliberately left single-quoted so the &lt;em&gt;shell&lt;/em&gt; expands it at runtime, not Groovy's string interpolation at pipeline-compile time. Interpolating a credential straight into the script text via a Groovy &lt;code&gt;GString&lt;/code&gt; bypasses Jenkins' log-masking, because masking works by pattern-matching the credential's value in the process's own output, not by tracking where a variable came from — if the value never appears as a literal in the generated shell command, masking has nothing to catch. It's a small, easy-to-miss distinction with a real consequence: get it backwards and your vault password shows up in plaintext in a build log.&lt;/p&gt;

&lt;h2&gt;
  
  
  If it's not codified, it doesn't count as tested
&lt;/h2&gt;

&lt;p&gt;Every config surface in this project is provisioned by code, not clicked into place: Jenkins' users, credentials, and seed job are all defined in &lt;code&gt;jenkins.yaml&lt;/code&gt; (JCasC); Grafana's datasource and dashboard are provisioned from files, not built by hand in the UI; even which services start at all is driven by Compose profiles (&lt;code&gt;--profile tools&lt;/code&gt;, &lt;code&gt;--profile observability&lt;/code&gt;) rather than commenting lines in and out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jenkins&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;systemMessage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jenkins-ansible-sample&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;—&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;configured&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;via&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;JCasC,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;do&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;not&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;click&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;around&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;by&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;hand"&lt;/span&gt;
  &lt;span class="na"&gt;numExecutors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;   &lt;span class="c1"&gt;# forces every build onto a Docker Cloud agent, never the controller itself&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason this matters more than it sounds: &lt;strong&gt;a manually clicked configuration is a fact about one running instance, not a fact about the system.&lt;/strong&gt; It can't be code-reviewed, it can't be diffed between two setups that disagree, and it silently stops being true the moment someone reinstalls. Config-as-code turns "how is this configured" from a question you answer by inspecting a live system into one you answer by reading a file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Different artifacts fail differently — lint each one on its own terms
&lt;/h2&gt;

&lt;p&gt;It's tempting to reach for one linter and call it done. This project runs four completely different checks, because a Dockerfile, a YAML playbook, a Python app, and an Ansible &lt;em&gt;role's actual runtime behavior&lt;/em&gt; fail in four different ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;hadolint&lt;/code&gt; for Dockerfiles (image-layer hygiene, not application logic)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;yamllint&lt;/code&gt; + &lt;code&gt;ansible-lint&lt;/code&gt; for the Ansible layer (syntax and Ansible-specific anti-patterns)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pytest&lt;/code&gt;, run as a build stage the final image can't skip:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;  FROM base AS test
  RUN uv sync --frozen --group test --no-install-project
  COPY app.py .
  COPY tests/ tests/
  RUN python -m pytest -q

  FROM base AS final
  COPY --from=test /app/app.py .   # only reachable if the test stage above succeeded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;molecule&lt;/code&gt; for the &lt;code&gt;common&lt;/code&gt; role, which spins up a real container, applies the role, and asserts on the &lt;em&gt;actual resulting state&lt;/em&gt; of the machine — not the playbook's syntax, but what it did:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/timezone should be UTC&lt;/span&gt;
    &lt;span class="na"&gt;ansible.builtin.command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cat /etc/timezone&lt;/span&gt;
    &lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tz&lt;/span&gt;
    &lt;span class="na"&gt;changed_when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;failed_when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tz.stdout != "UTC"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A linter can tell you a playbook is well-formed; only actually converging it against a host and asserting on the result tells you it does what you think it does. Static analysis and behavioral testing catch disjoint sets of bugs — treating a passing lint as proof of correctness is a mistake regardless of language or tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the failure mode visible, not just handled
&lt;/h2&gt;

&lt;p&gt;Deploy success/failure is worth almost nothing as a signal if nobody sees it in context. Rather than invent a new metric for "a deploy happened," this project posts deploy attempts as annotations onto a dashboard that already exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Record this deploy as a Grafana annotation&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.uri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;grafana_url&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/api/annotations"&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;POST&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;deploy_event_text&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;environment_name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;deploy_event_tag&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;&lt;span class="pi"&gt;]}&lt;/span&gt;
  &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_deploy_grafana_annotate | default(true)&lt;/span&gt;
  &lt;span class="na"&gt;failed_when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;   &lt;span class="c1"&gt;# an unreachable dashboard must never fail a deploy over it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two design choices here matter beyond the specific tool: annotating an &lt;em&gt;existing&lt;/em&gt; CPU/memory dashboard means a rollback shows up as a visible marker on the same graph where its effects would be seen, instead of living in a separate "deploys" panel nobody checks. And &lt;code&gt;failed_when: false&lt;/code&gt; encodes an explicit priority order — observability is a consumer of the deploy, not a dependency of it. A monitoring outage should never be able to fail a deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  CI failures aren't always about your code
&lt;/h2&gt;

&lt;p&gt;Not every red build is a bug in the thing being tested — sometimes it's drift between two steps in the pipeline that only &lt;em&gt;look&lt;/em&gt; the same. While working on this project, one CI job's lint step failed consistently, and the cause turned out to be nothing about the YAML being linted: &lt;code&gt;ansible-lint&lt;/code&gt; shells out to &lt;code&gt;ansible-playbook --syntax-check&lt;/code&gt; internally, which needs the vault password just to parse a group_vars file — even though nothing in a syntax check touches the vaulted values. The pipeline step running the linter simply hadn't been given that credential, while every later step that actually deployed had.&lt;/p&gt;

&lt;p&gt;The general takeaway: when a CI step fails in a way that doesn't match the tool's stated job, check what else that tool does under the hood before assuming the input is wrong. And when you're debugging a step that runs inside a container with pinned tool versions, reproducing it with the &lt;em&gt;exact same versions&lt;/em&gt; locally turns a guessing game into a repeatable test — version drift between "works on my machine" and CI is its own separate failure mode, easy to rule out early if you pin first and debug second.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's deliberately not here
&lt;/h2&gt;

&lt;p&gt;Kubernetes, multi-cloud provisioning, and blue-green deploys are all out of scope on purpose. The goal was depth on one deploy pattern (rolling deploy + health-check-gated rollback, over SSH, onto host-like targets) rather than breadth across every pattern that exists. That's its own lesson: a project that tries to demonstrate everything ends up demonstrating nothing well — better to pick one path all the way through, including its failure modes, than to sketch three and finish none.&lt;/p&gt;




&lt;p&gt;The full project, architecture diagram, and README are at &lt;a href="https://github.com/ykpraveen/jenkins-ansible-sample" rel="noopener noreferrer"&gt;github.com/ykpraveen/jenkins-ansible-sample&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>ansible</category>
      <category>jenkins</category>
      <category>docker</category>
    </item>
    <item>
      <title>I Ran From EJB2 to Spring. Then I Went Back. Here's What I Found.</title>
      <dc:creator>Praveen Yadav</dc:creator>
      <pubDate>Tue, 21 Jul 2026 14:47:39 +0000</pubDate>
      <link>https://dev.to/ykpraveen/i-ran-from-ejb2-to-spring-then-i-went-back-heres-what-i-found-3n7f</link>
      <guid>https://dev.to/ykpraveen/i-ran-from-ejb2-to-spring-then-i-went-back-heres-what-i-found-3n7f</guid>
      <description>&lt;h2&gt;
  
  
  The Ghost of Deployment Descriptors Past
&lt;/h2&gt;

&lt;p&gt;A few weeks ago I sat down to build a small clinic-appointment backend on WildFly, just to scratch an itch and relearn Jakarta EE properly. Nothing dramatic — a REST API, a booking flow, some business rules, and a small Vue 3 frontend on top so I'd have something to click through while I tested it. The kind of thing I'd normally reach for Spring Boot to knock out in an afternoon.&lt;/p&gt;

&lt;p&gt;Except I didn't reach for Spring Boot. I reached for EJB.&lt;/p&gt;

&lt;p&gt;And the second I typed &lt;code&gt;@Stateless&lt;/code&gt; into an empty Java file, a very specific kind of dread crawled up my spine. Because the last time I wrote a line of EJB code with any seriousness, it was EJB 2.1, and it was not a good time.&lt;/p&gt;

&lt;p&gt;If you never lived through that era, let me set the scene. To write what should have been a five-line "create a customer" method, you needed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;home interface&lt;/strong&gt;, so the container could tell you how to create or find your bean.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;remote (or local) interface&lt;/strong&gt;, so callers could actually talk to it.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;bean implementation class&lt;/strong&gt; that had to implement lifecycle callbacks it almost never used (&lt;code&gt;ejbCreate&lt;/code&gt;, &lt;code&gt;ejbActivate&lt;/code&gt;, &lt;code&gt;ejbPassivate&lt;/code&gt;, &lt;code&gt;ejbRemove&lt;/code&gt; — hello darkness, my old friend).&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;XML deployment descriptor&lt;/strong&gt; (&lt;code&gt;ejb-jar.xml&lt;/code&gt;) wiring the interfaces to the implementation, declaring transaction attributes, and generally repeating everything you'd already written in Java, but in angle brackets.&lt;/li&gt;
&lt;li&gt;If you were unlucky enough to use CMP entity beans for persistence, a &lt;em&gt;second&lt;/em&gt; vendor-specific XML file mapping fields to columns, because the spec left enough gaps that every app server (WebLogic, WebSphere, JBoss) filled them in its own special way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A trivial CRUD operation could sprawl across five files. Container-Managed Persistence entity beans were so notoriously painful that "just use Hibernate directly and skip CMP entirely" was common, accepted advice — inside a spec whose entire job was persistence. People didn't leave Java EE because they hated Java. They left because EJB 2.x had turned "write a business object" into a small bureaucracy.&lt;/p&gt;

&lt;p&gt;So I did what a lot of people did: I left. Spring Framework showed up promising Plain Old Java Objects, dependency injection without a home interface in sight, and &lt;code&gt;@Transactional&lt;/code&gt; on a method instead of an XML block. I spent most of the following decade there — Spring, then Spring Boot, autoconfiguration, embedded Tomcat, &lt;code&gt;application.properties&lt;/code&gt; doing in one line what used to take a deployment descriptor. Somewhere in the last couple of years I also poked at Quarkus, drawn in by the "supersonic subatomic" pitch — sub-second live reload, GraalVM native images, the sense that the JVM startup tax was finally being paid down.&lt;/p&gt;

&lt;p&gt;So — genuine question, going into this clinic project — was I about to relive 2007? Or had EJB actually caught up while I wasn't looking?&lt;/p&gt;

&lt;h2&gt;
  
  
  First Fork in the Road: EJB 3 or EJB 4?
&lt;/h2&gt;

&lt;p&gt;Before writing a single bean, I had to answer a boring-sounding but surprisingly consequential question: which EJB version?&lt;/p&gt;

&lt;p&gt;Here's the short version of EJB's history, as I pieced it back together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;EJB 1.x / 2.x&lt;/strong&gt; (1998–2003ish): the bureaucracy described above. Home/remote interfaces, CMP entity beans, &lt;code&gt;javax.ejb.*&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EJB 3.0&lt;/strong&gt; (2006): the reset button. Annotations replaced deployment descriptors for the common case. &lt;code&gt;@Stateless&lt;/code&gt;, &lt;code&gt;@Stateful&lt;/code&gt;, &lt;code&gt;@MessageDriven&lt;/code&gt; beans became plain classes with an annotation on top. JPA was introduced as a completely new, much saner persistence model, explicitly designed to replace CMP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EJB 3.1&lt;/strong&gt; (2009): the &lt;em&gt;"you don't even need an interface"&lt;/em&gt; release — the no-interface view, plus &lt;code&gt;@Singleton&lt;/code&gt; beans and an embeddable container for testing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EJB 3.2&lt;/strong&gt; (2013): mostly pruning and cleanup — some of the crustier optional pieces (like the entity bean component contract, if you can believe it survived that long as "optional") were marked for removal, async session bean invocation got polished.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Jakarta rename&lt;/strong&gt; (2017–2019): Oracle handed Java EE to the Eclipse Foundation. Trademark reasons meant the &lt;code&gt;javax.*&lt;/code&gt; namespace couldn't keep being evolved under the old name, so the specs were rebranded &lt;strong&gt;Jakarta EE&lt;/strong&gt;, and eventually (Jakarta EE 9, 2020) every &lt;code&gt;javax.*&lt;/code&gt; package was mechanically renamed to &lt;code&gt;jakarta.*&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jakarta EJB 4.0&lt;/strong&gt; (2022, part of &lt;strong&gt;Jakarta EE 10&lt;/strong&gt;): the namespace flip finally lands &lt;em&gt;inside&lt;/em&gt; EJB itself (&lt;code&gt;jakarta.ejb.*&lt;/code&gt;), plus a genuine pruning pass — CMP/BMP entity beans and a few other legacy corners are gone for good. This is also the version this article — and the WildFly 35 sample project it's built on — actually uses.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the twist that surprised me: &lt;strong&gt;EJB 4.0 isn't a big conceptual leap from 3.2.&lt;/strong&gt; It's not "EJB 3 was good, EJB 4 reinvents it again." It's much closer to "EJB 3.2, tidied up, renamed, with the truly dead weight finally thrown out." If you already know &lt;code&gt;@Stateless&lt;/code&gt;, &lt;code&gt;@Stateful&lt;/code&gt;, and &lt;code&gt;@MessageDriven&lt;/code&gt;, you already know EJB 4.0. The import changes from &lt;code&gt;javax.ejb&lt;/code&gt; to &lt;code&gt;jakarta.ejb&lt;/code&gt;, a couple of legacy APIs vanish, and that's essentially the delta.&lt;/p&gt;

&lt;p&gt;Which made the actual decision easy: &lt;strong&gt;for anything new, there is no reason to target EJB 3.x today.&lt;/strong&gt; Modern app servers — WildFly, Payara, Open Liberty, TomEE — are all building against the &lt;code&gt;jakarta.*&lt;/code&gt; namespace now. Picking EJB 3.x for a new project in 2024+ would be deliberately choosing the legacy on-ramp. The only reason to stay on 3.x is that you're stuck maintaining something old that hasn't been ported yet — and that's a &lt;em&gt;migration&lt;/em&gt; problem, not a &lt;em&gt;new project&lt;/em&gt; decision.&lt;/p&gt;

&lt;p&gt;So: EJB 4.0, on WildFly 35, targeting Jakarta EE 10. Onward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Okay, But Is It Actually Nicer to Write Now?
&lt;/h2&gt;

&lt;p&gt;Enormously, yes. A &lt;code&gt;@Stateless&lt;/code&gt; service bean in 2024 looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Stateless&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CustomerManagementService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@PersistenceContext&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unitName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"customerMgmtPU"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;EntityManager&lt;/span&gt; &lt;span class="n"&gt;em&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Transactional&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Customer&lt;/span&gt; &lt;span class="nf"&gt;createCustomer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;clinicId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;fullName&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...validate, persist, return&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No home interface. No remote interface (unless you deliberately want a remote one, for actual distributed calls across JVMs — most people never do). No deployment descriptor for this. It's a plain class with two annotations, injected wherever it's needed via &lt;code&gt;@Inject&lt;/code&gt; or &lt;code&gt;@EJB&lt;/code&gt;, and the container hands you connection pooling, thread-safety (it manages a pool of instances so you never worry about concurrent calls to the same object), and transaction demarcation for free.&lt;/p&gt;

&lt;p&gt;If you've been living in Spring Boot, this will feel &lt;em&gt;extremely&lt;/em&gt; familiar, just with different spelling:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spring&lt;/th&gt;
&lt;th&gt;Jakarta EE / EJB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@Service&lt;/code&gt; / &lt;code&gt;@Component&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@Stateless&lt;/code&gt; (or a CDI &lt;code&gt;@ApplicationScoped&lt;/code&gt; bean)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@Autowired&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@Inject&lt;/code&gt; (CDI) or &lt;code&gt;@EJB&lt;/code&gt; (EJB-specific lookup)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@Transactional&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@Transactional&lt;/code&gt; (yes, actually the same annotation package in modern Jakarta EE — &lt;code&gt;jakarta.transaction.Transactional&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@RestController&lt;/code&gt; + &lt;code&gt;@GetMapping&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;JAX-RS &lt;code&gt;@Path&lt;/code&gt; + &lt;code&gt;@GET&lt;/code&gt; resource class&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spring Data repository&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@PersistenceContext EntityManager&lt;/code&gt; + JPQL (more manual, but the same underlying JPA)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mental model transfers almost one-to-one. Which, honestly, was the biggest surprise of this whole exercise — I expected to be relearning a foreign paradigm, and instead I was mostly relearning vocabulary.&lt;/p&gt;

&lt;p&gt;Where it stops feeling like Spring Boot is packaging and deployment. This project ships as an &lt;strong&gt;EAR&lt;/strong&gt; (Enterprise Archive) — a WAR for the REST layer plus several EJB JARs, one per business domain, all bundled together and deployed to a running WildFly instance. There's no embedded server, no single fat executable jar you &lt;code&gt;java -jar&lt;/code&gt; and go. You build, you drop the EAR into WildFly's &lt;code&gt;deployments/&lt;/code&gt; folder (or let a deployment scanner pick it up), and the server hosts it. Compared to Spring Boot's "it's just a jar" simplicity, or Quarkus's aggressive dev-mode hot reload, this is a heavier loop — more ceremony in the Maven multi-module setup, a real datasource to configure on the app server itself (WildFly's CLI, not a one-line &lt;code&gt;application.properties&lt;/code&gt;), and a slower edit-deploy-test cycle. That tradeoff is real, and it's the main thing that still feels dated. But it buys you something Spring Boot and Quarkus don't hand you automatically: a shared, centrally-managed runtime where multiple applications can be deployed side by side under one operations team's control — which is exactly the world a lot of regulated enterprises still live in, and exactly the use case EJB and application servers were built for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Message-Driven Beans: Async Without the Config Headache
&lt;/h2&gt;

&lt;p&gt;The part I'd genuinely half-forgotten existed: &lt;strong&gt;Message-Driven Beans&lt;/strong&gt;. An MDB is a bean that doesn't get invoked by a caller at all — it gets invoked by the container whenever a message shows up on a queue or topic. No REST call, no method call, just: message arrives, container spins up (or reuses) a pooled instance, and calls &lt;code&gt;onMessage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In the clinic project, every time an appointment is booked, cancelled, or rescheduled, the business-logic bean publishes a small JSON event onto a JMS queue. A separate MDB picks it up asynchronously and turns it into a durable audit-log entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@JMSDestinationDefinition&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"java:jboss/exported/jms/queue/AppointmentEvents"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;interfaceName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"jakarta.jms.Queue"&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@MessageDriven&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;activationConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;@ActivationConfigProperty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;propertyName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"destinationType"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;propertyValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"jakarta.jms.Queue"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="nd"&gt;@ActivationConfigProperty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;propertyName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"destination"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;propertyValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"java:jboss/exported/jms/queue/AppointmentEvents"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;})&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppointmentEventMDB&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;MessageListener&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onMessage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Message&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// decode the event, persist an audit entry&lt;/span&gt;
        &lt;span class="c1"&gt;// failures here don't block the original booking transaction&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What struck me is how little of this I had to configure by hand. The queue itself is declared right there in an annotation (&lt;code&gt;@JMSDestinationDefinition&lt;/code&gt;) — no separate resource-adapter XML, no manual JNDI binding step. The container manages the listener's thread pool, its transaction (each message delivery gets its own container-managed transaction by default), and its lifecycle. I didn't write a single line of "here's how many concurrent consumers to run" boilerplate.&lt;/p&gt;

&lt;p&gt;The closest things I've used since are Spring's &lt;code&gt;@JmsListener&lt;/code&gt; (functionally similar, a bit more configuration around the connection factory and listener container) and Quarkus's SmallRye Reactive Messaging (&lt;code&gt;@Incoming&lt;/code&gt;/&lt;code&gt;@Outgoing&lt;/code&gt;, which leans further into a reactive-streams style and is genuinely lovely for Kafka-shaped problems). MDBs are older, less flashy, and tied specifically to JMS rather than being transport-agnostic — but for "decouple this side-effect from the main transaction, and let the container deal with concurrency," it's still a remarkably small amount of code for what it does. The audit trail in this project can fail to write without ever rolling back the appointment booking that triggered it, purely because the two run in separate transactions, on separate threads, and neither one has to know the other exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stateful Session Beans: Conversation, Held By the Server
&lt;/h2&gt;

&lt;p&gt;The other bean type this project leans on, and the one I think is genuinely underrated: &lt;strong&gt;Stateful Session Beans&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;@Stateless&lt;/code&gt; bean is anonymous — any pooled instance can handle any call, because it holds no per-client state between invocations. A &lt;code&gt;@Stateful&lt;/code&gt; bean is the opposite: the container hands &lt;em&gt;you&lt;/em&gt; your own dedicated instance, and it remembers instance fields across multiple method calls, for as long as your conversation with it lasts.&lt;/p&gt;

&lt;p&gt;The clinic project uses this for a multi-step booking wizard. A REST client starts a session, then makes a sequence of calls — pick a doctor, pick a schedule, pick a time, add notes, confirm — without ever having to resend everything it already told the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Stateful&lt;/span&gt;
&lt;span class="nd"&gt;@ConcurrencyManagement&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ConcurrencyManagementType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONTAINER&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BookingSessionBean&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;clinicId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;doctorId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;scheduleId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;LocalTime&lt;/span&gt; &lt;span class="n"&gt;selectedTime&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;

    &lt;span class="nd"&gt;@Lock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LockType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;WRITE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;selectDoctor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;doctorId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;doctorId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;doctorId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;// reset anything that depended on the previous doctor&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Lock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LockType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;WRITE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@Remove&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Appointment&lt;/span&gt; &lt;span class="nf"&gt;confirmBooking&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;createdBy&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// uses everything accumulated across prior calls&lt;/span&gt;
        &lt;span class="c1"&gt;// @Remove: the container destroys this bean instance afterward&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each REST call in the wizard resolves to the &lt;em&gt;same&lt;/em&gt; bean instance (tracked via a session id, held in a small in-memory registry in this sample — in a real deployment, an app server can passivate an idle stateful bean to disk and reactivate it later, entirely transparently to the client). &lt;code&gt;@Lock(LockType.WRITE)&lt;/code&gt; tells the container "serialize concurrent calls to this instance," which matters because a stateful bean is, definitionally, shared mutable state scoped to one conversation. And &lt;code&gt;@Remove&lt;/code&gt; marks the method that ends the conversation — after &lt;code&gt;confirmBooking&lt;/code&gt; runs, the container discards the instance; there's nothing left to passivate.&lt;/p&gt;

&lt;p&gt;I don't think I'd reach for this every day — plenty of "multi-step form" problems are perfectly well solved by just... storing a draft row in the database and updating it, which is honestly the more portable, horizontally-scalable choice (this sample's in-memory registry, worth saying plainly, means an in-progress booking is lost if the server restarts — a real tradeoff, not a hidden one). But for a short-lived, single-server, "walk me through these five steps" conversation, having the container hold your state for you — with real concurrency control and a defined end-of-life hook — is a genuinely different tool than anything Spring or Quarkus hand you out of the box. Both of those ecosystems would push you toward an explicit session store (Redis, a database row, a signed client-side token) for the same problem; EJB just... has a bean type for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, EJB 2 Survivor's Verdict
&lt;/h2&gt;

&lt;p&gt;Coming back to this after a decade-plus of Spring and a Quarkus flirtation, here's where I landed:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EJB today is not EJB 2.&lt;/strong&gt; The bureaucracy is gone. Annotations replaced almost all of the XML. JPA replaced CMP with something people actually choose to use voluntarily. A &lt;code&gt;@Stateless&lt;/code&gt; service bean reads like a Spring &lt;code&gt;@Service&lt;/code&gt; with a different import line. If you know modern Spring or Quarkus, you already know 90% of the concepts here — dependency injection, declarative transactions, annotation-driven REST endpoints — you're just relearning the spelling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EJB 4.0 over EJB 3.x, no contest, for anything new.&lt;/strong&gt; The gap between them is a namespace flip and some cleanup, not a redesign — so there's no "wait and see" argument for staying on 3.x the way there might be for a genuinely risky major version bump. If your target server supports Jakarta EE 10 (WildFly 27+, recent Payara, recent Open Liberty), there's no reason to write against the older, &lt;code&gt;javax&lt;/code&gt;-based spec today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What still costs you, relative to Spring Boot or Quarkus, is the operational loop, not the programming model&lt;/strong&gt; — a real app server to configure, an EAR to build and deploy, a slower inner development cycle than Quarkus's live reload or Spring Boot's &lt;code&gt;java -jar&lt;/code&gt;. That's the genuine, non-nostalgic tradeoff, and it's worth knowing about before you pick this stack for a new project versus a mature one already living on an app server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the two bean types I'd half-forgotten — MDBs and Stateful Session Beans — turned out to be the most interesting part of revisiting this.&lt;/strong&gt; Not because they're better than a Kafka consumer or a Redis-backed session store, but because they solve the same problems with less code than I expected, when the container itself is a good enough fit for what you're building.&lt;/p&gt;

&lt;p&gt;If you want to poke at the actual code behind any of this — the full clinic-appointment backend, the MDB, the stateful booking wizard, the multi-module Maven setup, and the Vue 3 frontend that talks to it — it's on GitHub: &lt;a href="https://github.com/ykpraveen/ejb-wildfly-sample" rel="noopener noreferrer"&gt;ykpraveen/ejb-wildfly-sample&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I came in expecting to relive 2007. I left with a renovated opinion of a technology I'd written off. Turns out it wasn't EJB that aged badly — it was EJB 2.&lt;/p&gt;

</description>
      <category>javaee</category>
      <category>ejb</category>
      <category>springframework</category>
      <category>java</category>
    </item>
    <item>
      <title>From Spring Boot to Quarkus</title>
      <dc:creator>Praveen Yadav</dc:creator>
      <pubDate>Mon, 13 Jul 2026 12:24:00 +0000</pubDate>
      <link>https://dev.to/ykpraveen/from-spring-boot-to-quarkus-241g</link>
      <guid>https://dev.to/ykpraveen/from-spring-boot-to-quarkus-241g</guid>
      <description>&lt;h1&gt;
  
  
  What I Learned Building a Microservices System
&lt;/h1&gt;

&lt;p&gt;I have architected and developed many enterprise solutions using Spring Boot microservices for many years. Spring MVC, WebFlux, Integration, Batch, Data JPA, Security — that whole world is muscle memory. When I decided to try Quarkus, I expected it to be "Spring Boot on a diet." In some ways it is. In other ways, it is a genuinely different philosophy about what a Java framework should be.&lt;/p&gt;

&lt;p&gt;I built a small microservices system: an identity service, a catalog service, an order service, and an API gateway — all backed by a single PostgreSQL instance with schema-level isolation, containerized with Docker Compose, and secured with JWT. The project lives at &lt;a href="https://github.com/ykpraveen/quarkus-panache-sample" rel="noopener noreferrer"&gt;quarkus-panache-sample&lt;/a&gt;. Everything I describe below is drawn from that codebase.&lt;/p&gt;

&lt;p&gt;This is not a Quarkus tutorial. It is a literature of discovery — the things that surprised me, the things that frustrated me, and the things that made me reconsider where I reach for which framework.&lt;/p&gt;




&lt;h2&gt;
  
  
  The First Surprise: Dev Mode Is Actually Fast
&lt;/h2&gt;

&lt;p&gt;Spring Boot DevTools reloads the application context. It works, but when your project crosses a certain size, you wait. Quarkus takes a different path: build-time processing. Annotations are processed at compile time, not runtime. Reflection is minimized. CDI beans are wired ahead of time.&lt;/p&gt;

&lt;p&gt;The result is a development loop that genuinely feels iterative. &lt;code&gt;./mvnw quarkus:dev&lt;/code&gt; starts in under a second after the first build. Hot reload of Java classes, resources, and configurations happens without restarting the JVM. Spring Boot DevTools works, but I never felt the feedback loop was fast enough to keep flow state. Quarkus gets close.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# Catalog service config — Quarkus reads this at build time
&lt;/span&gt;&lt;span class="py"&gt;quarkus.http.port&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${QUARKUS_HTTP_PORT:8082}&lt;/span&gt;
&lt;span class="py"&gt;quarkus.datasource.jdbc.url&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${DB_URL:jdbc:postgresql://localhost:5432/platform}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;${VAR:default}&lt;/code&gt; interpolation works the same as Spring's, but the processing model is fundamentally different. Quarkus resolves what it can at compile time. Spring Boot evaluates at startup, which gives you more dynamism but costs you startup time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Panache vs Spring Data JPA: Same Destination, Different Vehicle
&lt;/h2&gt;

&lt;p&gt;This was the most interesting comparison for me. Spring Data JPA uses the repository pattern: you define an interface, Spring generates the implementation. Quarkus offers two approaches through Panache: the &lt;strong&gt;active-record pattern&lt;/strong&gt; (entity extends &lt;code&gt;PanacheEntityBase&lt;/code&gt;) and the &lt;strong&gt;repository pattern&lt;/strong&gt; (class implements &lt;code&gt;PanacheRepository&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;I chose active record for this project. Here is a product entity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="nd"&gt;@Table&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"products"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"catalog"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductEntity&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;PanacheEntityBase&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Id&lt;/span&gt;
    &lt;span class="nd"&gt;@GeneratedValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strategy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GenerationType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SEQUENCE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;generator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"catalog_products_seq_gen"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@SequenceGenerator&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"catalog_products_seq_gen"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"catalog"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                       &lt;span class="n"&gt;sequenceName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"products_seq"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allocationSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;160&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;precision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// ... more fields&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProductEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findActive&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT p FROM ProductEntity p LEFT JOIN FETCH p.category WHERE p.deletedAt IS NULL"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;list&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here is the service that uses it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ApplicationScoped&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CatalogService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Bulkhead&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ProductResponse&lt;/span&gt; &lt;span class="nf"&gt;getProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;ProductEntity&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ProductEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDeletedAt&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ApiException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NOT_FOUND&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Product not found"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;toResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Bulkhead&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@RateLimit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;windowUnit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChronoUnit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SECONDS&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@Transactional&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ProductResponse&lt;/span&gt; &lt;span class="nf"&gt;createProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CreateProductRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;persist&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;toResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ProductEntity.findById(productId)&lt;/code&gt; is a static method. &lt;code&gt;product.persist()&lt;/code&gt; is an instance method. No &lt;code&gt;@Autowired JpaRepository&amp;lt;ProductEntity, Long&amp;gt; repository&lt;/code&gt;. The entity &lt;em&gt;is&lt;/em&gt; the data access object. In Spring Data JPA, the repository interface sits between the service and the entity. In Panache's active-record mode, that layer disappears entirely.&lt;/p&gt;

&lt;p&gt;I had mixed feelings about this. For simple CRUD, active record is concise and readable. For complex queries, it still supports the same HQL/JPQL you already know. But if you are used to the repository abstraction for testability and separation of concerns, Panache's repository mode is there too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ApplicationScoped&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductRepository&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;PanacheRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProductEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProductEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findActive&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"deletedAt IS NULL"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;list&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I could have used this pattern instead. I chose active record because I wanted the full Quarkus experience. Both are well documented at &lt;a href="https://quarkus.io/guides/hibernate-orm-panache" rel="noopener noreferrer"&gt;Panache&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  REST Resources: JAX-RS Feels Like Coming Home
&lt;/h2&gt;

&lt;p&gt;Spring Boot uses &lt;code&gt;@RestController&lt;/code&gt;, &lt;code&gt;@RequestMapping&lt;/code&gt;, &lt;code&gt;@GetMapping&lt;/code&gt;. Quarkus uses JAX-RS: &lt;code&gt;@Path&lt;/code&gt;, &lt;code&gt;@GET&lt;/code&gt;, &lt;code&gt;@POST&lt;/code&gt;. If you have ever worked with JAX-RS before Spring Boot took over the world, this is familiar ground.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/v1/catalog"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Consumes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;APPLICATION_JSON&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Produces&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;APPLICATION_JSON&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CatalogResource&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Inject&lt;/span&gt;
    &lt;span class="nc"&gt;CatalogService&lt;/span&gt; &lt;span class="n"&gt;catalogService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@POST&lt;/span&gt;
    &lt;span class="nd"&gt;@Path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/products"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@RolesAllowed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ADMIN"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt; &lt;span class="nf"&gt;createProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Valid&lt;/span&gt; &lt;span class="nc"&gt;CreateProductRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;ProductResponse&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;catalogService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CREATED&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;entity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@GET&lt;/span&gt;
    &lt;span class="nd"&gt;@Path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/products/{productId}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@RolesAllowed&lt;/span&gt;&lt;span class="o"&gt;({&lt;/span&gt;&lt;span class="s"&gt;"USER"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ADMIN"&lt;/span&gt;&lt;span class="o"&gt;})&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ProductResponse&lt;/span&gt; &lt;span class="nf"&gt;getProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathParam&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"productId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;catalogService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@Inject&lt;/code&gt; instead of &lt;code&gt;@Autowired&lt;/code&gt;. &lt;code&gt;jakarta.ws.rs.core.Response&lt;/code&gt; instead of &lt;code&gt;ResponseEntity&lt;/code&gt;. &lt;code&gt;@PathParam&lt;/code&gt; instead of &lt;code&gt;@PathVariable&lt;/code&gt;. The mapping is nearly one-to-one. Spring Boot drew from the same well — Spring MVC was inspired by JAX-RS, and over time the industry converged on Jakarta EE standards. Quarkus bet on the standard directly.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;@RolesAllowed&lt;/code&gt; annotation is the Jakarta EE security standard. In Spring Boot, you would write &lt;code&gt;@PreAuthorize("hasRole('ADMIN')")&lt;/code&gt;. It accomplishes the same thing. The difference is that &lt;code&gt;@RolesAllowed&lt;/code&gt; works with any Jakarta-compatible security implementation, not just Spring Security.&lt;/p&gt;




&lt;h2&gt;
  
  
  Exception Mapping Instead of Controller Advice
&lt;/h2&gt;

&lt;p&gt;Spring Boot uses &lt;code&gt;@ControllerAdvice&lt;/code&gt; with &lt;code&gt;@ExceptionHandler&lt;/code&gt; for centralized error handling. Quarkus uses JAX-RS &lt;code&gt;ExceptionMapper&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Provider&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApiExceptionMapper&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ExceptionMapper&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ApiException&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt; &lt;span class="nf"&gt;toResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ApiException&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;APPLICATION_JSON&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;entity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ApiError&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getStatusCode&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
                    &lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@Provider&lt;/code&gt; annotation registers the mapper automatically. No explicit configuration needed. I found this cleaner than Spring's approach, where you typically need to remember to add &lt;code&gt;@ControllerAdvice&lt;/code&gt; to a class and annotate individual methods. The tradeoff is that &lt;code&gt;ExceptionMapper&lt;/code&gt; operates at the JAX-RS layer, not the HTTP layer, which means it catches exceptions from JAX-RS resource methods but not filters. For most services, that is sufficient. The &lt;a href="https://quarkus.io/guides/resteasy-reactive#exception-mapping" rel="noopener noreferrer"&gt;Quarkus documentation on error handling&lt;/a&gt; covers the full picture.&lt;/p&gt;




&lt;h2&gt;
  
  
  The API Gateway: Vert.x Routes Instead of Spring Cloud Gateway
&lt;/h2&gt;

&lt;p&gt;This was the most alien part. Spring Cloud Gateway uses a builder DSL or YAML configuration for routes. Quarkus offers &lt;a href="https://quarkus.io/guides/reactive-routes" rel="noopener noreferrer"&gt;Vert.x Web routes&lt;/a&gt; as a first-class feature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ApplicationScoped&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReactiveGatewayRoutes&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Route&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/api/v1/identity/*"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HttpMethod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;GET&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@Route&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/api/v1/identity/*"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HttpMethod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;POST&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RoutingContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;identityBaseUrl&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Route&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/api/v1/catalog/*"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HttpMethod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;GET&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;catalogGet&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RoutingContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requireRoles&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"USER"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ADMIN"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;catalogBaseUrl&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Route&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/api/v1/orders"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HttpMethod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;POST&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;ordersReadWrite&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RoutingContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requireRoles&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"USER"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ADMIN"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orderBaseUrl&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@Route&lt;/code&gt; is processed at build time into Vert.x route handlers. The &lt;code&gt;RoutingContext&lt;/code&gt; gives you access to the request, response, headers, and body — all reactive. Proxying is done via &lt;code&gt;WebClient&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RoutingContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;baseUrl&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestAbs&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseUrl&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;targetPath&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Authorization"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;putHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Authorization"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;correlationId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"correlationId"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;correlationId&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;putHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-Correlation-Id"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;correlationId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onSuccess&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;sendResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onFailure&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;sendError&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I could have used Spring Cloud Gateway instead, but that would have meant running a separate Spring Boot application alongside the Quarkus services. Vert.x routes let me keep everything in one Quarkus binary. The tradeoff is that I had to write the proxying logic myself — Spring Cloud Gateway gives you &lt;code&gt;uri: http://identity-service&lt;/code&gt; in YAML.&lt;/p&gt;




&lt;h2&gt;
  
  
  REST Clients and Fault Tolerance: MicroProfile Over Feign
&lt;/h2&gt;

&lt;p&gt;For inter-service communication, Spring Boot developers reach for &lt;code&gt;@FeignClient&lt;/code&gt; (from Spring Cloud OpenFeign) or &lt;code&gt;WebClient&lt;/code&gt;. Quarkus uses MicroProfile REST Client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/v1/catalog/products"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@RegisterRestClient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;configKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"catalog-api"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;CatalogProductClient&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@GET&lt;/span&gt;
    &lt;span class="nd"&gt;@Path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/{id}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@Bulkhead&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@CircuitBreaker&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requestVolumeThreshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failureRatio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delayUnit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChronoUnit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MILLIS&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;CatalogProductResponse&lt;/span&gt; &lt;span class="nf"&gt;getProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathParam&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@RegisterRestClient&lt;/code&gt; annotation tells Quarkus to generate the implementation. The &lt;code&gt;@Bulkhead&lt;/code&gt; and &lt;code&gt;@CircuitBreaker&lt;/code&gt; annotations come from MicroProfile Fault Tolerance, not Resilience4j. They work the same way — configure limits, thresholds, delays — but they are specified by a Jakarta EE standard rather than a Spring-specific library.&lt;/p&gt;

&lt;p&gt;I could have used Resilience4j directly (it has no Spring dependency), but the MicroProfile annotations integrate natively with Quarkus's metadata scanning and build-time processing. The &lt;a href="https://download.eclipse.org/microprofile/microprofile-fault-tolerance-4.0/microprofile-fault-tolerance-spec-4.0.html" rel="noopener noreferrer"&gt;MicroProfile Fault Tolerance specification&lt;/a&gt; documents the full annotation set.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tests: @QuarkusTest vs @SpringBootTest
&lt;/h2&gt;

&lt;p&gt;Tests in Quarkus use &lt;code&gt;@QuarkusTest&lt;/code&gt;. The equivalent of &lt;code&gt;@WithMockUser&lt;/code&gt; is &lt;code&gt;@TestSecurity&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@QuarkusTest&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CatalogResourceTest&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="nd"&gt;@TestSecurity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"admin"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"ADMIN"&lt;/span&gt;&lt;span class="o"&gt;})&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;fullCrud&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// REST Assured calls against the running Quarkus instance&lt;/span&gt;
        &lt;span class="n"&gt;given&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ContentType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;JSON&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{\"name\":\"Electronics\"}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;post&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/v1/catalog/categories"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;then&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;listProductsUnauthenticated&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;given&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/v1/catalog/products"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;then&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;REST Assured is the default HTTP test client (Spring Boot also supports it, but &lt;code&gt;TestRestTemplate&lt;/code&gt; is the traditional choice). Mocking CDI beans uses &lt;code&gt;@InjectMock&lt;/code&gt; (Quarkus-specific), which is equivalent to Spring's &lt;code&gt;@MockBean&lt;/code&gt;. The test framework boots Quarkus once per test class, not per method, which keeps test runs fast.&lt;/p&gt;

&lt;p&gt;I did not use &lt;a href="https://quarkus.io/guides/mockito" rel="noopener noreferrer"&gt;Quarkus Mockito configuration&lt;/a&gt;, but the project includes &lt;code&gt;quarkus-junit5-mockito&lt;/code&gt; for that purpose. The order service tests use &lt;code&gt;@InjectMock&lt;/code&gt; to simulate the catalog REST client.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Memory Problem That Started This All
&lt;/h2&gt;

&lt;p&gt;Building native images with GraalVM is memory-intensive. Each Quarkus service consumes 4–16 GB of RAM during compilation. Starting all four at once with &lt;code&gt;docker compose -f docker-compose.yml -f docker-compose.native.yml up --build&lt;/code&gt; can overwhelm a development machine.&lt;/p&gt;

&lt;p&gt;The solution is incremental builds — one service at a time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.yml &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.native.yml build identity-service
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.yml &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.native.yml build catalog-service
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.yml &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.native.yml build order-service
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.yml &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.native.yml build api-gateway
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or skip compose and build the Docker image directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-f&lt;/span&gt; identity-service/Dockerfile.native &lt;span class="nt"&gt;-t&lt;/span&gt; identity-service &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The native Dockerfiles in this project use the &lt;code&gt;quay.io/quarkus/ubi9-quarkus-mandrel-builder-image&lt;/code&gt; with Mandrel (the GraalVM distribution maintained by the Quarkus team). The multi-stage build pattern mirrors what you would do for any compiled language — build in a heavy image, run in a minimal one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;quay.io/quarkus/ubi9-quarkus-mandrel-builder-image:jdk-21&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="c"&gt;# ... copy sources, compile with -Dquarkus.native.enabled=true&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; quay.io/quarkus/ubi9-quarkus-micro-image:2.0&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /work/&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /code/api-gateway/target/*-runner /work/application&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["./application", "-Dquarkus.http.host=0.0.0.0"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resulting image is around 130 MB — comparable to a Go binary container. Startup time is under 100 milliseconds. If you have ever watched a Spring Boot application struggle through classpath scanning on a cold start in Kubernetes, you understand why this matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I Still Reach for Spring Boot
&lt;/h2&gt;

&lt;p&gt;I built this system to learn Quarkus. I chose Quarkus for this project deliberately. But I am not abandoning Spring Boot. Here is where I still reach for Spring Boot:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Large teams with established conventions.&lt;/strong&gt; Spring Boot has been the dominant enterprise Java framework for over a decade. If you are onboarding developers who know Spring Boot but not JAX-RS or CDI, the learning curve for Quarkus — subtle as it is — still exists. Spring Boot's documentation ecosystem is unmatched. The Stack Overflow corpus for Spring Boot is vast. Quarkus is catching up, but it is not there yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Existing Spring ecosystem investments.&lt;/strong&gt; If you already use Spring Cloud Config, Spring Cloud Gateway, Spring Security OAuth2, Spring Batch, or Spring Cloud Data Flow, replacing individual components with Quarkus equivalents requires rewriting infrastructure code. The return on investment diminishes unless you also need the startup time or memory improvements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Projects that need maximum library compatibility.&lt;/strong&gt; Quarkus works by processing bytecode at build time. Most libraries work, but some use reflection, dynamic class loading, or runtime bytecode generation in ways that break the Quarkus build model. Spring Boot imposes no such restrictions — if a library runs on the JVM, it runs in Spring Boot. The &lt;a href="https://quarkus.io/extensions/" rel="noopener noreferrer"&gt;Quarkus extension ecosystem&lt;/a&gt; is growing, but it is not comprehensive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monoliths that are not in containers.&lt;/strong&gt; Quarkus's killer features — sub-second startup, low memory footprint, native compilation — matter most in containerized environments where you pay for idle. If you are deploying a monolith to a traditional server, Spring Boot's startup time is a one-time cost, and memory is cheaper than migration effort.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I Choose Quarkus Going Forward
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Serverless and function-as-a-service workloads.&lt;/strong&gt; Cold start is the enemy of serverless Java. Quarkus native images start in milliseconds. I have seen Spring Boot functions take 5–10 seconds to initialize on AWS Lambda cold starts. That is a non-starter for latency-sensitive APIs. Quarkus eliminates the problem entirely. The &lt;a href="https://quarkus.io/guides/amazon-lambda-http" rel="noopener noreferrer"&gt;Quarkus AWS Lambda guide&lt;/a&gt; documents the integration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Microservices where density matters.&lt;/strong&gt; If you are running twenty microservices on a Kubernetes node with 8 GB of RAM, each one needs to be small. A Quarkus native binary uses 20–50 MB of RSS. A Spring Boot JAR on a JVM with the same service typically uses 200–400 MB. That is the difference between fitting on one node and needing three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;High-iteration development loops.&lt;/strong&gt; I found the Quarkus dev mode genuinely faster — not incrementally faster, but categorically faster — than Spring Boot DevTools. If your team is doing rapid prototyping or frequent refactoring, the reduced cycle time adds up. Hot reload in Quarkus is sub-second for most changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Projects where build-time validation catches errors.&lt;/strong&gt; Quarkus validates configuration and injection points at compile time, not at startup. A misconfigured &lt;code&gt;@ConfigProperty&lt;/code&gt; or a missing bean dependency fails the build, not a production deployment. This is a cultural shift — you catch errors earlier in the pipeline — but it changes how you think about configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Greenfield projects with a preference for standards over frameworks.&lt;/strong&gt; JAX-RS, CDI, MicroProfile JWT, MicroProfile Fault Tolerance, and MicroProfile REST Client are Jakarta EE and MicroProfile standards. They are not tied to a single vendor or framework. If you value portability across runtimes, the standard-oriented approach is appealing. Spring Boot has been moving toward standards as well (Spring Data JPA is JPA, Spring Security supports OAuth2 standards), but Quarkus starts from the standard and builds out.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Decision Matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criteria&lt;/th&gt;
&lt;th&gt;Spring Boot&lt;/th&gt;
&lt;th&gt;Quarkus&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Startup time&lt;/td&gt;
&lt;td&gt;Seconds to minutes&lt;/td&gt;
&lt;td&gt;Milliseconds (JVM) / microseconds (native)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory (idle)&lt;/td&gt;
&lt;td&gt;200–400 MB per service&lt;/td&gt;
&lt;td&gt;20–50 MB per service (native)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dev iteration speed&lt;/td&gt;
&lt;td&gt;Fast (DevTools)&lt;/td&gt;
&lt;td&gt;Faster (build-time processing + hot reload)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Library ecosystem&lt;/td&gt;
&lt;td&gt;Comprehensive&lt;/td&gt;
&lt;td&gt;Growing (extensions model)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standards alignment&lt;/td&gt;
&lt;td&gt;Spring conventions&lt;/td&gt;
&lt;td&gt;Jakarta EE + MicroProfile specifications&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Learning curve for Spring devs&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Low (mapping is nearly one-to-one)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serverless / FaaS&lt;/td&gt;
&lt;td&gt;Struggles with cold start&lt;/td&gt;
&lt;td&gt;First-class&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes density&lt;/td&gt;
&lt;td&gt;Needs more resources&lt;/td&gt;
&lt;td&gt;Efficient&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Documentation corpus&lt;/td&gt;
&lt;td&gt;Massive&lt;/td&gt;
&lt;td&gt;Good and improving&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build-time validation&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;Extensive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-module Maven/Gradle&lt;/td&gt;
&lt;td&gt;Well supported&lt;/td&gt;
&lt;td&gt;Well supported&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What I Used and What I Could Have Used
&lt;/h2&gt;

&lt;p&gt;The project uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Quarkus 3.15.2&lt;/strong&gt; with &lt;a href="https://quarkus.io/guides/resteasy-reactive" rel="noopener noreferrer"&gt;RESTEasy Reactive&lt;/a&gt; for the REST layer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hibernate ORM with Panache&lt;/strong&gt; for persistence, active-record mode (&lt;a href="https://quarkus.io/guides/hibernate-orm-panache" rel="noopener noreferrer"&gt;Panache documentation&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flyway&lt;/strong&gt; for schema migrations (&lt;a href="https://quarkus.io/guides/flyway" rel="noopener noreferrer"&gt;Quarkus Flyway guide&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SmallRye JWT&lt;/strong&gt; for token issuance and validation (&lt;a href="https://smallrye.io/smallrye-jwt/" rel="noopener noreferrer"&gt;SmallRye JWT documentation&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MicroProfile REST Client&lt;/strong&gt; for inter-service HTTP calls (&lt;a href="https://quarkus.io/guides/rest-client" rel="noopener noreferrer"&gt;REST Client guide&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MicroProfile Fault Tolerance&lt;/strong&gt; (&lt;code&gt;@Bulkhead&lt;/code&gt;, &lt;code&gt;@RateLimit&lt;/code&gt;, &lt;code&gt;@CircuitBreaker&lt;/code&gt;) for resilience (&lt;a href="https://quarkus.io/guides/smallrye-fault-tolerance" rel="noopener noreferrer"&gt;Fault Tolerance guide&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vert.x Web routes&lt;/strong&gt; for the API gateway (&lt;a href="https://quarkus.io/guides/reactive-routes" rel="noopener noreferrer"&gt;Reactive Routes guide&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Micrometer + Prometheus&lt;/strong&gt; for metrics (&lt;a href="https://quarkus.io/guides/micrometer" rel="noopener noreferrer"&gt;Micrometer guide&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SmallRye OpenAPI / Swagger UI&lt;/strong&gt; for API documentation (&lt;a href="https://quarkus.io/guides/openapi-swaggerui" rel="noopener noreferrer"&gt;OpenAPI guide&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hibernate Validator&lt;/strong&gt; for bean validation (&lt;a href="https://quarkus.io/guides/validation" rel="noopener noreferrer"&gt;Validation guide&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quarkus Test&lt;/strong&gt; with REST Assured and &lt;code&gt;@TestSecurity&lt;/code&gt; for integration tests (&lt;a href="https://quarkus.io/guides/getting-started-testing" rel="noopener noreferrer"&gt;Testing guide&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Compose&lt;/strong&gt; with multi-stage native-image Dockerfiles (&lt;a href="https://quarkus.io/guides/building-native-image" rel="noopener noreferrer"&gt;Building Native Images guide&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I could have used instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PanacheRepository&lt;/strong&gt; instead of active-record mode if I wanted the repository abstraction&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring Cloud Gateway&lt;/strong&gt; instead of Vert.x routes if I preferred YAML-based routing configuration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resilience4j&lt;/strong&gt; instead of MicroProfile Fault Tolerance if I wanted to stay closer to the Spring ecosystem&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gradle&lt;/strong&gt; instead of Maven (the Quarkus plugin supports both equally)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testcontainers&lt;/strong&gt; via &lt;code&gt;@QuarkusTestResource&lt;/code&gt; for integration tests against real databases (&lt;a href="https://quarkus.io/guides/testcontainers" rel="noopener noreferrer"&gt;Testcontainers guide&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keycloak&lt;/strong&gt; as an external identity provider instead of issuing JWTs internally (the README documents this as a planned migration path)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Quarkus does not replace Spring Boot. It complements it. Each framework has a strength profile, and the profiles overlap in the middle. If you are building a monolith with a well-known architecture, deploy to traditional servers, and have a team that knows Spring Boot — stay with Spring Boot. If you are building microservices for Kubernetes, caring about startup time, memory budget, and iteration speed — Quarkus is worth the investment.&lt;/p&gt;

&lt;p&gt;The migration from Spring Boot to Quarkus is not a rewrite. It is a translation. The concepts map one-to-one. The annotations change names. The configuration keys change prefixes. But the patterns — dependency injection, REST endpoints, JPA persistence, JWT security, Flyway migrations — are the same patterns you already use.&lt;/p&gt;

&lt;p&gt;I built this project to learn, and I learned that Java frameworks are converging on common patterns faster than the community rhetoric suggests. The gap between Spring Boot and Quarkus is smaller than the gap between either framework and the previous generation of Java enterprise frameworks. We are all moving in the same direction.&lt;/p&gt;

&lt;p&gt;The full source code is available at &lt;a href="https://github.com/ykpraveen/quarkus-panache-sample" rel="noopener noreferrer"&gt;github.com/ykpraveen/quarkus-panache-sample&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>quarkus</category>
      <category>springboot</category>
      <category>java</category>
      <category>developers</category>
    </item>
    <item>
      <title>Redis 8 in Practice: Building a Full-Stack Movie Library with Search, JSON, Time Series, and Real API Workloads</title>
      <dc:creator>Praveen Yadav</dc:creator>
      <pubDate>Wed, 01 Jul 2026 09:53:21 +0000</pubDate>
      <link>https://dev.to/ykpraveen/redis-8-in-practice-building-a-full-stack-movie-library-with-search-json-time-series-and-real-3ano</link>
      <guid>https://dev.to/ykpraveen/redis-8-in-practice-building-a-full-stack-movie-library-with-search-json-time-series-and-real-3ano</guid>
      <description>&lt;p&gt;I have used Redis in production for years. In a previous role, our stack used Redis 6 on Azure Cache for Redis with a Spring Boot backend and Jedis. It worked, but advanced capabilities often came with extra decisions around cost, packaging, and service tier selection.&lt;/p&gt;

&lt;p&gt;Looking back, that tradeoff may also help explain some of the platform direction we are seeing now, including the move toward Azure Managed Redis and a clearer separation in positioning and capabilities.&lt;/p&gt;

&lt;p&gt;If we wanted richer search behavior, that typically pushed us toward higher service tiers and additional operational planning. For side projects and experiments, that friction was enough to keep many ideas in the "maybe later" bucket.&lt;/p&gt;

&lt;p&gt;That context is why this project exists.&lt;/p&gt;

&lt;p&gt;Redis is often introduced as "just a cache," but that framing misses how far the platform has evolved. In this project, I treated Redis 8 as the primary operational data engine for a full-stack movie application: document storage, full-text search, aggregations, and time-series telemetry.&lt;/p&gt;

&lt;p&gt;The result is a practical reference implementation, not a toy script. The app supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JSON-backed movie records&lt;/li&gt;
&lt;li&gt;Full-text and faceted search&lt;/li&gt;
&lt;li&gt;Numeric filtering and sorting&lt;/li&gt;
&lt;li&gt;Aggregation dashboards&lt;/li&gt;
&lt;li&gt;Time-series event tracking&lt;/li&gt;
&lt;li&gt;CRUD workflows in a React UI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This article is deliberately verbose and implementation-heavy, but with a practical story arc: what was painful before, what changed in Redis 8, and what that change looks like in a real app.&lt;/p&gt;

&lt;p&gt;If you want the full project, the sample repo is here: &lt;a href="https://github.com/ykpraveen/rediseach-sample.git" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I used to treat Redis primarily as a fast key-value/cache layer in Redis 6 era workloads.&lt;/li&gt;
&lt;li&gt;Redis 8 made it easier to approach Redis as a multi-model operational data backend.&lt;/li&gt;
&lt;li&gt;I built a Movie Library app to test this directly with RediSearch, RedisJSON, and RedisTimeSeries.&lt;/li&gt;
&lt;li&gt;The result: one Redis service powers CRUD, full-text search, analytics, and time-series event tracking.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why This Was a Big Shift for Me
&lt;/h2&gt;

&lt;p&gt;Coming from Redis 6 + managed cloud usage, I was used to a split between "simple Redis usage" and "advanced Redis usage." The second path usually meant more planning around feature availability, pricing, and platform choices.&lt;/p&gt;

&lt;p&gt;For teams with budget and clear production requirements, that can be reasonable. For learning, prototyping, and internal tools, it can be a blocker.&lt;/p&gt;

&lt;p&gt;With this project, I wanted to test whether Redis 8 reduced that friction enough to change day-to-day developer behavior.&lt;/p&gt;

&lt;p&gt;In practice, the setup is intentionally simple: one Redis 8 container, one Node.js API container, and one React frontend container. If you want to inspect the exact Compose and Docker configuration, it is easier to browse it directly in the &lt;a href="https://github.com/ykpraveen/rediseach-sample" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt; than to repeat it inline here.&lt;/p&gt;

&lt;p&gt;No custom module loading is required in this setup. The backend starts, connects, creates a search index, and serves traffic. Operationally, that dramatically improves first-run experience for learning projects and demos, especially compared with the "figure out modules first, build app second" workflow many of us had before.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Build: A Full-Stack Movie Library
&lt;/h2&gt;

&lt;p&gt;This is a three-container architecture:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;redis&lt;/code&gt; (port &lt;code&gt;6379&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;backend&lt;/code&gt; Node.js + Express API (port &lt;code&gt;3001&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;frontend&lt;/code&gt; React + Vite app (port &lt;code&gt;5173&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Backend stack details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;express&lt;/code&gt; for API routes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;redis&lt;/code&gt; official &lt;code&gt;node-redis&lt;/code&gt; client (&lt;code&gt;^4.7.0&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;zod&lt;/code&gt; for request/query validation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;express-rate-limit&lt;/code&gt; for traffic throttling&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;morgan&lt;/code&gt; for request logging&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Frontend stack details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React 19&lt;/li&gt;
&lt;li&gt;React Router 7&lt;/li&gt;
&lt;li&gt;Redux Toolkit&lt;/li&gt;
&lt;li&gt;Recharts for charts&lt;/li&gt;
&lt;li&gt;Tailwind CSS + shadcn/ui components&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  RedisJSON, CRUD, and Search UX
&lt;/h2&gt;

&lt;p&gt;I started with the baseline product loop every app needs: create, read, update, delete, then make discovery pleasant with search and filters.&lt;/p&gt;

&lt;p&gt;One early lesson: my first search experience felt "technically correct" but practically flat. Results came back, but relevance was not great for title-heavy queries. Giving &lt;code&gt;title&lt;/code&gt; a higher search weight immediately improved that, and it reminded me that search quality is mostly about thoughtful schema and scoring choices, not just endpoint wiring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data Model: RedisJSON as the Source of Truth
&lt;/h2&gt;

&lt;p&gt;Each movie is stored as a JSON document at key pattern &lt;code&gt;movie:{id}&lt;/code&gt; using RedisJSON. A typical record includes core metadata such as title, plot, genres, year, rating, votes, cast, director, runtime, language, poster, and tags. The exact seed data and JSON shape are available in the &lt;a href="https://github.com/ykpraveen/rediseach-sample" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The backend seed process loads real movie records from &lt;code&gt;movies.json&lt;/code&gt;, then generates synthetic titles, plots, cast lists, and metadata until the dataset reaches &lt;strong&gt;500 movies&lt;/strong&gt;. That larger cardinality makes filtering, sorting, and aggregation behavior more visible than a tiny static set, which is important if you want search and dashboard behavior to feel believable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Search Design: How It Actually Works
&lt;/h2&gt;

&lt;p&gt;On startup, the API creates RediSearch index &lt;code&gt;idx:movies&lt;/code&gt; over JSON documents with prefix &lt;code&gt;movie:&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Field mapping used by the app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;$.title&lt;/code&gt; -&amp;gt; &lt;code&gt;TEXT&lt;/code&gt; (&lt;code&gt;WEIGHT 2&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$.plot&lt;/code&gt; -&amp;gt; &lt;code&gt;TEXT&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$.director&lt;/code&gt; -&amp;gt; &lt;code&gt;TEXT&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$.cast[*]&lt;/code&gt; -&amp;gt; &lt;code&gt;TEXT&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$.genres[*]&lt;/code&gt; -&amp;gt; &lt;code&gt;TAG&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$.tags[*]&lt;/code&gt; -&amp;gt; &lt;code&gt;TAG&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$.language&lt;/code&gt; -&amp;gt; &lt;code&gt;TAG&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$.year&lt;/code&gt; -&amp;gt; &lt;code&gt;NUMERIC SORTABLE&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$.rating&lt;/code&gt; -&amp;gt; &lt;code&gt;NUMERIC SORTABLE&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$.votes&lt;/code&gt; -&amp;gt; &lt;code&gt;NUMERIC SORTABLE&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two practical implications:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Weighting &lt;code&gt;title&lt;/code&gt; higher than &lt;code&gt;plot&lt;/code&gt; improves relevance for title-driven queries.&lt;/li&gt;
&lt;li&gt;Marking numeric fields sortable enables efficient server-side ordering for UX controls like "top rated" or "newest first".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I also found that getting sortable numeric fields right up front saved rework later. In earlier projects, I had deferred sorting strategy and paid for it with awkward API changes once product requirements became concrete.&lt;/p&gt;

&lt;h2&gt;
  
  
  API Surface and Behavior
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Health and operational endpoints
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GET /health&lt;/code&gt; pings Redis and returns service state.&lt;/li&gt;
&lt;li&gt;Every non-health request increments &lt;code&gt;ts:activity&lt;/code&gt; using RedisTimeSeries for global API throughput telemetry.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  CRUD endpoints
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;POST /movies&lt;/code&gt; creates a record with generated &lt;code&gt;tt...&lt;/code&gt; style ID.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /movies/:id&lt;/code&gt; fetches one movie.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PUT /movies/:id&lt;/code&gt; updates an existing movie (404 if absent).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DELETE /movies/:id&lt;/code&gt; deletes a movie (204 on success).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Validation is done with Zod. Example constraints include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;year&lt;/code&gt; must be integer between &lt;code&gt;1888&lt;/code&gt; and &lt;code&gt;2030&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rating&lt;/code&gt; must be between &lt;code&gt;0&lt;/code&gt; and &lt;code&gt;10&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;votes&lt;/code&gt; must be non-negative integer&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;poster&lt;/code&gt; must be URL (or empty string)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Search endpoint
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;GET /movies/search&lt;/code&gt; supports combined full-text + structured filtering + pagination + sorting.&lt;/p&gt;

&lt;p&gt;Supported query params:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;q&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;genre&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tag&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;language&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;yearFrom&lt;/code&gt;, &lt;code&gt;yearTo&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;minRating&lt;/code&gt;, &lt;code&gt;maxRating&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sortBy&lt;/code&gt; (&lt;code&gt;rating | year | votes&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sortOrder&lt;/code&gt; (&lt;code&gt;ASC | DESC&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;limit&lt;/code&gt; (1-100)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;offset&lt;/code&gt; (&amp;gt;= 0)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A representative request would be a search for &lt;code&gt;shawshank&lt;/code&gt;, filtered to the &lt;code&gt;Drama&lt;/code&gt; genre, constrained to movies with rating &lt;code&gt;&amp;gt;= 8&lt;/code&gt;, and sorted by rating descending.&lt;/p&gt;

&lt;p&gt;The backend dynamically composes RediSearch syntax such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;@genres:{Drama}&lt;/code&gt; for tag filtering&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@rating:[8 +inf]&lt;/code&gt; for numeric thresholds&lt;/li&gt;
&lt;li&gt;Combined query form &lt;code&gt;(shawshank) @genres:{Drama} @rating:[8 +inf]&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Input hardening detail: tag-like fields are escaped before interpolation to reduce query parser edge cases from punctuation.&lt;/p&gt;

&lt;p&gt;Every successful search also increments &lt;code&gt;ts:searches&lt;/code&gt;, which later powers trend charts.&lt;/p&gt;

&lt;p&gt;The practical effect is that product behavior feeds analytics behavior automatically: users search, and you immediately gain a signal you can graph and monitor.&lt;/p&gt;

&lt;p&gt;That "single action, dual value" pattern was one of my favorite outcomes in this build. In previous systems, I often had to bolt analytics on after core features shipped. Here, product events and telemetry evolved together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Analytics with FT.AGGREGATE
&lt;/h2&gt;

&lt;p&gt;The analytics endpoints push computation to Redis instead of pulling records into Node and reducing in application code.&lt;/p&gt;

&lt;p&gt;This was the moment the architecture clicked for me. In older Redis usage patterns, I would usually pull records into the service and aggregate there. It works, but it adds code paths, memory overhead, and maintenance burden. Using server-side aggregation simplified both the implementation and the mental model.&lt;/p&gt;

&lt;p&gt;Implemented operations include movie counts by genre, average rating by genre, and decade-based grouping derived from the release year. The exact query shapes are in the &lt;a href="https://github.com/ykpraveen/rediseach-sample" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt;, but the important point here is that the aggregation work stays inside Redis instead of moving into application-side loops.&lt;/p&gt;

&lt;p&gt;Exposed API routes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;GET /analytics/genres&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /analytics/ratings&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /analytics/decades&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /analytics/top-rated&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;/analytics/top-rated&lt;/code&gt; uses &lt;code&gt;FT.SEARCH&lt;/code&gt; with &lt;code&gt;SORTBY rating DESC&lt;/code&gt; and &lt;code&gt;LIMIT 0 10&lt;/code&gt; to return top titles.&lt;/p&gt;

&lt;p&gt;I kept this endpoint intentionally simple because "top rated" is one of those deceptively expensive features if you do it repeatedly in the application layer under load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Time-Series Telemetry with RedisTimeSeries
&lt;/h2&gt;

&lt;p&gt;The project tracks three classes of events:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Search volume (&lt;code&gt;ts:searches&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;API activity (&lt;code&gt;ts:activity&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Per-movie views (&lt;code&gt;ts:movie:views:{id}&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Write examples in this app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;POST /movies/:id/view&lt;/code&gt; -&amp;gt; &lt;code&gt;TS.ADD ts:movie:views:{id} * 1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Search requests -&amp;gt; &lt;code&gt;TS.ADD ts:searches * 1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Non-health API requests -&amp;gt; &lt;code&gt;TS.ADD ts:activity * 1&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read and downsample examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;GET /movies/:id/views?bucket=3600000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /timeseries/searches?bucket=3600000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /timeseries/activity?bucket=86400000&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of these rely on &lt;code&gt;TS.RANGE&lt;/code&gt; with &lt;code&gt;AGGREGATION SUM&lt;/code&gt; and a configurable bucket size (default &lt;code&gt;3,600,000 ms&lt;/code&gt;, i.e. 1 hour).&lt;/p&gt;

&lt;p&gt;If a series key does not yet exist, the API returns &lt;code&gt;[]&lt;/code&gt; rather than a hard error, which simplifies frontend state handling.&lt;/p&gt;

&lt;p&gt;This small API decision turned out to matter a lot in UI polish. Returning empty arrays lets charts render gracefully on first use and avoids noisy error states for a perfectly valid condition: "no data yet."&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliability and Runtime Safeguards
&lt;/h2&gt;

&lt;p&gt;This sample includes several practical safeguards that are easy to forget in demos:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Redis connection retry loop (&lt;code&gt;15&lt;/code&gt; retries, &lt;code&gt;2s&lt;/code&gt; delay)&lt;/li&gt;
&lt;li&gt;Backend waits on Redis service health in Compose&lt;/li&gt;
&lt;li&gt;Backend healthcheck probes &lt;code&gt;GET /health&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Rate limiting: &lt;code&gt;100&lt;/code&gt; requests/minute per IP&lt;/li&gt;
&lt;li&gt;CORS scoped to frontend origin&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are not enterprise-hardening substitutes, but they are meaningful defaults for local and staging environments.&lt;/p&gt;

&lt;p&gt;I added the Redis retry loop after seeing the classic local race: API container starts milliseconds before Redis is ready, then fails fast. With retries in place, startup became boring in the best way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frontend Workflow and UX Model
&lt;/h2&gt;

&lt;p&gt;The React app implements pages for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Search and filter&lt;/li&gt;
&lt;li&gt;Add movie&lt;/li&gt;
&lt;li&gt;Edit movie&lt;/li&gt;
&lt;li&gt;Movie detail&lt;/li&gt;
&lt;li&gt;Analytics dashboard&lt;/li&gt;
&lt;li&gt;Time-series dashboard&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The search experience combines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Text query&lt;/li&gt;
&lt;li&gt;Faceted filtering (genre, tag, language)&lt;/li&gt;
&lt;li&gt;Numeric ranges (year, rating)&lt;/li&gt;
&lt;li&gt;Server-side sorting and pagination&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;State is managed with Redux Toolkit slices and async APIs per feature (&lt;code&gt;movies&lt;/code&gt;, &lt;code&gt;filters&lt;/code&gt;, &lt;code&gt;analytics&lt;/code&gt;, &lt;code&gt;timeseries&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;In practice, this keeps query-building deterministic and enables consistent "URL-ish" state transitions between views.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seed Strategy: Why the Data Looks Realistic Enough
&lt;/h2&gt;

&lt;p&gt;The seed script does more than static insertion:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Starts from curated real movie examples&lt;/li&gt;
&lt;li&gt;Expands to 500 documents with synthetic generation&lt;/li&gt;
&lt;li&gt;Adds 30 days of search/activity signals&lt;/li&gt;
&lt;li&gt;Adds per-movie view entries with probabilistic sparsity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This matters because aggregation and charting workflows are often misleading when driven by tiny uniform datasets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the Project
&lt;/h2&gt;

&lt;p&gt;You can run the project fully in Docker or split it into local frontend/backend development against Redis in Docker. Rather than duplicate the setup commands throughout the article, I would point readers to the &lt;a href="https://github.com/ykpraveen/rediseach-sample" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt; for the latest start-up steps, project structure, and environment notes.&lt;/p&gt;

&lt;p&gt;Default endpoints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API: &lt;code&gt;http://localhost:3001&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Frontend: &lt;code&gt;http://localhost:5173&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What This Project Demonstrates Clearly
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Redis can act as a multi-model operational backend in a single service boundary.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FT.SEARCH&lt;/code&gt; + &lt;code&gt;FT.AGGREGATE&lt;/code&gt; cover a surprisingly wide analytics/search spectrum without a second database.&lt;/li&gt;
&lt;li&gt;RedisTimeSeries reduces bespoke metrics plumbing for product-level event trends.&lt;/li&gt;
&lt;li&gt;A Node.js + React team can adopt this incrementally: start with JSON storage, then add search, then aggregations, then telemetry.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Gaps and Next Steps
&lt;/h2&gt;

&lt;p&gt;The sample is intentionally practical, but not production-complete. Major follow-ups would be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authentication/authorization&lt;/li&gt;
&lt;li&gt;Integration and load tests&lt;/li&gt;
&lt;li&gt;Cursor-based pagination for high offsets&lt;/li&gt;
&lt;li&gt;Background jobs for denormalized materializations&lt;/li&gt;
&lt;li&gt;Observability beyond logs (traces, structured metrics)&lt;/li&gt;
&lt;li&gt;Optional vector similarity for recommendation UX&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final Takeaway
&lt;/h2&gt;

&lt;p&gt;Redis 8 becomes most compelling when you evaluate it as a system-building platform rather than a key-value primitive. In this movie app, a single Redis deployment handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;document persistence&lt;/li&gt;
&lt;li&gt;search relevance and faceting&lt;/li&gt;
&lt;li&gt;aggregation queries&lt;/li&gt;
&lt;li&gt;time-series telemetry&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;with straightforward operational wiring in Docker and a conventional Node/React stack.&lt;/p&gt;

&lt;p&gt;If you want a concrete way to learn Redis beyond cache tutorials, this architecture is a solid, extensible starting point.&lt;/p&gt;

&lt;p&gt;If your own Redis history looks like mine (fast cache first, advanced features later), Redis 8 is worth a fresh look with a project that exercises multiple features end-to-end.&lt;/p&gt;

&lt;p&gt;Personally, this project changed how I scope Redis in new designs. I still use it as a cache when that is the right fit, but I now consider it much earlier as an operational data layer when search, aggregations, and event trends are part of the product surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repository
&lt;/h2&gt;

&lt;p&gt;The full sample project is available here: &lt;a href="https://github.com/ykpraveen/rediseach-sample" rel="noopener noreferrer"&gt;https://github.com/ykpraveen/rediseach-sample&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you publish updates, that repository is also the best place to keep the article in sync with the latest code, commands, and configuration.&lt;/p&gt;




&lt;p&gt;Questions, issues, or ideas for extending the sample are welcome.&lt;/p&gt;

&lt;p&gt;If you publish your own Redis 8 build, share it. I would love to compare approaches, especially around vector search, ranking strategies, and production hardening patterns.&lt;/p&gt;

</description>
      <category>redis</category>
      <category>react</category>
      <category>development</category>
      <category>node</category>
    </item>
    <item>
      <title>Building Distributed Data Processing with Spring Batch 6 + Spring Boot 4</title>
      <dc:creator>Praveen Yadav</dc:creator>
      <pubDate>Thu, 25 Jun 2026 11:37:27 +0000</pubDate>
      <link>https://dev.to/ykpraveen/building-distributed-data-processing-with-spring-batch-6-spring-boot-4-5hf8</link>
      <guid>https://dev.to/ykpraveen/building-distributed-data-processing-with-spring-batch-6-spring-boot-4-5hf8</guid>
      <description>&lt;p&gt;When people first use Spring Batch, they usually start with a simple single-threaded job. That works for small datasets, but once data volume grows, throughput becomes the bottleneck.&lt;/p&gt;

&lt;p&gt;In this sample project, I implemented a &lt;strong&gt;partitioned, multi-threaded Spring Batch pipeline&lt;/strong&gt; to process sales records in parallel using a master/worker step model.&lt;/p&gt;

&lt;p&gt;👉 Code repo: &lt;a href="https://github.com/ykpraveen/spring-batch-sample" rel="noopener noreferrer"&gt;github.com/ykpraveen/spring-batch-sample&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Spring Batch
&lt;/h2&gt;

&lt;p&gt;At its core, Spring Batch is built around a few key abstractions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Job&lt;/strong&gt;: a complete batch workflow&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step&lt;/strong&gt;: one phase of a job&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ItemReader / ItemProcessor / ItemWriter&lt;/strong&gt;: read-transform-write pipeline&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunk processing&lt;/strong&gt;: process N items in one transaction (&lt;code&gt;chunkSize&lt;/code&gt;)
### Why chunking matters&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In chunk-oriented steps, Spring Batch reads and processes items until the chunk size is reached, then writes and commits in one transaction.&lt;/p&gt;

&lt;p&gt;So with &lt;code&gt;chunk(500)&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;500 items are read/processed/written&lt;/li&gt;
&lt;li&gt;one commit happens per chunk&lt;/li&gt;
&lt;li&gt;failures can be retried at chunk boundaries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This gives a good balance between:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;too-small chunks (high transaction overhead)&lt;/li&gt;
&lt;li&gt;too-large chunks (long transactions, higher rollback cost)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  How Spring Batch scales
&lt;/h3&gt;

&lt;p&gt;Spring Batch offers multiple scaling patterns:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Multi-threaded Step&lt;/strong&gt;: one step, concurrent chunk processing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partitioning&lt;/strong&gt;: split input domain into partitions, each handled by a worker step&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote Chunking / Remote Partitioning&lt;/strong&gt;: distribute work across processes/nodes&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This project uses &lt;strong&gt;partitioning + thread pool execution&lt;/strong&gt; (local distributed-style parallelism).&lt;/p&gt;

&lt;h2&gt;
  
  
  How this project applies those concepts
&lt;/h2&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/ykpraveen/spring-batch-sample" rel="noopener noreferrer"&gt;spring-batch-sample&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The architecture is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;master step&lt;/strong&gt; creates partitions (data ranges)&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;worker step&lt;/strong&gt; executes each partition&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;ThreadPoolTaskExecutor&lt;/code&gt; runs workers concurrently&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Key classes (see &lt;code&gt;src/main/java&lt;/code&gt; in repo):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;BatchConfiguration&lt;/code&gt; → job/step orchestration&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SalesDataPartitioner&lt;/code&gt; → partition boundary logic&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SalesDataProcessor&lt;/code&gt; → business transformation logic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Code area: &lt;a href="https://github.com/ykpraveen/spring-batch-sample/tree/main/src/main/java" rel="noopener noreferrer"&gt;src/main/java&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance tuning used here
&lt;/h2&gt;

&lt;p&gt;The sample uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gridSize: 8&lt;/code&gt; (number of partitions)&lt;/li&gt;
&lt;li&gt;Thread pool: &lt;code&gt;corePoolSize=4&lt;/code&gt;, &lt;code&gt;maxPoolSize=8&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;chunk size: 500&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Sample input: 5000 records&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Interpretation
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gridSize&lt;/code&gt; controls parallel work units.&lt;/li&gt;
&lt;li&gt;Thread pool size controls actual concurrent execution.&lt;/li&gt;
&lt;li&gt;Effective throughput depends on DB I/O, CPU, and item processing complexity.&lt;/li&gt;
&lt;li&gt;Increasing partitions beyond available threads can still help load balancing, but with diminishing returns.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Database + metadata angle
&lt;/h2&gt;

&lt;p&gt;Spring Batch is not just a processing framework; it is also a &lt;strong&gt;stateful execution framework&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It tracks job/step execution state in metadata, enabling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;restartability&lt;/li&gt;
&lt;li&gt;execution history&lt;/li&gt;
&lt;li&gt;failure diagnostics&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In this sample, PostgreSQL stores both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;domain tables (&lt;code&gt;sales_data&lt;/code&gt;, &lt;code&gt;processed_data&lt;/code&gt;, &lt;code&gt;processing_statistics&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;batch execution context/metadata managed by Spring Batch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That combination is what makes batch jobs operationally reliable in real systems.&lt;/p&gt;




&lt;h2&gt;
  
  
  Run locally
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1) Start PostgreSQL
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2) Build and run the app
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mvn clean &lt;span class="nb"&gt;install
&lt;/span&gt;mvn spring-boot:run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3) Trigger the batch job
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8080/api/batch/start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4) Stop PostgreSQL
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose down
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Why this pattern is useful in real projects
&lt;/h2&gt;

&lt;p&gt;This design is a strong baseline for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ETL and data migration&lt;/li&gt;
&lt;li&gt;order/payment reconciliation&lt;/li&gt;
&lt;li&gt;large-volume reporting prep&lt;/li&gt;
&lt;li&gt;scheduled backend data shaping&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;clear separation of orchestration vs business logic&lt;/li&gt;
&lt;li&gt;predictable transactional boundaries&lt;/li&gt;
&lt;li&gt;scalable parallel execution&lt;/li&gt;
&lt;li&gt;operational observability through batch metadata&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next extensions
&lt;/h2&gt;

&lt;p&gt;If you want to evolve this sample toward production-grade scale:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add retry/skip policies for fault tolerance.&lt;/li&gt;
&lt;li&gt;Export job metrics (Micrometer + Prometheus/Grafana).&lt;/li&gt;
&lt;li&gt;Make partition strategy adaptive to dataset size.&lt;/li&gt;
&lt;li&gt;Move to remote partitioning for multi-node execution.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;If you’re learning Spring Batch or designing high-throughput processing pipelines, this pattern is a solid starting point: simple enough to understand, realistic enough to extend.&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>batch</category>
      <category>java</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Building an AI Chat Agent with MCP, Spring AI</title>
      <dc:creator>Praveen Yadav</dc:creator>
      <pubDate>Wed, 24 Jun 2026 09:41:20 +0000</pubDate>
      <link>https://dev.to/ykpraveen/building-an-ai-chat-agent-with-mcp-spring-ai-f0n</link>
      <guid>https://dev.to/ykpraveen/building-an-ai-chat-agent-with-mcp-spring-ai-f0n</guid>
      <description>&lt;p&gt;Model Context Protocol (MCP) is an open standard for connecting AI apps to tools and data sources. A useful way to think about it is as a USB-C port for AI: one standard interface that lets different models plug into different capabilities without custom glue code for every integration.&lt;/p&gt;

&lt;p&gt;In this project, we combine MCP, Spring AI, and Google Gemini to build a chat app that can answer weather questions using real tools instead of hallucinating. The system has three parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MCP tool server&lt;/strong&gt; - a Spring Boot service that exposes weather and geocoding tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI chat agent&lt;/strong&gt; - a Spring Boot service that uses Spring AI + Gemini and calls MCP tools when needed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React chat UI&lt;/strong&gt; - a lightweight frontend for sending messages and rendering replies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is a small but realistic architecture you can extend into a production assistant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User (Browser:3000)
    | POST /api/chat
    v
AI Agent (Spring:7171) -- MCP / Streamable HTTP --&amp;gt; MCP Server (Spring:7170)
    |                                               |
    | Google Gemini                                 | Bright Sky API (weather)
    |                                               | OpenStreetMap Nominatim (geocoding)
    v                                               v
Chat response                                    Tool execution
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full source code is available on &lt;a href="https://github.com/ykpraveen/mcp-spring-sample" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The MCP Tool Server
&lt;/h2&gt;

&lt;p&gt;The tool server is a Spring Boot application that exposes MCP tools through Spring AI's annotation scanner. It runs on port &lt;code&gt;7170&lt;/code&gt; and uses Streamable HTTP for transport.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dependencies
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.ai&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-ai-starter-mcp-server-webmvc&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.boot&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-boot-starter-web&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Defining tools
&lt;/h3&gt;

&lt;p&gt;With Spring AI, a tool is just a Spring bean method annotated with &lt;code&gt;@McpTool&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WeatherTool&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;WeatherToolService&lt;/span&gt; &lt;span class="n"&gt;weatherToolService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;WeatherTool&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WeatherToolService&lt;/span&gt; &lt;span class="n"&gt;weatherToolService&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;weatherToolService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;weatherToolService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@McpTool&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"get_current_weather"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
             &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Get current weather by dwd_station_id or by lat/lon"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getCurrentWeather&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nd"&gt;@McpToolParam&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"DWD station ID"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;dwd_station_id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nd"&gt;@McpToolParam&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Latitude"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nd"&gt;@McpToolParam&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Longitude"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="n"&gt;lon&lt;/span&gt;
    &lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;weatherToolService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getWeather&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dwd_station_id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lon&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spring turns that method into an MCP tool definition and publishes the parameter metadata as part of the schema. That means the model can discover the tool, understand its inputs, and decide when to call it.&lt;/p&gt;

&lt;p&gt;The project also includes a geocoding tool that resolves city names to coordinates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@McpTool&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"geocode_city"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Convert a city name to latitude and longitude using OpenStreetMap Nominatim"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;geocodeCity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nd"&gt;@McpToolParam&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"City name (e.g., 'Berlin', 'New York')"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;cityName&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The service layer
&lt;/h3&gt;

&lt;p&gt;The tools delegate the real work to services that handle validation, caching, and external API calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WeatherToolService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getWeather&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;dwdStationId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="n"&gt;lon&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Validate the request&lt;/span&gt;
        &lt;span class="c1"&gt;// Check the cache&lt;/span&gt;
        &lt;span class="c1"&gt;// Call Bright Sky if needed&lt;/span&gt;
        &lt;span class="c1"&gt;// Return a structured response&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key design choices are straightforward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Separate TTL caches&lt;/strong&gt; for station-id and coordinate lookups&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured responses&lt;/strong&gt; with &lt;code&gt;success&lt;/code&gt;, &lt;code&gt;error_code&lt;/code&gt;, and &lt;code&gt;error_message&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache metadata&lt;/strong&gt; in each response so you can see whether the result came from cache or upstream&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Server configuration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7170&lt;/span&gt;

&lt;span class="na"&gt;spring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ai&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;mcp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spring-sample-mcp-server&lt;/span&gt;
        &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.0.0&lt;/span&gt;
        &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;STREAMABLE&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SYNC&lt;/span&gt;
        &lt;span class="na"&gt;annotation-scanner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;mcp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;security&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;api-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MCP_API_KEY:}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;STREAMABLE&lt;/code&gt; protocol gives the agent a lightweight MCP transport, and the shared API key keeps the demo simple without adding full auth infrastructure.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Security for the Demo
&lt;/h2&gt;

&lt;p&gt;The MCP server and agent share an &lt;code&gt;MCP_API_KEY&lt;/code&gt;. The agent adds it automatically as an &lt;code&gt;X-API-Key&lt;/code&gt; header, and the server validates it on inbound MCP requests.&lt;/p&gt;

&lt;p&gt;That is enough for local development and a sample project. For anything public-facing, move to Spring Security, OAuth2 or JWT, rate limiting, and a gateway in front of the MCP endpoint.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The AI Chat Agent
&lt;/h2&gt;

&lt;p&gt;The agent is responsible for deciding when to use tools, calling Gemini, and keeping the conversation stateful.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dependencies
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.ai&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-ai-starter-model-google-genai&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.ai&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-ai-starter-mcp-client&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.boot&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-boot-starter-web&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  MCP client configuration
&lt;/h3&gt;

&lt;p&gt;The agent injects the shared API key through a custom HTTP request customizer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Configuration&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AgentConfiguration&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="nc"&gt;McpClientCustomizer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;HttpClientStreamableHttpTransport&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Builder&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;streamableHttpTransportCustomizer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AgentProperties&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;McpSyncHttpClientRequestCustomizer&lt;/span&gt; &lt;span class="n"&gt;requestCustomizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
                &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StringUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasText&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMcpApiKey&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                        &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-API-Key"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMcpApiKey&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
                    &lt;span class="o"&gt;}&lt;/span&gt;
                &lt;span class="o"&gt;};&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;httpRequestCustomizer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requestCustomizer&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Core chat flow
&lt;/h3&gt;

&lt;p&gt;The agent keeps a small in-memory conversation history, checks whether the user message looks like a tool request, and then routes the prompt through either a plain Gemini client or a tool-enabled client.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;sessionId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;userMessage&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ConversationTurn&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;memoryStore&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;history&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sessionId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;buildPrompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userMessage&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;toolRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shouldUseTools&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userMessage&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;ChatClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;toolRequest&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;toolEnabledClient&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;plainChatClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;invokeModel&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;memoryStore&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;appendTurn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sessionId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userMessage&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lazy initialization is deliberate: the agent can start even if the MCP server is down, and it only initializes MCP clients when a tool request actually arrives.&lt;/p&gt;

&lt;p&gt;The tool trigger is intentionally simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;shouldUseTools&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;userMessage&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userMessage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toLowerCase&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Locale&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ROOT&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;TOOL_KEYWORDS&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That heuristic is enough for a demo and easy to explain. In a larger system, you could replace it with a router model or intent classifier.&lt;/p&gt;

&lt;h3&gt;
  
  
  Virtual threads and timeout handling
&lt;/h3&gt;

&lt;p&gt;The model call runs on a virtual thread with a configurable timeout so the request does not hang forever if Gemini is slow or unreachable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;invokeModel&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Executors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newVirtualThreadPerTaskExecutor&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;future&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;submit&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
                &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;future&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeoutSeconds&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SECONDS&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TimeoutException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ResponseStatusException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;GATEWAY_TIMEOUT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;...);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;shutdownNow&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Session memory
&lt;/h3&gt;

&lt;p&gt;Conversation history lives in an in-memory LRU store with a small per-session turn window. That keeps follow-up questions like "What about tomorrow?" grounded in the earlier exchange without introducing a database too early.&lt;/p&gt;

&lt;p&gt;The agent configuration sets the model to &lt;code&gt;gemini-3.5-flash&lt;/code&gt;, the memory limit to 20 turns per session, and the session cap to 500.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The React Chat UI
&lt;/h2&gt;

&lt;p&gt;The frontend is a Vite app with a simple chat window, minimal state, and no component library.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setMessages&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sendMessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}]);&lt;/span&gt;
    &lt;span class="nf"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/chat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;setMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No response&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;}]);&lt;/span&gt;
    &lt;span class="nf"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Vite dev server proxies &lt;code&gt;/api/*&lt;/code&gt; to the agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:7171&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;changeOrigin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;rewrite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;api/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI is intentionally plain: a purple gradient, responsive layout, and a smooth message list are enough to make the app feel complete without distracting from the architecture.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Putting It All Together
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Running the application
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Set the environment variables:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GEMINI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_gemini_api_key
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;MCP_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;a_shared_secret
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Start the MCP server:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;mcp-server-spring
mvn spring-boot:run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Start the agent:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;mcp-spring-agent
mvn spring-boot:run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Start the UI:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;mcp-ui
npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What happens when you ask a question
&lt;/h3&gt;

&lt;p&gt;If the user asks, "What's the weather in Berlin?" the flow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The agent sees the word "weather" and switches to tool-enabled mode&lt;/li&gt;
&lt;li&gt;Gemini calls &lt;code&gt;geocode_city("Berlin")&lt;/code&gt; to get coordinates&lt;/li&gt;
&lt;li&gt;The agent calls &lt;code&gt;get_current_weather(lat=52.52, lon=13.41)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Gemini turns the raw data into a readable response&lt;/li&gt;
&lt;li&gt;The UI renders the answer&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  6. Why This Architecture Works
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;MCP separates the model from the tools.&lt;/strong&gt; The agent knows what tools exist and how to call them, but not how those tools are implemented. That makes the system easier to evolve.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The same server can serve different models.&lt;/strong&gt; Gemini is just the model in this demo. The MCP server itself can work with any compatible client.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lazy initialization keeps the app resilient.&lt;/strong&gt; The agent can boot even if the MCP server is temporarily unavailable, and tool support only activates when it is actually needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. What's Next
&lt;/h2&gt;

&lt;p&gt;This sample is a solid starting point. Natural next steps include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Docker Compose&lt;/strong&gt; - run all services together&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL persistence&lt;/strong&gt; - durable chat history and richer memory&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OAuth2&lt;/strong&gt; - authenticated multi-user access&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebSocket streaming&lt;/strong&gt; - token-by-token responses&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes&lt;/strong&gt; - scale the agent and tool server independently&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/ykpraveen/mcp-spring-sample" rel="noopener noreferrer"&gt;Source code on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.spring.io/spring-ai/reference/api/mcp.html" rel="noopener noreferrer"&gt;Spring AI MCP documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;Model Context Protocol specification&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ai.google.dev/gemini-api/docs" rel="noopener noreferrer"&gt;Google Gemini API documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Have you built anything with MCP and Spring AI? I'd love to hear how you approached it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>spring</category>
      <category>mcp</category>
      <category>react</category>
    </item>
    <item>
      <title>Building an Order Processing Pipeline with Spring Integration (HTTP + File Polling)</title>
      <dc:creator>Praveen Yadav</dc:creator>
      <pubDate>Wed, 24 Jun 2026 07:57:53 +0000</pubDate>
      <link>https://dev.to/ykpraveen/building-an-order-processing-pipeline-with-spring-integration-http-file-polling-i6a</link>
      <guid>https://dev.to/ykpraveen/building-an-order-processing-pipeline-with-spring-integration-http-file-polling-i6a</guid>
      <description>&lt;p&gt;If you’ve used Spring Boot REST APIs but haven’t explored &lt;strong&gt;Spring Integration&lt;/strong&gt; yet, this project is a practical way to see what message-driven flow design looks like in real code.&lt;/p&gt;

&lt;p&gt;I built a sample app that processes orders from two different entry points:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;HTTP API&lt;/strong&gt; (&lt;code&gt;POST /api/orders&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File polling&lt;/strong&gt; (drop CSV files into &lt;code&gt;input/&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both inputs share the same core processing logic.&lt;/p&gt;

&lt;p&gt;👉 Source code: &lt;strong&gt;&lt;a href="https://github.com/ykpraveen/spring-integration-sample" rel="noopener noreferrer"&gt;https://github.com/ykpraveen/spring-integration-sample&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What this project does
&lt;/h2&gt;

&lt;p&gt;Each order goes through this pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Transform input payload into &lt;code&gt;Order&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Validate (&lt;code&gt;id&lt;/code&gt;, &lt;code&gt;customer&lt;/code&gt;, &lt;code&gt;total&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Route by amount:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;total &amp;lt;= 100&lt;/code&gt; → EXPRESS&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;total &amp;gt; 100&lt;/code&gt; → REVIEW&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Persist order (JPA)&lt;/li&gt;
&lt;li&gt;Fan out to:

&lt;ul&gt;
&lt;li&gt;archive output file&lt;/li&gt;
&lt;li&gt;summary aggregation&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Runtime persistence uses &lt;strong&gt;PostgreSQL&lt;/strong&gt;, and tests use &lt;strong&gt;H2&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tech stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Java 21&lt;/li&gt;
&lt;li&gt;Spring Boot 4.1&lt;/li&gt;
&lt;li&gt;Spring Integration 7 (Java DSL)&lt;/li&gt;
&lt;li&gt;Spring Data JPA&lt;/li&gt;
&lt;li&gt;PostgreSQL (runtime)&lt;/li&gt;
&lt;li&gt;H2 (test)&lt;/li&gt;
&lt;li&gt;Maven&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Architecture at a glance
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1) HTTP flow
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;POST /api/orders&lt;/code&gt; sends raw JSON to a messaging gateway, then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;JsonToOrderTransformer&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;OrderValidationService&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;content-based router (&lt;code&gt;EXPRESS&lt;/code&gt; / &lt;code&gt;REVIEW&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;OrderStore.put(...)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;publish-subscribe to archive + summary + HTTP response message&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2) File flow
&lt;/h3&gt;

&lt;p&gt;The poller watches &lt;code&gt;input/*.csv&lt;/code&gt;, then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;FileToStringTransformer&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CsvToOrderTransformer&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;validation + routing + persistence&lt;/li&gt;
&lt;li&gt;publish-subscribe to archive + summary&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3) Error handling
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;HTTP errors return JSON via dedicated HTTP error handling.&lt;/li&gt;
&lt;li&gt;File processing errors generate failure logs in &lt;code&gt;output/failed/&lt;/code&gt; and move bad source files to &lt;code&gt;input/failed/&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why Spring Integration here?
&lt;/h2&gt;

&lt;p&gt;Using Spring Integration made these parts clean and explicit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Routing rules&lt;/strong&gt; are declarative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fan-out behavior&lt;/strong&gt; is easy with publish-subscribe channels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry advice&lt;/strong&gt; can be attached per handler in the file pipeline.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;integration graph&lt;/strong&gt; (&lt;code&gt;/api/integration/graph&lt;/code&gt;) helps visualize the runtime flow.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Example requests
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Submit an EXPRESS order
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8080/api/orders &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"id":"ORD-001","customer":"Alice","description":"Book","total":25.00}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Submit a REVIEW order
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8080/api/orders &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"id":"ORD-002","customer":"Bob","description":"Laptop","total":1500.00}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Get one order
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:8080/api/orders/ORD-001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  List all orders
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:8080/api/orders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Running locally
&lt;/h2&gt;

&lt;p&gt;Clone the repo first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/ykpraveen/spring-integration-sample.git
&lt;span class="nb"&gt;cd &lt;/span&gt;spring-integration-sample
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
mvn clean package
mvn spring-boot:run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then test HTTP endpoints or drop a CSV file into &lt;code&gt;input/&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;The project currently has &lt;strong&gt;38 tests&lt;/strong&gt; (unit + integration), covering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;transformation and validation&lt;/li&gt;
&lt;li&gt;HTTP happy paths and failure paths&lt;/li&gt;
&lt;li&gt;duplicate order handling (&lt;code&gt;409 Conflict&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;file poller processing and retries&lt;/li&gt;
&lt;li&gt;summary aggregation behavior via Spring Integration aggregator + writer service&lt;/li&gt;
&lt;li&gt;integration graph endpoint&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can run these directly from the repo: &lt;strong&gt;&lt;a href="https://github.com/ykpraveen/spring-integration-sample" rel="noopener noreferrer"&gt;https://github.com/ykpraveen/spring-integration-sample&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Key implementation details I found useful
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use correlation IDs&lt;/strong&gt; in headers and push them into MDC for traceable logs across flow steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep config split by concern&lt;/strong&gt; (&lt;code&gt;HttpIntegrationConfig&lt;/code&gt;, &lt;code&gt;FileIntegrationConfig&lt;/code&gt;, shared config) instead of one huge integration config class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat summary aggregation as stateful logic&lt;/strong&gt;: clear release rules (batch size vs timeout), stable keys, and append-safe file writing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer explicit web path-variable lookup&lt;/strong&gt; for &lt;code&gt;GET /api/orders/{id}&lt;/code&gt; over indirect URL parsing.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Repo
&lt;/h2&gt;

&lt;p&gt;You can clone the project and run it as a demo starter for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Spring Integration basics&lt;/li&gt;
&lt;li&gt;event/message-driven service design in Spring&lt;/li&gt;
&lt;li&gt;hybrid ingestion patterns (HTTP + file)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re learning Spring Integration, this pattern is a good stepping stone before Kafka/Rabbit-based distributed flows.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;strong&gt;&lt;a href="https://github.com/ykpraveen/spring-integration-sample" rel="noopener noreferrer"&gt;https://github.com/ykpraveen/spring-integration-sample&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>springintegration</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
