<?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>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>
