<?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: sandeep chagalakonda</title>
    <description>The latest articles on DEV Community by sandeep chagalakonda (@sandeep_chagalakonda_6e60).</description>
    <link>https://dev.to/sandeep_chagalakonda_6e60</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%2F4112301%2Fc590b808-fb1d-4794-a410-103a49817f09.png</url>
      <title>DEV Community: sandeep chagalakonda</title>
      <link>https://dev.to/sandeep_chagalakonda_6e60</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sandeep_chagalakonda_6e60"/>
    <language>en</language>
    <item>
      <title>The Distributed Transaction Nightmare: Why @Transactional Silently Failed Across Microservices (And the Outbox Pattern Fix)</title>
      <dc:creator>sandeep chagalakonda</dc:creator>
      <pubDate>Fri, 16 Oct 2026 10:22:15 +0000</pubDate>
      <link>https://dev.to/sandeep_chagalakonda_6e60/the-distributed-transaction-nightmare-why-transactional-silently-failed-across-microservices-and-2oki</link>
      <guid>https://dev.to/sandeep_chagalakonda_6e60/the-distributed-transaction-nightmare-why-transactional-silently-failed-across-microservices-and-2oki</guid>
      <description>&lt;p&gt;Everything looks flawless in development. You decorate your Spring Boot service method with @Transactional, run an integration test against a local container, simulate a downstream failure, and verify that the database rolls back cleanly.&lt;/p&gt;

&lt;p&gt;Then you deploy to production.&lt;/p&gt;

&lt;p&gt;At scale, when network sockets hiccup, third-party payment gateways timeout, and message brokers drop TCP connections mid-flight, your database state begins drifting silently. Money gets deducted without inventory decrements. Orders get created in your database, but downstream notification services never hear about them.&lt;/p&gt;

&lt;p&gt;Here is why Spring's @Transactional cannot protect distributed microservice architectures, the anatomy of the dreaded Dual-Write Problem, and the exact Transactional Outbox Pattern implementation in Spring Boot 3 and Java 21 that eliminates data drift forever.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Localhost Illusion: What @Transactional Actually Does
Spring's @Transactional is built around a single foundational primitive: a local relational database transaction managed via a single JDBC Connection.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Plaintext&lt;/p&gt;

&lt;p&gt;[HTTP Request]&lt;br&gt;
       │&lt;br&gt;
       ▼&lt;br&gt;
┌──────────────────────────────────────────────┐&lt;br&gt;
│ Spring @Transactional Boundary               │&lt;br&gt;
│                                              │&lt;br&gt;
│  1. conn.setAutoCommit(false)                │&lt;br&gt;
│  2. INSERT INTO orders ... (Local DB)        │&lt;br&gt;
│  3. restTemplate.postForObject(...) ──► [HTTP Downstream Service]&lt;br&gt;
│  4. conn.commit()                            │&lt;br&gt;
└──────────────────────────────────────────────┘&lt;br&gt;
When you wrap a method with @Transactional, Spring intercepts the call, grabs a physical connection from your connection pool (HikariCP), sets autoCommit = false, executes your JPA statements, and calls conn.commit() at the boundary exit.&lt;/p&gt;

&lt;p&gt;If an unhandled RuntimeException triggers before line 4, Spring issues conn.rollback().&lt;/p&gt;

&lt;p&gt;The Fatal Flaw Across Network Boundaries&lt;br&gt;
Consider this typical order-placement method:&lt;/p&gt;

&lt;p&gt;Java&lt;/p&gt;

&lt;p&gt;@Service&lt;br&gt;
public class OrderProcessingService {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;private final OrderRepository orderRepository;
private final RestClient inventoryClient;
private final KafkaTemplate&amp;lt;String, OrderCreatedEvent&amp;gt; kafkaTemplate;

@Transactional
public Order placeOrder(CreateOrderRequest request) {
    // Step 1: Save local record
    Order order = orderRepository.save(new Order(request));

    // Step 2: Call downstream service over HTTP
    inventoryClient.post()
        .uri("/api/inventory/reserve")
        .body(new ReserveInventoryRequest(order.getId(), request.items()))
        .retrieve()
        .toBstatus();

    // Step 3: Broadcast event to message broker
    kafkaTemplate.send("order.events", new OrderCreatedEvent(order));

    return order;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
This method contains three distinct failure boundaries masquerading as one atomic transaction:&lt;/p&gt;

&lt;p&gt;Downstream Timeout After Processing: What happens if inventoryClient successfully reserves stock in the inventory database, but a network blip severs the connection before the HTTP 200 OK reaches your server? The HTTP client throws a ResourceAccessException. Your @Transactional method catches the exception and rolls back the local Order entity. Result: Inventory is permanently locked downstream, but no order exists upstream.&lt;/p&gt;

&lt;p&gt;Broker Network Partition: What happens if the database commit succeeds, but your Kafka broker encounters a transient leader re-election during kafkaTemplate.send()? If the broker call throws an exception, your database changes roll back—but if the message was already acknowledged, downstream consumers start processing an order that does not exist in your primary database.&lt;/p&gt;

&lt;p&gt;The Dual-Write Race Condition: Even without errors, notice the order of operations: line 3 emits an event before the database transaction commits at line 4. A high-speed downstream consumer reading from Kafka can receive the message and query GET /orders/{id} before your local database transaction has finished committing, resulting in an immediate 404 Not Found.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why Two-Phase Commit (2PC / XA) is a Trap
Historically, distributed systems used Two-Phase Commit (2PC) with XA transactions to maintain atomicity across relational databases and message queues.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In modern cloud environments, 2PC is an anti-pattern:&lt;/p&gt;

&lt;p&gt;Blocking Coordinator Protocol: If the transaction coordinator crashes during the PREPARE phase, locks on all participating database rows remain held indefinitely.&lt;/p&gt;

&lt;p&gt;Latency Escalation: Every participant must flush state and wait on round-trip network calls before committing, multiplying API latency by 10x–50x.&lt;/p&gt;

&lt;p&gt;Cloud Incompatibility: Most modern distributed datastores and message brokers (Kafka, AWS SQS, DynamoDB) do not even support the XA protocol.&lt;/p&gt;

&lt;p&gt;To achieve scale and zero data drift, we must separate local persistence from event dispatching.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Architecture: Transactional Outbox Pattern
The Transactional Outbox Pattern ensures guaranteed at-least-once event delivery without distributed locks:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Plaintext&lt;/p&gt;

&lt;p&gt;[Client] ──► [Order Service API]&lt;br&gt;
                    │&lt;br&gt;
                    ▼  (Single Local ACID Transaction)&lt;br&gt;
        ┌──────────────────────────────────────────────┐&lt;br&gt;
        │ 1. INSERT INTO orders (Business State)       │&lt;br&gt;
        │ 2. INSERT INTO outbox_events (Event Payload) │&lt;br&gt;
        └──────────────────────────────────────────────┘&lt;br&gt;
                    │&lt;br&gt;
                    ▼ (Commit)&lt;br&gt;
             [PostgreSQL Database]&lt;br&gt;
                    │&lt;br&gt;
        ┌───────────┴────────────────┐&lt;br&gt;
        │                            │&lt;br&gt;
        ▼                            ▼&lt;br&gt;
[Outbox Poller Service]   OR   [CDC Engine (Debezium)]&lt;br&gt;
        │                            │&lt;br&gt;
        └──────────────┬─────────────┘&lt;br&gt;
                       ▼&lt;br&gt;
            [Apache Kafka / RabbitMQ]&lt;br&gt;
                       │&lt;br&gt;
                       ▼&lt;br&gt;
           [Downstream Microservices]&lt;br&gt;
Instead of sending network calls directly inside the business logic:&lt;/p&gt;

&lt;p&gt;You insert the domain entity (Order) into the primary table.&lt;/p&gt;

&lt;p&gt;In the exact same database transaction, you insert an event payload into a dedicated outbox_events table.&lt;/p&gt;

&lt;p&gt;Because both inserts share a single JDBC connection, they succeed or fail atomically. Zero network calls occur inside the transaction.&lt;/p&gt;

&lt;p&gt;An asynchronous worker (either a background polling scheduler or a Change Data Capture tool like Debezium) reads the outbox table and broadcasts the events to the broker.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Production Spring Boot 3 Implementation
Step 1: The Outbox Database Schema
SQL&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;CREATE TABLE outbox_events (&lt;br&gt;
    id UUID PRIMARY KEY,&lt;br&gt;
    aggregate_type VARCHAR(64) NOT NULL,&lt;br&gt;
    aggregate_id VARCHAR(64) NOT NULL,&lt;br&gt;
    event_type VARCHAR(128) NOT NULL,&lt;br&gt;
    payload JSONB NOT NULL,&lt;br&gt;
    status VARCHAR(32) NOT NULL DEFAULT 'PENDING',&lt;br&gt;
    retry_count INT NOT NULL DEFAULT 0,&lt;br&gt;
    created_at TIMESTAMP WITH TIME ZONE NOT NULL,&lt;br&gt;
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;CREATE INDEX idx_outbox_pending ON outbox_events (status, created_at) &lt;br&gt;
WHERE status = 'PENDING';&lt;br&gt;
Step 2: The Outbox Entity and JPA Repository&lt;br&gt;
Java&lt;/p&gt;

&lt;p&gt;package com.springforge.outbox.entity;&lt;/p&gt;

&lt;p&gt;import jakarta.persistence.*;&lt;br&gt;
import lombok.Getter;&lt;br&gt;
import lombok.NoArgsConstructor;&lt;br&gt;
import lombok.Setter;&lt;br&gt;
import org.hibernate.annotations.JdbcTypeCode;&lt;br&gt;
import org.hibernate.type.SqlTypes;&lt;/p&gt;

&lt;p&gt;import java.time.Instant;&lt;br&gt;
import java.util.UUID;&lt;/p&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/entity"&gt;@entity&lt;/a&gt;&lt;br&gt;
@Table(name = "outbox_events")&lt;br&gt;
@Getter&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/setter"&gt;@setter&lt;/a&gt;&lt;br&gt;
@NoArgsConstructor&lt;br&gt;
public class OutboxEvent {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@Id
private UUID id;

@Column(nullable = false, length = 64)
private String aggregateType;

@Column(nullable = false, length = 64)
private String aggregateId;

@Column(nullable = false, length = 128)
private String eventType;

@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb", nullable = false)
private String payload;

@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private OutboxStatus status;

@Column(nullable = false)
private int retryCount;

@Column(nullable = false)
private Instant createdAt;

@Column(nullable = false)
private Instant updatedAt;

public enum OutboxStatus {
    PENDING, PROCESSING, PUBLISHED, FAILED
}

public static OutboxEvent from(String aggregateType, String aggregateId, String eventType, String jsonPayload) {
    OutboxEvent event = new OutboxEvent();
    event.setId(UUID.randomUUID());
    event.setAggregateType(aggregateType);
    event.setAggregateId(aggregateId);
    event.setEventType(eventType);
    event.setPayload(jsonPayload);
    event.setStatus(OutboxStatus.PENDING);
    event.setRetryCount(0);
    event.setCreatedAt(Instant.now());
    event.setUpdatedAt(Instant.now());
    return event;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Java&lt;/p&gt;

&lt;p&gt;package com.springforge.outbox.repository;&lt;/p&gt;

&lt;p&gt;import com.springforge.outbox.entity.OutboxEvent;&lt;br&gt;
import org.springframework.data.domain.Pageable;&lt;br&gt;
import org.springframework.data.jpa.repository.JpaRepository;&lt;br&gt;
import org.springframework.data.jpa.repository.Query;&lt;br&gt;
import org.springframework.stereotype.Repository;&lt;/p&gt;

&lt;p&gt;import java.util.List;&lt;br&gt;
import java.util.UUID;&lt;/p&gt;

&lt;p&gt;@Repository&lt;br&gt;
public interface OutboxRepository extends JpaRepository {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@Query("SELECT o FROM OutboxEvent o WHERE o.status = 'PENDING' ORDER BY o.createdAt ASC")
List&amp;lt;OutboxEvent&amp;gt; findPendingEvents(Pageable pageable);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Step 3: Atomic Mutation in Business Service&lt;br&gt;
The business service no longer touches Kafka or external HTTP endpoints. It writes both records in a clean 2ms database transaction:&lt;/p&gt;

&lt;p&gt;Java&lt;/p&gt;

&lt;p&gt;package com.springforge.outbox.service;&lt;/p&gt;

&lt;p&gt;import com.fasterxml.jackson.core.JsonProcessingException;&lt;br&gt;
import com.fasterxml.jackson.databind.ObjectMapper;&lt;br&gt;
import com.springforge.outbox.entity.Order;&lt;br&gt;
import com.springforge.outbox.entity.OutboxEvent;&lt;br&gt;
import com.springforge.outbox.repository.OrderRepository;&lt;br&gt;
import com.springforge.outbox.repository.OutboxRepository;&lt;br&gt;
import lombok.RequiredArgsConstructor;&lt;br&gt;
import lombok.extern.slf4j.Slf4j;&lt;br&gt;
import org.springframework.stereotype.Service;&lt;br&gt;
import org.springframework.transaction.annotation.Transactional;&lt;/p&gt;

&lt;p&gt;@Service&lt;br&gt;
@RequiredArgsConstructor&lt;br&gt;
@Slf4j&lt;br&gt;
public class OrderService {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
private final ObjectMapper objectMapper;

@Transactional
public Order createOrder(OrderRequest request) {
    // 1. Persist business state
    Order order = new Order(request.customerId(), request.totalAmount());
    Order savedOrder = orderRepository.save(order);

    // 2. Prepare event payload
    OrderCreatedEvent event = new OrderCreatedEvent(
        savedOrder.getId(),
        savedOrder.getCustomerId(),
        savedOrder.getTotalAmount(),
        savedOrder.getCreatedAt()
    );

    // 3. Persist outbox event in same ACID boundary
    try {
        String payload = objectMapper.writeValueAsString(event);
        OutboxEvent outboxEvent = OutboxEvent.from(
            "ORDER",
            savedOrder.getId().toString(),
            "ORDER_CREATED",
            payload
        );
        outboxRepository.save(outboxEvent);
    } catch (JsonProcessingException e) {
        log.error("Failed to serialize order event for order id: {}", savedOrder.getId(), e);
        throw new IllegalStateException("Event serialization failed", e);
    }

    // Both tables commit simultaneously
    return savedOrder;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Step 4: The Resilient Outbox Poller Worker&lt;br&gt;
A dedicated scheduler periodically sweeps pending events, streams them to Kafka, and updates the state. If Kafka is down, the records stay safely stored in outbox_events until the broker recovers.&lt;/p&gt;

&lt;p&gt;Java&lt;/p&gt;

&lt;p&gt;package com.springforge.outbox.worker;&lt;/p&gt;

&lt;p&gt;import com.springforge.outbox.entity.OutboxEvent;&lt;br&gt;
import com.springforge.outbox.repository.OutboxRepository;&lt;br&gt;
import lombok.RequiredArgsConstructor;&lt;br&gt;
import lombok.extern.slf4j.Slf4j;&lt;br&gt;
import org.springframework.data.domain.PageRequest;&lt;br&gt;
import org.springframework.kafka.core.KafkaTemplate;&lt;br&gt;
import org.springframework.scheduling.annotation.Scheduled;&lt;br&gt;
import org.springframework.stereotype.Component;&lt;br&gt;
import org.springframework.transaction.annotation.Propagation;&lt;br&gt;
import org.springframework.transaction.annotation.Transactional;&lt;/p&gt;

&lt;p&gt;import java.time.Instant;&lt;br&gt;
import java.util.List;&lt;/p&gt;

&lt;p&gt;@Component&lt;br&gt;
@RequiredArgsConstructor&lt;br&gt;
@Slf4j&lt;br&gt;
public class OutboxPublisherWorker {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;private final OutboxRepository outboxRepository;
private final KafkaTemplate&amp;lt;String, String&amp;gt; kafkaTemplate;

@Scheduled(fixedDelayString = "${app.outbox.poll-interval-ms:1000}")
public void processOutbox() {
    List&amp;lt;OutboxEvent&amp;gt; pendingEvents = outboxRepository.findPendingEvents(PageRequest.of(0, 100));

    for (OutboxEvent event : pendingEvents) {
        publishSingleEvent(event);
    }
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void publishSingleEvent(OutboxEvent event) {
    try {
        // Partition by aggregateId to preserve strict entity ordering in Kafka
        kafkaTemplate.send("order-events", event.getAggregateId(), event.getPayload())
            .whenComplete((result, ex) -&amp;gt; {
                if (ex == null) {
                    event.setStatus(OutboxEvent.OutboxStatus.PUBLISHED);
                    event.setUpdatedAt(Instant.now());
                    outboxRepository.save(event);
                } else {
                    handlePublishFailure(event, ex);
                }
            });
    } catch (Exception ex) {
        handlePublishFailure(event, ex);
    }
}

private void handlePublishFailure(OutboxEvent event, Throwable ex) {
    log.error("Failed to publish outbox event: {}", event.getId(), ex);
    event.setRetryCount(event.getRetryCount() + 1);
    event.setUpdatedAt(Instant.now());

    if (event.getRetryCount() &amp;gt;= 5) {
        event.setStatus(OutboxEvent.OutboxStatus.FAILED);
    }
    outboxRepository.save(event);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Golden Rule of At-Least-Once Delivery: Idempotent Consumers
The Outbox Pattern guarantees at-least-once delivery. If the publisher crashes right after emitting a message to Kafka but before executing outboxRepository.save(event), the message will be published a second time when the worker reboots.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Downstream consumers must be strictly idempotent.&lt;/p&gt;

&lt;p&gt;How to Ensure Idempotence in Consumer Services:&lt;br&gt;
Unique Message Deduplication Table: Store the processed event_id in a local consumer table within a unique index constraint.&lt;/p&gt;

&lt;p&gt;Conditional Updates: Use SQL version checks (UPDATE accounts SET balance = balance - 100 WHERE id = ? AND version = ?).&lt;/p&gt;

&lt;p&gt;Database Constraints: Rely on natural primary keys (e.g., unique constraints on payment_transaction_id).&lt;/p&gt;

&lt;p&gt;Java&lt;/p&gt;

&lt;p&gt;@Component&lt;br&gt;
@Slf4j&lt;br&gt;
@RequiredArgsConstructor&lt;br&gt;
public class InventoryOrderConsumer {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;private final ProcessedEventRepository processedEventRepository;
private final InventoryService inventoryService;

@KafkaListener(topics = "order-events", groupId = "inventory-service")
@Transactional
public void handleOrderCreated(ConsumerRecord&amp;lt;String, String&amp;gt; record) {
    String eventId = record.key(); // or extract UUID from payload

    // 1. Deduplication check
    if (processedEventRepository.existsByEventId(eventId)) {
        log.info("Duplicate event detected. Skipping event id: {}", eventId);
        return;
    }

    // 2. Execute domain logic
    inventoryService.reserveStock(record.value());

    // 3. Mark processed in same transaction
    processedEventRepository.save(new ProcessedEventRecord(eventId, Instant.now()));
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Conclusion: Architectural Takeaways&lt;br&gt;
Failure Scenario    Traditional @Transactional  Transactional Outbox Pattern&lt;br&gt;
Downstream HTTP Timeout Inconsistent state / Data drift No external HTTP calls; 100% atomic&lt;br&gt;
Kafka Broker Outage Lost messages or rolled back business transactions  Events persist safely in DB; published upon recovery&lt;br&gt;
Throughput / Latency    Bottlenecked by slowest downstream API call Sub-5ms database commits; asynchronous dispatch&lt;br&gt;
Delivery Guarantee  Best-effort (Zero guarantees)   Guaranteed At-Least-Once&lt;br&gt;
Stop relying on single-node transaction abstractions to coordinate distributed systems. Isolate your writes to local relational bounds, make event dispatching asynchronous, and build your consumer microservices to be strictly idempotent.&lt;br&gt;
for cross-posting visit:&lt;a href="https://sandeeptechieeblogs.blogspot.com/" rel="noopener noreferrer"&gt;https://sandeeptechieeblogs.blogspot.com/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>ai</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The 3-Tier Spring Boot Optimization Playbook: From 800ms Latency to Sub-5ms Reliability</title>
      <dc:creator>sandeep chagalakonda</dc:creator>
      <pubDate>Thu, 01 Oct 2026 06:35:15 +0000</pubDate>
      <link>https://dev.to/sandeep_chagalakonda_6e60/the-3-tier-spring-boot-optimization-playbook-from-800ms-latency-to-sub-5ms-reliability-3g5c</link>
      <guid>https://dev.to/sandeep_chagalakonda_6e60/the-3-tier-spring-boot-optimization-playbook-from-800ms-latency-to-sub-5ms-reliability-3g5c</guid>
      <description>&lt;p&gt;In high-throughput systems, backend latency rarely stems from JVM CPU bottlenecks. In over 80% of production incidents, slow API endpoints trace back to three silent architectural traps: unmonitored database query multipliers (Hibernate N+1), misconfigured in-memory caching that leaks stale state, and connection pool starvation under concurrent load.&lt;/p&gt;

&lt;p&gt;Here is the step-by-step 3-tier playbook to diagnose, isolate, and systematically reduce an unoptimized Spring Boot REST endpoint from an 800ms bottleneck down to sub-5ms execution.&lt;/p&gt;

&lt;p&gt;Tier 1: Eliminate Database Query Multipliers (Hibernate N+1)&lt;br&gt;
Before touching Redis or scaling container replicas, inspect the raw SQL queries your persistence layer emits. Placing an in-memory cache over an inefficient query path merely masks an architectural flaw until cache misses crash your database under peak traffic.&lt;/p&gt;

&lt;p&gt;Consider a parent-child relationship where an Order entity contains a collection of OrderItem records:&lt;/p&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/entity"&gt;@entity&lt;/a&gt;&lt;br&gt;
@Table(name = "orders")&lt;br&gt;
public class Order {&lt;br&gt;
    &lt;a class="mentioned-user" href="https://dev.to/id"&gt;@id&lt;/a&gt;&lt;br&gt;
    @GeneratedValue(strategy = GenerationType.IDENTITY)&lt;br&gt;
    private Long id;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;private String customerReference;

@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List&amp;lt;OrderItem&amp;gt; items = new ArrayList&amp;lt;&amp;gt;();

// getters and setters
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
While FetchType.LAZY is appropriate default behavior, calling an unoptimized repository method triggers immediate query multiplication:&lt;/p&gt;

&lt;p&gt;// ❌ Emits 1 query for orders + N queries for line items&lt;br&gt;
List orders = orderRepository.findByStatus("COMPLETED");&lt;br&gt;
When downstream serialization or business logic accesses order.getItems() across 50 orders, Hibernate executes 51 separate network round trips to the database:&lt;/p&gt;

&lt;p&gt;-- 1 initial query&lt;br&gt;
SELECT id, customer_reference FROM orders WHERE status = 'COMPLETED';&lt;/p&gt;

&lt;p&gt;-- Followed by 50 individual queries&lt;br&gt;
SELECT id, order_id, product_name, price FROM order_items WHERE order_id = 1;&lt;br&gt;
SELECT id, order_id, product_name, price FROM order_items WHERE order_id = 2;&lt;br&gt;
-- ... repeats for every single row&lt;br&gt;
The Fix: Explicit JOIN FETCH&lt;br&gt;
Instruct the persistence provider to retrieve the entire entity graph in a single query via an inner or left outer join:&lt;/p&gt;

&lt;p&gt;public interface OrderRepository extends JpaRepository {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@Query("SELECT DISTINCT o FROM Order o LEFT JOIN FETCH o.items WHERE o.status = :status")
List&amp;lt;Order&amp;gt; findCompletedOrdersWithItems(@Param("status") String status);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Result: 51 queries collapse into exactly 1 single SQL round-trip. Baseline p95 query latency drops from ~800ms down to ~35ms immediately.&lt;/p&gt;

&lt;p&gt;Tier 2: Production-Grade Redis Caching with TTL &amp;amp; Eviction&lt;br&gt;
Once relational database queries are consolidated, introduce an in-memory Redis caching layer. The critical failure mode in Spring Boot caching is using @Cacheable without defining explicit Time-To-Live (TTL) or custom serialization, resulting in stale reads or memory leaks.&lt;/p&gt;

&lt;p&gt;Configure deterministic cache defaults with JSON serialization in your configuration layer:&lt;/p&gt;

&lt;p&gt;@Configuration&lt;br&gt;
@EnableCaching&lt;br&gt;
public class RedisCacheConfig {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
    RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofMinutes(15)) // Strict TTL to prevent stale state persistence
        .disableCachingNullValues()
        .serializeValuesWith(
            RedisSerializationContext.SerializationPair.fromSerializer(
                new GenericJackson2JsonRedisSerializer()
            )
        );

    return RedisCacheManager.builder(connectionFactory)
        .cacheDefaults(cacheConfig)
        .build();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Ensure write operations proactively clear or synchronize cache entries via @CacheEvict:&lt;/p&gt;

&lt;p&gt;@Service&lt;br&gt;
public class OrderService {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@Cacheable(value = "orders", key = "#id")
@Transactional(readOnly = true)
public OrderResponse getOrderById(Long id) {
    return orderRepository.findByIdWithItems(id)
        .map(OrderResponse::from)
        .orElseThrow(() -&amp;gt; new OrderNotFoundException(id));
}

@CacheEvict(value = "orders", key = "#id")
@Transactional
public void updateOrderStatus(Long id, String status) {
    orderRepository.updateStatus(id, status);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Result: Repeated read traffic hits in-memory RAM rather than disk storage, dropping p95 latency to sub-5ms.&lt;/p&gt;

&lt;p&gt;Tier 3: HikariCP Connection Pool &amp;amp; Transaction Tuning&lt;br&gt;
Even with clean queries and fast caching, services will throttle under load if worker threads experience connection pool starvation. When long-running transactions lock database connections unnecessarily, incoming requests back up rapidly.&lt;/p&gt;

&lt;p&gt;Standardize connection pool boundaries inside your application.yml:&lt;/p&gt;

&lt;p&gt;spring:&lt;br&gt;
  datasource:&lt;br&gt;
    hikari:&lt;br&gt;
      maximum-pool-size: 20&lt;br&gt;
      minimum-idle: 10&lt;br&gt;
      idle-timeout: 300000&lt;br&gt;
      connection-timeout: 20000      # Fast-fail after 20s instead of blocking indefinitely&lt;br&gt;
      leak-detection-threshold: 2000 # Emit WARN log if a query holds a connection &amp;gt; 2 seconds&lt;br&gt;
  jpa:&lt;br&gt;
    open-in-view: false              # Prevent lazy-loading leaks outside service transactions&lt;br&gt;
    properties:&lt;br&gt;
      hibernate:&lt;br&gt;
        default_batch_fetch_size: 30 # Secondary safeguard against un-fetched collections&lt;br&gt;
Always apply @Transactional(readOnly = true) on read workflows. This signals Hibernate to skip dirty-checking snapshot creation, cutting memory allocation per request.&lt;/p&gt;

&lt;p&gt;Performance Benchmark Telemetry&lt;br&gt;
The table below illustrates benchmark metrics captured using Spring Boot Actuator and Postman under simulated concurrency (100 concurrent worker threads, 50 entities per payload):&lt;/p&gt;

&lt;p&gt;Optimization Stage  SQL Queries p95 Latency DB CPU Load&lt;br&gt;
Baseline (Default Lazy Loading) 51  820 ms  84%&lt;br&gt;
Tier 1: Explicit JOIN FETCH 1   36 ms   14%&lt;br&gt;
Tier 2 + 3: Redis Cache Hit + Tuned HikariCP    0   4 ms    &amp;lt; 2%&lt;br&gt;
Production Takeaways&lt;br&gt;
Audit before caching: Never slap Redis on an unprofiled database endpoint. Fix the underlying SQL round-trips first.&lt;br&gt;
Keep relationships LAZY, but fetch deliberately: Avoid switching entity fields to FetchType.EAGER, which causes unavoidable performance degradation across all usage patterns. Use JOIN FETCH or @EntityGraph only where child data is explicitly required.&lt;br&gt;
Always configure leak detection: A 2-second leak-detection-threshold in HikariCP identifies connection hogging before it causes an outright service outage. &lt;br&gt;
for cross-posting:&lt;a href="https://sandeeptechieeblogs.blogspot.com/" rel="noopener noreferrer"&gt;https://sandeeptechieeblogs.blogspot.com/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>springboot</category>
      <category>restapi</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Deployed My Spring Boot App 6 Different Ways: Here’s the One That Stopped the 3 AM Incidents</title>
      <dc:creator>sandeep chagalakonda</dc:creator>
      <pubDate>Mon, 28 Sep 2026 10:04:19 +0000</pubDate>
      <link>https://dev.to/sandeep_chagalakonda_6e60/i-deployed-my-spring-boot-app-6-different-ways-heres-the-one-that-stopped-the-3-am-incidents-28gj</link>
      <guid>https://dev.to/sandeep_chagalakonda_6e60/i-deployed-my-spring-boot-app-6-different-ways-heres-the-one-that-stopped-the-3-am-incidents-28gj</guid>
      <description>&lt;p&gt;Disclosure: This article contains an affiliate/referral link. If you choose to sign up through it, I may earn a small credit at no additional cost to you. I only recommend infrastructure configurations I run in production.&lt;/p&gt;

&lt;p&gt;I have deployed the exact same Spring Boot order management service six different ways over the past two years: Heroku, AWS EC2 with a hand-rolled Nginx reverse proxy, AWS Elastic Beanstalk, an unmanaged bare-metal VPS, Railway, and finally, a containerized setup on DigitalOcean Droplets backed by Managed PostgreSQL.&lt;/p&gt;

&lt;p&gt;Each deployment taught me a distinct lesson. Most of those lessons arrived in the form of elevated error rates, silent SSL dropouts, or surprise billing alerts. If you have followed my previous posts on fixing N+1 queries, configuring Redis caching layers, or hardening CI/CD pipelines, you know that code defects are only half the battle. How and where you host that code determines whether your application stays resilient under real traffic.&lt;/p&gt;

&lt;p&gt;Below is an honest, production-tested comparison of all six platforms—breaking down real monthly costs, operational burden, JVM memory constraints, and the exact configuration I rely on today.&lt;/p&gt;




&lt;p&gt;The 6 Spring Boot Hosting Options Analyzed&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Heroku: Frictionless Start, Inflexible Scaling
Setup Time: 5 minutes
Realistic Monthly Cost: $7 to $25+ per month (Postgres add-on included)
Operational Burden: Low
The Reality: Git-push deployments make Heroku convenient for proofs of concept. However, since the retirement of the free tier, entry-level dynos suffer from aggressive sleeping mechanisms that cause 15–30 second cold starts—an unacceptable latency spike for REST APIs. Add-ons like managed data stores scale steeply in price, making it inefficient for production JVM workloads.&lt;/li&gt;
&lt;li&gt;AWS EC2 (Manual Setup): Total Control, Heavy Maintenance
Setup Time: 2 to 3 hours
Realistic Monthly Cost: $8 to $20 per month (t3.small / t4g.small)
Operational Burden: High
The Reality: You provision the instance, configure systemd daemon services, map ports via an Nginx reverse proxy, establish security groups, and manage Let's Encrypt certificate renewals via cron. It grants maximum operational freedom, but transforms you into an on-call systems administrator. If a background automated renewal breaks or an OS-level package conflicts, troubleshooting falls entirely on you.&lt;/li&gt;
&lt;li&gt;AWS Elastic Beanstalk: Managed Abstraction, Complex Diagnostics
Setup Time: 45 minutes
Realistic Monthly Cost: $20 to $50 per month (EC2 + Application Load Balancer + RDS)
Operational Burden: Medium-High
The Reality: Elastic Beanstalk abstracts core provisioning while leveraging AWS infrastructure. However, debugging deployment failures requires navigating CloudWatch log streams, IAM role permission matrices, and environment health policies. The abstraction layer frequently obscures the underlying root cause when deployments stall.&lt;/li&gt;
&lt;li&gt;Railway: Excellent DX, Unpredictable Memory Invoicing
Setup Time: 10 minutes
Realistic Monthly Cost: $10 to $40+ per month (Usage-based)
Operational Burden: Low
The Reality: The developer experience is first-class, with seamless GitHub webhook builds and clear dashboards. The challenge stems from Spring Boot's baseline memory behavior. Because the Java Virtual Machine reserves memory upfront (heap plus metaspace and native threads), dynamic resource-metered pricing models can produce unexpected billing spikes even during low-throughput periods.&lt;/li&gt;
&lt;li&gt;Bare Unmanaged VPS: Lowest Price, Highest Fragility
Setup Time: Ongoing maintenance
Realistic Monthly Cost: $4 to $6 per month
Operational Burden: Critical
The Reality: Inexpensive on paper, but you own every operational failure. You must manually patch Linux kernel vulnerabilities, configure firewall tables (UFW/iptables), mitigate Docker layer disk bloat, and handle host-level failures without automated snapshot recovery.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;DigitalOcean (Dockerized Droplet + Managed DB): The Sweet Spot&lt;br&gt;
Setup Time: 15 to 20 minutes&lt;br&gt;
Realistic Monthly Cost: $12 to $27 per month&lt;br&gt;
Operational Burden: Low to Medium&lt;/p&gt;
&lt;h2&gt;
  
  
  The Reality: A 2GB RAM / 1 vCPU Droplet ($12/month) paired with DigitalOcean's Managed Database tier provides fixed, predictable monthly pricing alongside automated off-site backups and native metrics alerts. It delivers the isolation of a standard Linux VPS without the overhead of enterprise cloud complexity.
&lt;/h2&gt;

&lt;p&gt;Platform Comparison Matrix&lt;br&gt;
Hosting Platform    Initial Setup   Estimated Monthly Cost  Ops Overhead    Best Fit&lt;br&gt;
Heroku  5 mins  $7 – $25+ Low Prototypes &amp;amp; Demo APIs&lt;br&gt;
AWS EC2 (Manual)    2 – 3 hrs $8 – $20  High    Infrastructure Mastery&lt;br&gt;
AWS Elastic Beanstalk   45 mins $20 – $50+    Medium-High Existing AWS Ecosystems&lt;br&gt;
Railway 10 mins Usage-based ($10 – $40+)  Low Rapid App Delivery&lt;br&gt;
Bare-Metal VPS  Ongoing $4 – $6   Very High   Tight Budgets, High Ops Skill&lt;/p&gt;
&lt;h2&gt;
  
  
  DigitalOcean (Droplet)  15 – 20 mins  $12 – $27 Low-Medium  Production Backend Services
&lt;/h2&gt;

&lt;p&gt;Production Configuration: Docker Compose + DigitalOcean&lt;br&gt;
To avoid manual environment divergence, I deploy using immutable, commit-SHA tagged container images pulled directly from DigitalOcean Container Registry (DOCR) into a basic docker-compose.yml definition.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The Docker Compose Specification&lt;br&gt;
version: '3.8'&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;services:&lt;br&gt;
  order-service:&lt;br&gt;
    image: registry.digitalocean.com/springforge-registry/order-service:${IMAGE_TAG}&lt;br&gt;
    container_name: order-service-prod&lt;br&gt;
    restart: unless-stopped&lt;br&gt;
    ports:&lt;br&gt;
      - "8080:8080"&lt;br&gt;
    environment:&lt;br&gt;
      - SPRING_PROFILES_ACTIVE=prod&lt;br&gt;
      - SPRING_DATASOURCE_URL=${DB_JDBC_URL}&lt;br&gt;
      - SPRING_DATASOURCE_USERNAME=${DB_USER}&lt;br&gt;
      - SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD}&lt;br&gt;
      - JAVA_TOOL_OPTIONS=-Xms512m -Xmx1024m -XX:+UseG1GC&lt;br&gt;
    healthcheck:&lt;br&gt;
      test: ["CMD-SHELL", "curl -f &lt;a href="http://localhost:8080/actuator/health" rel="noopener noreferrer"&gt;http://localhost:8080/actuator/health&lt;/a&gt; || exit 1"]&lt;br&gt;
      interval: 30s&lt;br&gt;
      timeout: 5s&lt;br&gt;
      retries: 3&lt;br&gt;
      start_period: 40s&lt;br&gt;
    logging:&lt;br&gt;
      driver: "json-file"&lt;br&gt;
      options:&lt;br&gt;
        max-size: "20m"&lt;br&gt;
        max-file: "5"&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Automated Deployment Script
This script integrates with your CI runner or local build workflow to ensure zero untracked state on the host:&lt;/li&gt;
&lt;/ol&gt;

&lt;h1&gt;
  
  
  !/usr/bin/env bash
&lt;/h1&gt;

&lt;p&gt;set -euo pipefail&lt;/p&gt;

&lt;p&gt;COMMIT_TAG=$(git rev-parse --short HEAD)&lt;br&gt;
REGISTRY="registry.digitalocean.com/springforge-registry/order-service"&lt;/p&gt;

&lt;p&gt;echo "=== Building and Tagging Image: ${COMMIT_TAG} ==="&lt;br&gt;
docker build -t "${REGISTRY}:${COMMIT_TAG}" .&lt;/p&gt;

&lt;p&gt;echo "=== Authenticating and Pushing to DOCR ==="&lt;br&gt;
doctl registry login&lt;br&gt;
docker push "${REGISTRY}:${COMMIT_TAG}"&lt;/p&gt;

&lt;p&gt;echo "=== Executing Droplet Deployment ==="&lt;br&gt;
ssh deployer@your-droplet-ip \&lt;br&gt;
  "export IMAGE_TAG=${COMMIT_TAG} &amp;amp;&amp;amp; \&lt;br&gt;
   cd /srv/apps/order-service &amp;amp;&amp;amp; \&lt;br&gt;
   docker-compose pull order-service &amp;amp;&amp;amp; \&lt;br&gt;
   docker-compose up -d --remove-orphans"&lt;/p&gt;

&lt;p&gt;echo "=== Deployment Successful ==="&lt;br&gt;
This approach enforces three critical operational standards:&lt;/p&gt;

&lt;p&gt;Explicit JVM Heap Boundaries: Setting -Xms512m -Xmx1024m prevents Java from consuming the Droplet's remaining memory, leaving ample operating space for the OS kernel and metrics agents.&lt;br&gt;
Actuator Health Checks: The container engine automatically marks instances unhealthy if the Spring context drops or database connectivity fails.&lt;br&gt;
Log Rotation Caps: Restricting logs to five 20MB files prevents uncontrolled container logs from filling your storage volume.&lt;br&gt;
Try This Architecture for Free: If you are looking to set up a production-ready environment without immediate overhead, you can use the link below to receive $200 in free cloud credits over 60 days for new DigitalOcean accounts. That provides more than enough bandwidth to run a 2GB Droplet and a Managed PostgreSQL database at zero cost while testing your setup:&lt;/p&gt;

&lt;p&gt;DigitalOcean Referral Badge&lt;/p&gt;




&lt;p&gt;Frequently Asked Questions&lt;br&gt;
Is a 1GB Droplet sufficient for running Spring Boot?&lt;br&gt;
No. While an empty Spring Boot service might idle at 300–400MB, production workloads involving Jackson serialization, active database connections (HikariCP), and thread stacks can quickly trigger Out-Of-Memory (OOM) kills on a 1GB host. A 2GB RAM instance is the practical minimum baseline for reliable production execution.&lt;/p&gt;

&lt;p&gt;How does DigitalOcean differ from AWS for a solo engineer or small team?&lt;br&gt;
AWS provides an expansive ecosystem of managed services, but comes with steep IAM policy overhead, complex VPC networking, and variable data transfer costs. DigitalOcean delivers predictable pricing, direct configuration interfaces, and core infrastructure building blocks without dedicated DevOps overhead.&lt;/p&gt;

&lt;p&gt;What is the recommended database strategy for small production setups?&lt;br&gt;
Run your application layer inside a Docker container on the Droplet, but point its datasource to a Managed Database. Managing database replication, failover, and point-in-time recovery on an ephemeral application server introduces significant recovery risk.&lt;/p&gt;

&lt;p&gt;Recommendation links:&lt;a href="https://sandeeptechieeblogs.the-day-our-green-ci-pipeline-deployed" rel="noopener noreferrer"&gt;https://sandeeptechieeblogs.the-day-our-green-ci-pipeline-deployed&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://sandeeptechieebhow-my-microservices-architecture" rel="noopener noreferrer"&gt;https://sandeeptechieebhow-my-microservices-architecture&lt;/a&gt; &lt;br&gt;
for cross-posting visit:&lt;a href="https://sandeeptechieeblogs.blogspot.com" rel="noopener noreferrer"&gt;https://sandeeptechieeblogs.blogspot.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>productivity</category>
      <category>java</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>The Day Our Green CI Pipeline Deployed an 11-Day-Old Bug to Production</title>
      <dc:creator>sandeep chagalakonda</dc:creator>
      <pubDate>Sat, 19 Sep 2026 14:30:53 +0000</pubDate>
      <link>https://dev.to/sandeep_chagalakonda_6e60/the-day-our-green-ci-pipeline-deployed-an-11-day-old-bug-to-production-1n3c</link>
      <guid>https://dev.to/sandeep_chagalakonda_6e60/the-day-our-green-ci-pipeline-deployed-an-11-day-old-bug-to-production-1n3c</guid>
      <description>&lt;p&gt;The Day Our Deployment Pipeline Deployed the Wrong Version to Production&lt;br&gt;
We had 47 green checkmarks. Every test passed. CI was happy. The deploy button was practically begging to be pressed.&lt;/p&gt;

&lt;p&gt;I pressed it.&lt;/p&gt;

&lt;p&gt;Twenty minutes later, a customer support ticket came in: "The export feature is completely broken. It's showing data from three weeks ago."&lt;/p&gt;

&lt;p&gt;Then another. Then five more.&lt;/p&gt;

&lt;p&gt;We hadn't broken the export feature. We had deployed a version of the code from eleven days earlier — quietly overwriting a critical bug fix and reintroducing a data staleness issue we'd already resolved.&lt;/p&gt;

&lt;p&gt;The scariest part? Our pipeline said everything was successful. No errors. No warnings. Green across the board. We had just shipped the wrong version of our own application with total confidence.&lt;/p&gt;

&lt;p&gt;Here's how that happened, and the changes we made so it can't happen again.&lt;/p&gt;

&lt;p&gt;The Setup: A Pipeline That Looked Solid&lt;br&gt;
Our deployment flow looked reasonable on paper:&lt;/p&gt;

&lt;p&gt;Push to main → Run tests → Build Docker image → Push to registry → Deploy to production&lt;br&gt;
Jenkins pipeline, roughly:&lt;/p&gt;

&lt;p&gt;groovy&lt;br&gt;
pipeline {&lt;br&gt;
    agent any&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stages {
    stage('Checkout') {
        steps {
            git branch: 'main', url: 'https://github.com/company/order-service.git'
        }
    }

    stage('Test') {
        steps {
            sh 'mvn test'
        }
    }

    stage('Build') {
        steps {
            sh 'docker build -t order-service:latest .'
        }
    }

    stage('Push') {
        steps {
            sh 'docker push registry.company.com/order-service:latest'
        }
    }

    stage('Deploy') {
        steps {
            sh 'kubectl set image deployment/order-service order-service=registry.company.com/order-service:latest'
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Notice anything? I didn't, for months. Everything is tagged :latest.&lt;/p&gt;

&lt;p&gt;That single word — latest — was about to cause a very bad Tuesday.&lt;/p&gt;

&lt;p&gt;The Failure: How "Latest" Betrayed Us&lt;br&gt;
Here's the actual sequence of events, once we reconstructed it from logs:&lt;/p&gt;

&lt;p&gt;Step 1: A teammate had an old feature branch, checked out locally from 11 days earlier, that he was using to test something unrelated on his machine.&lt;/p&gt;

&lt;p&gt;Step 2: He accidentally ran a local script that pushed a Docker image tagged order-service:latest directly to our registry — bypassing CI entirely — while testing a local Docker build command he'd copy-pasted from an old note.&lt;/p&gt;

&lt;p&gt;Step 3: Fifteen minutes later, an unrelated, completely correct PR was merged to main. CI ran, all tests passed, and it triggered a deploy.&lt;/p&gt;

&lt;p&gt;Step 4: Here's the killer: our Kubernetes deployment was already configured to pull :latest, and due to imagePullPolicy: IfNotPresent on some nodes, several pods didn't even re-pull the image — they just kept running whatever :latest happened to resolve to on that node, which was now his 11-day-old accidental push.&lt;/p&gt;

&lt;p&gt;Step 5: New pods scheduled onto different nodes pulled the "real" latest deploy. Old pods on other nodes kept running the stale one.&lt;/p&gt;

&lt;p&gt;We ended up with a production cluster running two different versions of the same service simultaneously — split roughly across nodes — for 20+ minutes, with zero visibility into it.&lt;/p&gt;

&lt;p&gt;That's not a deployment bug. That's an entire deployment strategy bug.&lt;/p&gt;

&lt;p&gt;Why This Happens (The Concept)&lt;br&gt;
This incident had three separate root causes stacked on top of each other:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Mutable Tags (:latest is a Trap)&lt;br&gt;
:latest isn't a version — it's a moving pointer. Anyone, anywhere, with push access can silently redefine what "latest" means. There's no audit trail tying a specific deploy to a specific, immutable artifact.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;No Deployment Provenance&lt;br&gt;
We had no way to answer the question: "What commit SHA is actually running in production right now?" Not without SSH-ing into a pod and checking manually.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Inconsistent Image Pull Behavior&lt;br&gt;
imagePullPolicy: IfNotPresent means Kubernetes won't re-pull an image if it already has something cached under that tag name — even if the registry's :latest has moved on. Different nodes, different cache states, different code running.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Put together: a bypass of CI + a mutable tag + inconsistent pull behavior = two versions of production running side-by-side with a green pipeline the whole time.&lt;/p&gt;

&lt;p&gt;The Fix: Building a Pipeline You Can Actually Trust&lt;br&gt;
Fix #1: Never Deploy :latest. Tag by Commit SHA.&lt;br&gt;
This is the single highest-leverage change we made.&lt;/p&gt;

&lt;p&gt;groovy&lt;br&gt;
pipeline {&lt;br&gt;
    agent any&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;environment {
    IMAGE_TAG = "${env.GIT_COMMIT.take(8)}"
}

stages {
    stage('Checkout') {
        steps {
            checkout scm
            script {
                env.IMAGE_TAG = sh(script: "git rev-parse --short=8 HEAD", returnStdout: true).trim()
            }
        }
    }

    stage('Test') {
        steps {
            sh 'mvn test'
        }
    }

    stage('Build') {
        steps {
            sh "docker build -t order-service:${IMAGE_TAG} ."
        }
    }

    stage('Push') {
        steps {
            sh "docker push registry.company.com/order-service:${IMAGE_TAG}"
        }
    }

    stage('Deploy') {
        steps {
            sh "kubectl set image deployment/order-service order-service=registry.company.com/order-service:${IMAGE_TAG} --record"
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Now every image is immutable and traceable to an exact commit. order-service:a3f92c1e will always be exactly that code, forever. No ambiguity, no accidental overwrites, no "which version is this actually running."&lt;/p&gt;

&lt;p&gt;Rule I now enforce on every project: if your deployment artifact's tag can mean something different tomorrow than it does today, you don't have reproducible deployments — you have a guessing game.&lt;/p&gt;

&lt;p&gt;Fix #2: Block Manual Pushes to the Registry&lt;br&gt;
The teammate's accidental push should never have been possible in the first place.&lt;/p&gt;

&lt;p&gt;yaml&lt;/p&gt;

&lt;h1&gt;
  
  
  Registry access policy (example: AWS ECR repository policy)
&lt;/h1&gt;

&lt;p&gt;{&lt;br&gt;
  "Version": "2012-10-17",&lt;br&gt;
  "Statement": [&lt;br&gt;
    {&lt;br&gt;
      "Sid": "OnlyCIPipelineCanPush",&lt;br&gt;
      "Effect": "Deny",&lt;br&gt;
      "Principal": "*",&lt;br&gt;
      "Action": [&lt;br&gt;
        "ecr:PutImage",&lt;br&gt;
        "ecr:InitiateLayerUpload",&lt;br&gt;
        "ecr:UploadLayerPart",&lt;br&gt;
        "ecr:CompleteLayerUpload"&lt;br&gt;
      ],&lt;br&gt;
      "Condition": {&lt;br&gt;
        "StringNotEquals": {&lt;br&gt;
          "aws:PrincipalArn": "arn:aws:iam::123456789:role/jenkins-ci-role"&lt;br&gt;
        }&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
  ]&lt;br&gt;
}&lt;br&gt;
Only the CI service role can push images now. Individual developer credentials — however well-intentioned — cannot write directly to the production registry. If it doesn't go through the pipeline, it doesn't exist as a deployable artifact.&lt;/p&gt;

&lt;p&gt;Fix #3: Explicit imagePullPolicy: Always&lt;br&gt;
yaml&lt;/p&gt;

&lt;h1&gt;
  
  
  deployment.yaml
&lt;/h1&gt;

&lt;p&gt;apiVersion: apps/v1&lt;br&gt;
kind: Deployment&lt;br&gt;
metadata:&lt;br&gt;
  name: order-service&lt;br&gt;
spec:&lt;br&gt;
  replicas: 4&lt;br&gt;
  template:&lt;br&gt;
    spec:&lt;br&gt;
      containers:&lt;br&gt;
        - name: order-service&lt;br&gt;
          image: registry.company.com/order-service:a3f92c1e&lt;br&gt;
          imagePullPolicy: Always   # No ambiguity, no stale caches&lt;br&gt;
Combined with commit-SHA tagging, this is almost redundant now (since each deploy uses a unique tag), but it's cheap insurance against any node serving a cached image under a tag it shouldn't.&lt;/p&gt;

&lt;p&gt;Fix #4: A Deployment Manifest That Answers "What's Running Right Now?"&lt;br&gt;
We built a tiny endpoint that every service exposes, populated at build time:&lt;/p&gt;

&lt;p&gt;java&lt;br&gt;
@RestController&lt;br&gt;
public class VersionController {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@Value("${app.build.commit:unknown}")
private String commitSha;

@Value("${app.build.timestamp:unknown}")
private String buildTimestamp;

@GetMapping("/actuator/version")
public Map&amp;lt;String, String&amp;gt; version() {
    Map&amp;lt;String, String&amp;gt; info = new HashMap&amp;lt;&amp;gt;();
    info.put("commit", commitSha);
    info.put("builtAt", buildTimestamp);
    return info;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Injected at build time:&lt;/p&gt;

&lt;p&gt;groovy&lt;br&gt;
stage('Build') {&lt;br&gt;
    steps {&lt;br&gt;
        sh """&lt;br&gt;
            docker build \&lt;br&gt;
              --build-arg GIT_COMMIT=${IMAGE_TAG} \&lt;br&gt;
              --build-arg BUILD_TIME=\$(date -u +%Y-%m-%dT%H:%M:%SZ) \&lt;br&gt;
              -t order-service:${IMAGE_TAG} .&lt;br&gt;
        """&lt;br&gt;
    }&lt;br&gt;
}&lt;br&gt;
Now, any time, from any environment: curl production-url/actuator/version tells you exactly what's running — no SSH, no guessing, no "let me check with the team who deployed last."&lt;/p&gt;

&lt;p&gt;During an incident, this alone can save 20 minutes of "wait, are we even sure what version is live?"&lt;/p&gt;

&lt;p&gt;Fix #5: A Rollback That Takes 30 Seconds, Not 30 Minutes&lt;br&gt;
Because every deploy is tagged with an immutable commit SHA, rollback became trivial:&lt;/p&gt;

&lt;p&gt;bash&lt;/p&gt;

&lt;h1&gt;
  
  
  Before: no idea what "the previous version" even means with :latest
&lt;/h1&gt;

&lt;h1&gt;
  
  
  After: exact, unambiguous rollback
&lt;/h1&gt;

&lt;p&gt;kubectl set image deployment/order-service \&lt;br&gt;
  order-service=registry.company.com/order-service:PREVIOUS_KNOWN_GOOD_SHA \&lt;br&gt;
  --record&lt;/p&gt;

&lt;p&gt;kubectl rollout status deployment/order-service&lt;br&gt;
We also added a one-command rollback script that pulls the last known-good SHA from our deployment history automatically:&lt;/p&gt;

&lt;p&gt;bash&lt;/p&gt;

&lt;h1&gt;
  
  
  !/bin/bash
&lt;/h1&gt;

&lt;h1&gt;
  
  
  rollback.sh
&lt;/h1&gt;

&lt;p&gt;LAST_GOOD_SHA=$(kubectl rollout history deployment/order-service | tail -2 | head -1 | awk '{print $1}')&lt;br&gt;
kubectl rollout undo deployment/order-service --to-revision=$LAST_GOOD_SHA&lt;br&gt;
echo "Rolled back to revision: $LAST_GOOD_SHA"&lt;br&gt;
What used to be a frantic 30-minute scramble (figure out what broke → find the last good version → manually redeploy it → hope it works) became a single command with a predictable outcome.&lt;/p&gt;

&lt;p&gt;The Results&lt;br&gt;
Metric  Before  After&lt;br&gt;
Time to identify "what's currently deployed"    15-20 mins (manual investigation)   &amp;lt;10 seconds (/actuator/version)&lt;br&gt;
Time to roll back a bad deploy  25-30 mins  Under 2 mins&lt;br&gt;
Unauthorized/manual registry pushes possible    Yes No (IAM-blocked)&lt;br&gt;
Ambiguous "latest" deployments  Constant risk   Eliminated&lt;br&gt;
Incidents caused by tag confusion   1 major (this one)  0 since&lt;br&gt;
The bigger shift wasn't any single fix — it was realizing that a deployment pipeline's job isn't just "make the code run in production." It's "make it provable which code is running in production, and make undoing a mistake boring."&lt;/p&gt;

&lt;p&gt;The Lessons&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;:latest is not a deployment strategy. It's a liability wearing a deployment strategy's clothes.&lt;br&gt;
If your tags aren't immutable, your deployments aren't reproducible — and if they're not reproducible, you can't debug them with confidence.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;CI passing green doesn't mean production is running what you think it's running.&lt;br&gt;
Those are two separate claims. Bridge that gap explicitly (version endpoints, deployment records) instead of assuming they're the same thing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Anything that CAN bypass your pipeline, eventually WILL bypass your pipeline.&lt;br&gt;
Not out of malice — out of a well-meaning developer testing something locally at the wrong moment. Lock down write access to production artifacts at the infrastructure level, not just as a team policy.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Rollback speed matters more than deploy speed.&lt;br&gt;
Everyone optimizes for fast deploys. Fewer teams optimize for "how fast can we undo a mistake at 2 AM." That second number is the one that determines how bad your worst incident gets.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;"It passed CI" and "it's safe" are different sentences.&lt;br&gt;
Tests validate code behavior. They say nothing about whether the artifact that reaches production is the artifact you think it is. You need both.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What I'd Tell a Team Setting Up CI/CD Today&lt;br&gt;
Don't just ask "how do we deploy fast?" Ask "if this deploy goes wrong at 2 AM, how do we prove what's running, and how fast can we undo it?"&lt;/p&gt;

&lt;p&gt;If you can't answer that second question in under a minute, you don't have a deployment pipeline — you have a deployment hope.&lt;/p&gt;

&lt;p&gt;Tag by commit SHA. Lock down your registry. Expose a version endpoint. Build a rollback script before you need it, not while you need it.&lt;/p&gt;

&lt;p&gt;Because the day you need it will not send you a warning.&lt;/p&gt;

&lt;p&gt;Questions? Comments? Drop them below — I read and reply to every one.&lt;/p&gt;

&lt;p&gt;Related Reading&lt;br&gt;
How My Microservices Architecture Collapsed (And How I Fixed It)&lt;br&gt;
Why Your Spring Boot API is Slow: The N+1 Query Problem&lt;br&gt;
Building a Secure JWT Authentication Filter in Spring Boot 3&lt;br&gt;
crossposting:&lt;a href="https://sandeeptechieeblogs.blogspot.com/2026/09/the-day-our-green-ci-pipeline-deployed.html" rel="noopener noreferrer"&gt;https://sandeeptechieeblogs.blogspot.com/2026/09/the-day-our-green-ci-pipeline-deployed.html&lt;/a&gt;&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>systemdesign</category>
      <category>java</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How My Microservices Architecture Collapsed (And How I Fixed It)</title>
      <dc:creator>sandeep chagalakonda</dc:creator>
      <pubDate>Fri, 11 Sep 2026 14:58:53 +0000</pubDate>
      <link>https://dev.to/sandeep_chagalakonda_6e60/how-my-microservices-architecture-collapsed-and-how-i-fixed-it-3o5d</link>
      <guid>https://dev.to/sandeep_chagalakonda_6e60/how-my-microservices-architecture-collapsed-and-how-i-fixed-it-3o5d</guid>
      <description>&lt;p&gt;It was 11 PM on a Friday when my phone started buzzing non-stop.&lt;/p&gt;

&lt;p&gt;Not the good kind of buzzing. The kind that means production is on fire.&lt;/p&gt;

&lt;p&gt;I opened Slack. 47 unread messages. The order service was throwing timeouts. The payment service couldn't reach the inventory service. Customers were seeing spinning loaders that never stopped spinning.&lt;/p&gt;

&lt;p&gt;Our "scalable microservices architecture" — the one I was so proud of three months earlier — had turned into a house of cards. And someone had just sneezed.&lt;/p&gt;

&lt;p&gt;Here's what happened, why it happened, and how I fixed it so it never happened again.&lt;/p&gt;

&lt;p&gt;The Setup: When Microservices Felt Like a Good Idea&lt;br&gt;
Six months earlier, we split our monolith into five services:&lt;/p&gt;

&lt;p&gt;Order Service → Payment Service → Inventory Service → Notification Service → Shipping Service&lt;br&gt;
On paper, this looked great:&lt;/p&gt;

&lt;p&gt;Each team owns their service&lt;br&gt;
Independent deployments&lt;br&gt;
Scale services individually&lt;br&gt;
Clean separation of concerns&lt;br&gt;
In practice, we had created a distributed monolith. Every service called every other service synchronously, and none of us had thought about what happens when one link in that chain gets slow — or breaks entirely.&lt;/p&gt;

&lt;p&gt;That Friday night, the inventory service started responding slowly because of a bad database index (unrelated issue, we'll get to that another time). But that slowness didn't stay contained. It spread like a virus through every service that depended on it.&lt;/p&gt;

&lt;p&gt;The Cascade: How One Slow Service Took Down Everything&lt;br&gt;
Here's the actual chain of failure, step by step:&lt;/p&gt;

&lt;p&gt;Step 1: Inventory service's database query started taking 8 seconds instead of 80ms (missing index after a schema migration).&lt;/p&gt;

&lt;p&gt;Step 2: Order service calls inventory service synchronously with a default timeout of... nothing. No timeout was configured. It just waited.&lt;/p&gt;

&lt;p&gt;// This is what we had - no timeout, no fallback&lt;br&gt;
@Service&lt;br&gt;
public class OrderService {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@Autowired
private RestTemplate restTemplate;

public InventoryResponse checkInventory(String productId) {
    // If inventory service hangs, this hangs FOREVER
    return restTemplate.getForObject(
        "http://inventory-service/api/v1/check/" + productId,
        InventoryResponse.class
    );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Step 3: Every incoming order request now held a thread hostage for 8+ seconds waiting on inventory.&lt;/p&gt;

&lt;p&gt;Step 4: Our order service had a fixed thread pool (Tomcat default: 200 threads). Within minutes, all 200 threads were stuck waiting on slow inventory calls.&lt;/p&gt;

&lt;p&gt;Step 5: New order requests couldn't get a thread at all. They started timing out immediately.&lt;/p&gt;

&lt;p&gt;Step 6: The payment service, which calls order service to confirm order status before processing payment, started timing out too.&lt;/p&gt;

&lt;p&gt;Step 7: Customers saw failed payments, stuck loaders, and some got charged without their order confirming.&lt;/p&gt;

&lt;p&gt;One slow database query in ONE service brought down the ENTIRE checkout flow.&lt;/p&gt;

&lt;p&gt;That's the nature of distributed systems: failures don't stay isolated unless you design them to.&lt;/p&gt;

&lt;p&gt;Why This Happens (The Concept Behind the Chaos)&lt;br&gt;
This is called a cascading failure, and it happens because of three missing safeguards:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;No Timeouts&lt;br&gt;
Without a timeout, a slow dependency becomes an infinitely slow dependency from your service's perspective. Your thread just waits.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;No Circuit Breakers&lt;br&gt;
A circuit breaker "trips" when a downstream service is failing too often, and stops sending requests to it temporarily — giving it room to recover instead of hammering it with more traffic while it's already struggling.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;No Bulkheads&lt;br&gt;
A bulkhead isolates resources (like thread pools) per dependency, so a problem in one integration can't consume ALL your available threads.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Without these three things, every synchronous call in your system is a potential single point of failure for your entire system — even if that call is to a "less important" service.&lt;/p&gt;

&lt;p&gt;The Fix: Building Resilience Into the Architecture&lt;br&gt;
Fix #1: Add Timeouts Everywhere (The Bare Minimum)&lt;br&gt;
This should have existed from day one. It didn't.&lt;/p&gt;

&lt;p&gt;@Configuration&lt;br&gt;
public class RestTemplateConfig {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@Bean
public RestTemplate restTemplate() {
    HttpComponentsClientHttpRequestFactory factory = 
        new HttpComponentsClientHttpRequestFactory();

    factory.setConnectTimeout(2000);  // 2 seconds to connect
    factory.setConnectionRequestTimeout(2000);
    factory.setReadTimeout(3000);     // 3 seconds to get a response

    return new RestTemplate(factory);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Rule of thumb: No external call should ever be allowed to hang indefinitely. Pick a timeout that's generous enough for normal traffic but short enough that a slow dependency fails fast instead of holding your service hostage.&lt;/p&gt;

&lt;p&gt;Fix #2: Circuit Breaker with Resilience4j&lt;br&gt;
We added Resilience4j to stop hammering a struggling service and to fail fast once we know it's unhealthy.&lt;/p&gt;

&lt;p&gt;Add dependency:&lt;/p&gt;

&lt;p&gt;&lt;br&gt;
    io.github.resilience4j&lt;br&gt;
    resilience4j-spring-boot3&lt;br&gt;
    2.1.0&lt;br&gt;
&lt;br&gt;
Configure the circuit breaker:&lt;/p&gt;

&lt;h1&gt;
  
  
  application.yml
&lt;/h1&gt;

&lt;p&gt;resilience4j:&lt;br&gt;
  circuitbreaker:&lt;br&gt;
    instances:&lt;br&gt;
      inventoryService:&lt;br&gt;
        sliding-window-size: 10&lt;br&gt;
        failure-rate-threshold: 50&lt;br&gt;
        wait-duration-in-open-state: 10s&lt;br&gt;
        permitted-number-of-calls-in-half-open-state: 3&lt;br&gt;
        automatic-transition-from-open-to-half-open-enabled: true&lt;br&gt;
  timelimiter:&lt;br&gt;
    instances:&lt;br&gt;
      inventoryService:&lt;br&gt;
        timeout-duration: 3s&lt;br&gt;
Apply it to the call:&lt;/p&gt;

&lt;p&gt;@Service&lt;br&gt;
public class OrderService {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@Autowired
private RestTemplate restTemplate;

@CircuitBreaker(name = "inventoryService", fallbackMethod = "inventoryFallback")
@TimeLimiter(name = "inventoryService")
public CompletableFuture&amp;lt;InventoryResponse&amp;gt; checkInventory(String productId) {
    return CompletableFuture.supplyAsync(() -&amp;gt; 
        restTemplate.getForObject(
            "http://inventory-service/api/v1/check/" + productId,
            InventoryResponse.class
        )
    );
}

// Called automatically when circuit is open or call fails
public CompletableFuture&amp;lt;InventoryResponse&amp;gt; inventoryFallback(String productId, Throwable t) {
    log.warn("Inventory service unavailable, using cached fallback for product: {}", productId);

    // Return cached last-known inventory state, or a safe default
    InventoryResponse fallback = inventoryCacheService.getLastKnownState(productId);
    return CompletableFuture.completedFuture(fallback);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
What this gives us:&lt;/p&gt;

&lt;p&gt;After 50% of recent calls fail, the circuit "opens" — we stop calling inventory service entirely for 10 seconds&lt;br&gt;
During that time, we use the fallback (cached inventory data) instead of hammering a struggling service&lt;br&gt;
After 10 seconds, it tries a few test calls ("half-open") to see if inventory service has recovered&lt;br&gt;
If it has, the circuit closes and normal traffic resumes&lt;br&gt;
This alone would have prevented 90% of that Friday night's damage.&lt;/p&gt;

&lt;p&gt;Fix #3: Bulkhead Pattern (Isolate Thread Pools)&lt;br&gt;
Even with timeouts, one slow dependency shouldn't be able to eat every thread in your application.&lt;/p&gt;

&lt;p&gt;resilience4j:&lt;br&gt;
  bulkhead:&lt;br&gt;
    instances:&lt;br&gt;
      inventoryService:&lt;br&gt;
        max-concurrent-calls: 20&lt;br&gt;
        max-wait-duration: 500ms&lt;br&gt;
      paymentService:&lt;br&gt;
        max-concurrent-calls: 30&lt;br&gt;
        max-wait-duration: 500ms&lt;br&gt;
@Bulkhead(name = "inventoryService", type = Bulkhead.Type.THREADPOOL)&lt;br&gt;
public CompletableFuture checkInventory(String productId) {&lt;br&gt;
    // Only 20 concurrent calls to inventory service allowed,&lt;br&gt;
    // regardless of what's happening elsewhere in the app&lt;br&gt;
    ...&lt;br&gt;
}&lt;br&gt;
Now, even if inventory service goes completely down, only 20 threads get stuck waiting on it — not all 200. Orders that don't depend on inventory keep flowing normally.&lt;/p&gt;

&lt;p&gt;Fix #4: Move Non-Critical Calls to Async (Message Queue)&lt;br&gt;
The biggest architectural change: not everything needs to be synchronous.&lt;/p&gt;

&lt;p&gt;Before (synchronous, blocking):&lt;/p&gt;

&lt;p&gt;Order placed → Wait for Payment → Wait for Inventory update → &lt;br&gt;
Wait for Notification sent → Wait for Shipping label created → &lt;br&gt;
Return response to customer&lt;br&gt;
Every one of those "waits" is a place where things can go wrong and block the customer.&lt;/p&gt;

&lt;p&gt;After (event-driven with RabbitMQ):&lt;/p&gt;

&lt;p&gt;@Service&lt;br&gt;
public class OrderService {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@Autowired
private RabbitTemplate rabbitTemplate;

public OrderResponse placeOrder(OrderRequest request) {
    // Only the CRITICAL path is synchronous
    Order order = createOrder(request);
    PaymentResult payment = paymentService.charge(order); // Must be sync

    if (payment.isSuccessful()) {
        order.setStatus(OrderStatus.CONFIRMED);
        orderRepository.save(order);

        // Everything else happens asynchronously
        rabbitTemplate.convertAndSend("order.confirmed", new OrderEvent(order.getId()));

        return new OrderResponse(order.getId(), "CONFIRMED");
    }

    order.setStatus(OrderStatus.FAILED);
    orderRepository.save(order);
    return new OrderResponse(order.getId(), "FAILED");
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;// Separate consumers handle the non-critical work independently&lt;br&gt;
@Component&lt;br&gt;
public class OrderEventConsumer {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@RabbitListener(queues = "inventory.update.queue")
public void updateInventory(OrderEvent event) {
    // If this fails, it retries independently.
    // It does NOT block the customer's checkout response.
    inventoryService.reserveStock(event.getOrderId());
}

@RabbitListener(queues = "notification.queue")
public void sendNotification(OrderEvent event) {
    notificationService.sendOrderConfirmation(event.getOrderId());
}

@RabbitListener(queues = "shipping.queue")
public void createShippingLabel(OrderEvent event) {
    shippingService.generateLabel(event.getOrderId());
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Why this matters: Inventory service being slow no longer affects whether a customer's payment goes through. It just means the inventory update happens a few seconds later than usual — invisible to the customer, and retryable if it fails.&lt;/p&gt;

&lt;p&gt;The rule I now follow: if a step doesn't need to block the customer's response, it doesn't belong in the synchronous path.&lt;/p&gt;

&lt;p&gt;Fix #5: Health Checks + Proper Monitoring&lt;br&gt;
We had no visibility into which service was struggling until customers started complaining. That's backwards.&lt;/p&gt;

&lt;p&gt;@Component&lt;br&gt;
public class InventoryServiceHealthIndicator implements HealthIndicator {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@Autowired
private RestTemplate restTemplate;

@Override
public Health health() {
    try {
        ResponseEntity&amp;lt;String&amp;gt; response = restTemplate.getForEntity(
            "http://inventory-service/actuator/health", String.class
        );

        if (response.getStatusCode().is2xxSuccessful()) {
            return Health.up().build();
        }
        return Health.down().withDetail("status", response.getStatusCode()).build();

    } catch (Exception e) {
        return Health.down().withException(e).build();
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Combined with Spring Boot Actuator + Prometheus + Grafana, we now get alerted the moment a service's response time creeps up — long before it becomes a full outage.&lt;/p&gt;

&lt;h1&gt;
  
  
  Alert rule (Prometheus)
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;alert: SlowInventoryResponses
expr: histogram_quantile(0.95, http_request_duration_seconds{service="inventory"}) &amp;gt; 1
for: 2m
annotations:
summary: "Inventory service p95 latency above 1s for 2 minutes"
That Friday night, this alert would have fired 20 minutes before the cascading failure even started.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Results&lt;br&gt;
Metric  Before  After&lt;br&gt;
Cascading failures (per quarter)    3   0&lt;br&gt;
Mean time to detect an issue    25+ mins (customer reports) 2 mins (automated alert)&lt;br&gt;
Thread pool exhaustion incidents    2   0&lt;br&gt;
Checkout success rate during partial outages    ~15%    ~92%&lt;br&gt;
On-call pages at 11 PM  Too many    Way fewer&lt;br&gt;
The most important number isn't in that table: customer trust. When checkout keeps working even while one internal service is having a bad night, customers never know anything went wrong at all. That's the actual goal of resilience engineering — not eliminating failures (impossible), but containing them so they don't cascade.&lt;/p&gt;

&lt;p&gt;The Lessons&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Microservices don't remove complexity — they redistribute it.&lt;br&gt;
A monolith fails as one unit. A microservices architecture fails as a graph, and failures can travel along edges you didn't think were critical.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Every synchronous call is a liability until proven otherwise.&lt;br&gt;
Ask of every inter-service call: "What happens to MY service if this one hangs for 30 seconds? For 5 minutes? Forever?" If you don't know the answer, you have a timeout gap.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Timeouts, circuit breakers, and bulkheads are not optional extras.&lt;br&gt;
They're as fundamental to a distributed system as exception handling is to a single application. Skipping them isn't "moving fast" — it's deferring an outage to a worse time (like 11 PM on a Friday).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Not every operation needs to be synchronous.&lt;br&gt;
If the customer doesn't need to wait for it, it belongs in a queue, not in the request path.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You can't fix what you can't see.&lt;br&gt;
Health checks and latency dashboards aren't nice-to-haves. They're what turns a 2 AM outage into a 2 PM "huh, that's interesting, let's fix it" ticket.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What I'd Tell Someone Starting Microservices Today&lt;br&gt;
Don't split a monolith into microservices because it's trendy. Split it because you have a real scaling or team-ownership problem that a monolith can't solve.&lt;/p&gt;

&lt;p&gt;And if you do split it, treat resilience patterns — timeouts, circuit breakers, bulkheads, async messaging, and observability — as part of the minimum viable architecture, not as a "phase 2" improvement you'll get to later.&lt;/p&gt;

&lt;p&gt;Because "later" showed up for us at 11 PM on a Friday. It probably will for you too.&lt;/p&gt;

&lt;p&gt;Questions? Comments? Drop them below. I read and reply to every comment.&lt;/p&gt;

&lt;p&gt;Related Reading&lt;br&gt;
Why Your Spring Boot API is Slow: The N+1 Query Problem&lt;br&gt;
How Redis Reduced My Spring Boot API Response Time from 800ms to 5ms&lt;br&gt;
Building a Secure JWT Authentication Filter in Spring Boot 3 &lt;br&gt;
Cross-posted from my blog:&lt;a href="https://sandeeptechieeblogs.blogspot.com/2026/09/how-my-microservices-architecture.html" rel="noopener noreferrer"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow for more production engineering insights on Java, Spring Boot, and distributed systems.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>java</category>
      <category>springboot</category>
      <category>productivity</category>
    </item>
    <item>
      <title>"Why Your Spring Boot API is Slow: The N+1 Query Problem (And How I Fixed It in Production)"</title>
      <dc:creator>sandeep chagalakonda</dc:creator>
      <pubDate>Sun, 06 Sep 2026 12:50:10 +0000</pubDate>
      <link>https://dev.to/sandeep_chagalakonda_6e60/why-your-spring-boot-api-is-slow-the-n1-query-problem-and-how-i-fixed-it-in-production-3pmk</link>
      <guid>https://dev.to/sandeep_chagalakonda_6e60/why-your-spring-boot-api-is-slow-the-n1-query-problem-and-how-i-fixed-it-in-production-3pmk</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3f1284nzacuv44fz3ff9.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3f1284nzacuv44fz3ff9.jpg" alt=" " width="800" height="533"&gt;&lt;/a&gt;I was debugging a Spring Boot API at 2 AM on a Tuesday when I realized something that should have been obvious: my database query was being executed 500 times per request.&lt;/p&gt;

&lt;p&gt;Not 5 times. Not 50 times.&lt;/p&gt;

&lt;p&gt;Five. Hundred. Times.&lt;/p&gt;

&lt;p&gt;One single user request → 500 SQL queries → API response time: 3.2 seconds.&lt;/p&gt;

&lt;p&gt;That's when I learned about the N+1 query problem. And how it nearly destroyed production.&lt;/p&gt;

&lt;p&gt;The Setup: Everything Seemed Fine&lt;br&gt;
Our food delivery microservice was working great. Orders were being processed, customers were happy, and performance looked good in development.&lt;/p&gt;

&lt;p&gt;Then we hit production traffic.&lt;/p&gt;

&lt;p&gt;Day 1: Latency: 200ms ✅ Day 5: Latency: 500ms ⚠️ Day 10: Latency: 1.2 seconds 🔴 Day 15: Latency: 3+ seconds 💥&lt;/p&gt;

&lt;p&gt;Our API was getting slower every single day. And we had no idea why.&lt;/p&gt;

&lt;p&gt;I grabbed a profiler and started investigating. That's when I found it: the N+1 query catastrophe.&lt;/p&gt;

&lt;p&gt;What Is the N+1 Query Problem?&lt;br&gt;
Imagine you want to fetch a list of orders with their customers.&lt;/p&gt;

&lt;p&gt;The naive approach:&lt;/p&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/entity"&gt;@entity&lt;/a&gt;&lt;br&gt;
public class Order {&lt;br&gt;
    &lt;a class="mentioned-user" href="https://dev.to/id"&gt;@id&lt;/a&gt;&lt;br&gt;
    private Long id;&lt;br&gt;
    private String orderNumber;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@ManyToOne
private Customer customer; // This relationship is the problem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;@Service&lt;br&gt;
public class OrderService {&lt;br&gt;
    @Autowired&lt;br&gt;
    private OrderRepository orderRepository;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public List&amp;lt;Order&amp;gt; getAllOrders() {
    return orderRepository.findAll(); // Query 1: Get all orders
    // For each order, fetch customer // Query 2, 3, 4, 5...
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Here's what happens in your database:&lt;/p&gt;

&lt;p&gt;-- Query 1: Get all orders (1 query)&lt;br&gt;
SELECT * FROM orders;&lt;/p&gt;

&lt;p&gt;-- Query 2: Get customer for order 1 (N queries)&lt;br&gt;
SELECT * FROM customers WHERE id = 101;&lt;/p&gt;

&lt;p&gt;-- Query 3: Get customer for order 2&lt;br&gt;
SELECT * FROM customers WHERE id = 102;&lt;/p&gt;

&lt;p&gt;-- Query 4: Get customer for order 3&lt;br&gt;
SELECT * FROM customers WHERE id = 103;&lt;/p&gt;

&lt;p&gt;-- ... repeat for every single order ...&lt;br&gt;
If you fetch 500 orders:&lt;/p&gt;

&lt;p&gt;1 query to get orders&lt;br&gt;
500 queries to get each customer&lt;br&gt;
Total: 501 queries&lt;br&gt;
That's the N+1 problem. You execute 1 query, then N more queries (one per result).&lt;/p&gt;

&lt;p&gt;At scale, this destroys performance.&lt;/p&gt;

&lt;p&gt;How I Discovered It (The Hard Way)&lt;br&gt;
I was looking at our order endpoint logs:&lt;/p&gt;

&lt;p&gt;GET /api/v1/orders&lt;br&gt;
Database queries: 487&lt;br&gt;
Query time: 2.8 seconds&lt;br&gt;
487 queries for a single API request.&lt;/p&gt;

&lt;p&gt;I added Spring Boot's query logging to see what was happening:&lt;/p&gt;

&lt;h1&gt;
  
  
  application.properties
&lt;/h1&gt;

&lt;p&gt;spring.jpa.properties.hibernate.format_sql=true&lt;br&gt;
spring.jpa.properties.hibernate.use_sql_comments=true&lt;br&gt;
logging.level.org.hibernate.SQL=DEBUG&lt;br&gt;
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE&lt;br&gt;
Then I made a single API call. Here's what the logs showed:&lt;/p&gt;

&lt;p&gt;Hibernate: select order0_.id, order0_.customer_id, order0_.order_number, order0_.total_amount from orders order0_ limit 100&lt;br&gt;
Hibernate: select customer0_.id, customer0_.name, customer0_.email from customers customer0_ where customer0_.id=?&lt;br&gt;
Hibernate: select customer0_.id, customer0_.name, customer0_.email from customers customer0_ where customer0_.id=?&lt;br&gt;
Hibernate: select customer0_.id, customer0_.name, customer0_.email from customers customer0_ where customer0_.id=?&lt;br&gt;
Hibernate: select customer0_.id, customer0_.name, customer0_.email from customers customer0_ where customer0_.id=?&lt;br&gt;
... (repeat 95 more times)&lt;br&gt;
My jaw dropped.&lt;/p&gt;

&lt;p&gt;Hibernate was fetching the customer for every single order, one at a time. It was supposed to be doing one fetch. Instead, it was doing 100+ queries.&lt;/p&gt;

&lt;p&gt;This is classic N+1 query problem.&lt;/p&gt;

&lt;p&gt;The Root Cause: Lazy Loading&lt;br&gt;
By default, JPA uses lazy loading for relationships:&lt;/p&gt;

&lt;p&gt;@ManyToOne(fetch = FetchType.LAZY) // Default behavior&lt;br&gt;
private Customer customer;&lt;br&gt;
This means:&lt;/p&gt;

&lt;p&gt;When you load an Order, the Customer is NOT loaded&lt;br&gt;
When you access order.getCustomer(), JPA fetches it then&lt;br&gt;
If you have 500 orders and access customer on each one → 500 queries&lt;br&gt;
This was my code:&lt;/p&gt;

&lt;p&gt;@GetMapping("/orders")&lt;br&gt;
public ResponseEntity&amp;gt; getAllOrders() {&lt;br&gt;
    List orders = orderService.getAllOrders();&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// This loop triggers the N+1 problem
List&amp;lt;OrderDTO&amp;gt; dtos = orders.stream()
    .map(order -&amp;gt; new OrderDTO(
        order.getId(),
        order.getOrderNumber(),
        order.getCustomer().getName() // Query executed here! 
    ))
    .collect(Collectors.toList());

return ResponseEntity.ok(dtos);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Every time the code accessed order.getCustomer().getName(), Hibernate fired a separate SQL query.&lt;/p&gt;

&lt;p&gt;Result: 1 query to get orders + 500 queries to get customers = 501 total queries.&lt;/p&gt;

&lt;p&gt;The Fix #1: Eager Loading (The Quick Fix)&lt;br&gt;
The simplest solution: tell JPA to fetch the customer when loading the order.&lt;/p&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/entity"&gt;@entity&lt;/a&gt;&lt;br&gt;
public class Order {&lt;br&gt;
    &lt;a class="mentioned-user" href="https://dev.to/id"&gt;@id&lt;/a&gt;&lt;br&gt;
    private Long id;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@ManyToOne(fetch = FetchType.EAGER) // Change to EAGER
private Customer customer;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Now Hibernate does:&lt;/p&gt;

&lt;p&gt;SELECT order0_.id, order0_.customer_id, order0_.order_number, customer1_.id, customer1_.name, customer1_.email&lt;br&gt;
FROM orders order0_&lt;br&gt;
LEFT JOIN customers customer1_ ON order0_.customer_id = customer1_.id&lt;br&gt;
Single query. 500 results.&lt;/p&gt;

&lt;p&gt;Result:&lt;/p&gt;

&lt;p&gt;Before: 501 queries, 2.8 seconds&lt;br&gt;
After: 1 query, 120ms&lt;br&gt;
That's 23x faster.&lt;/p&gt;

&lt;p&gt;Why This Isn't Always The Answer&lt;br&gt;
Problem: Eager loading loads customers even if you don't need them.&lt;/p&gt;

&lt;p&gt;Example: If you have another endpoint that just needs order numbers:&lt;/p&gt;

&lt;p&gt;@GetMapping("/orders/numbers")&lt;br&gt;
public List getOrderNumbers() {&lt;br&gt;
    List orders = orderRepository.findAll(); // Loads 500 customers unnecessarily&lt;br&gt;
    return orders.stream()&lt;br&gt;
        .map(Order::getOrderNumber)&lt;br&gt;
        .collect(Collectors.toList());&lt;br&gt;
}&lt;br&gt;
Now you're loading data you don't use. Wastes memory and database resources.&lt;/p&gt;

&lt;p&gt;Better approach: Use eager loading only where you need it.&lt;/p&gt;

&lt;p&gt;The Fix #2: Fetch Join (The Proper Fix)&lt;br&gt;
Instead of changing the entity, use JPQL fetch join in your query:&lt;/p&gt;

&lt;p&gt;@Repository&lt;br&gt;
public interface OrderRepository extends JpaRepository {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@Query("SELECT DISTINCT o FROM Order o " +
       "LEFT JOIN FETCH o.customer c " +
       "WHERE o.id IN :orderIds")
List&amp;lt;Order&amp;gt; findOrdersWithCustomers(@Param("orderIds") List&amp;lt;Long&amp;gt; orderIds);

// Or get all with customers
@Query("SELECT DISTINCT o FROM Order o " +
       "LEFT JOIN FETCH o.customer c")
List&amp;lt;Order&amp;gt; findAllWithCustomers();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Now update your service:&lt;/p&gt;

&lt;p&gt;@Service&lt;br&gt;
public class OrderService {&lt;br&gt;
    @Autowired&lt;br&gt;
    private OrderRepository orderRepository;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public List&amp;lt;Order&amp;gt; getAllOrdersWithCustomers() {
    return orderRepository.findAllWithCustomers(); // Uses fetch join
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Hibernate generates:&lt;/p&gt;

&lt;p&gt;SELECT DISTINCT order0_.id, order0_.customer_id, order0_.order_number, &lt;br&gt;
       customer1_.id, customer1_.name, customer1_.email&lt;br&gt;
FROM orders order0_&lt;br&gt;
LEFT JOIN customers customer1_ ON order0_.customer_id = customer1_.id&lt;br&gt;
Single query. All data loaded.&lt;/p&gt;

&lt;p&gt;Why this is better:&lt;/p&gt;

&lt;p&gt;✅ Only loads customer when you need it&lt;br&gt;
✅ Still uses one query (no N+1 problem)&lt;br&gt;
✅ You control when eager loading happens&lt;br&gt;
✅ Different queries can load different relationships&lt;br&gt;
The Fix #3: Projection (The Advanced Fix)&lt;br&gt;
Sometimes you don't need the full Order object. You just need specific fields:&lt;/p&gt;

&lt;p&gt;public interface OrderDTO {&lt;br&gt;
    Long getId();&lt;br&gt;
    String getOrderNumber();&lt;br&gt;
    String getCustomerName(); // Comes from customer table&lt;br&gt;
    BigDecimal getTotalAmount();&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;@Repository&lt;br&gt;
public interface OrderRepository extends JpaRepository {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@Query("SELECT new com.example.dto.OrderDTO(" +
       "o.id, o.orderNumber, c.name, o.totalAmount) " +
       "FROM Order o " +
       "LEFT JOIN o.customer c")
List&amp;lt;OrderDTO&amp;gt; findAllOrderDTOs();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Hibernate generates:&lt;/p&gt;

&lt;p&gt;SELECT order0_.id, order0_.order_number, customer1_.name, order0_.total_amount&lt;br&gt;
FROM orders order0_&lt;br&gt;
LEFT JOIN customers customer1_ ON order0_.customer_id = customer1_.id&lt;br&gt;
Single query. Only the fields you need.&lt;/p&gt;

&lt;p&gt;Why this is best for APIs:&lt;/p&gt;

&lt;p&gt;✅ Single query (no N+1)&lt;br&gt;
✅ Returns DTO directly (no mapping overhead)&lt;br&gt;
✅ Database returns only needed columns&lt;br&gt;
✅ Fastest option for REST responses&lt;br&gt;
The Real-World Fix (What I Did)&lt;br&gt;
In production, I did all three:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Identified N+1 queries:&lt;/li&gt;
&lt;/ol&gt;

&lt;h1&gt;
  
  
  Enable query logging to spot N+1 problems
&lt;/h1&gt;

&lt;p&gt;spring.jpa.properties.hibernate.generate_statistics=true&lt;br&gt;
logging.level.org.hibernate.stat=debug&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fixed the critical endpoints with fetch join:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;@GetMapping("/api/v1/orders")&lt;br&gt;
public ResponseEntity&amp;gt; getAllOrders(&lt;br&gt;
    @RequestParam(defaultValue = "0") int page,&lt;br&gt;
    @RequestParam(defaultValue = "50") int size) {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Uses fetch join - single query with pagination
Page&amp;lt;OrderDTO&amp;gt; orders = orderRepository.findAllOrderDTOs(
    PageRequest.of(page, size)
);

return ResponseEntity.ok(orders.getContent());
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Added Spring Data Specification for complex queries:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;@Repository&lt;br&gt;
public interface OrderRepository extends &lt;br&gt;
    JpaRepository,&lt;br&gt;
    JpaSpecificationExecutor {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Specifications handle complex queries efficiently
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;@Service&lt;br&gt;
public class OrderService {&lt;br&gt;
    public List searchOrders(OrderSearchCriteria criteria) {&lt;br&gt;
        return orderRepository.findAll((root, query, cb) -&amp;gt; {&lt;br&gt;
            Join customerJoin = root.join("customer", JoinType.LEFT);&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        // Complex query with joins - still single query
        Predicate predicate = cb.and(
            cb.like(customerJoin.get("name"), criteria.getCustomerName() + "%"),
            cb.greaterThan(root.get("totalAmount"), criteria.getMinAmount())
        );

        return predicate;
    });
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
The Results (Before &amp;amp; After)&lt;br&gt;
Metric  Before  After   Improvement&lt;br&gt;
Queries per request 487 1   487x ↓&lt;br&gt;
Response time   2.8s    120ms   23x ↓&lt;br&gt;
Database load   95% CPU 15% CPU 80% ↓&lt;br&gt;
Customer complaints 47  0   100% ↓&lt;br&gt;
After the fix:&lt;/p&gt;

&lt;p&gt;Orders endpoint latency: 120ms (was 2.8s)&lt;br&gt;
Database CPU dropped from 95% to 15%&lt;br&gt;
Concurrent users increased from 100 to 500 without slowdown&lt;br&gt;
Zero customer complaints about slow orders&lt;br&gt;
How To Prevent This In The Future&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enable query counting in development:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;spring.jpa.properties.hibernate.generate_statistics=true&lt;br&gt;
logging.level.org.hibernate.stat=debug&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Monitor queries in your tests:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/test"&gt;@test&lt;/a&gt;&lt;br&gt;
@Transactional&lt;br&gt;
public void testOrderFetch() {&lt;br&gt;
    // Hibernate counts queries during test&lt;br&gt;
    List orders = orderService.getAllOrders();&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// If this shows &amp;gt; 1 query, N+1 problem detected
// The test fails before production sees it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use database profiling tools:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;MySQL: Enable query logs and analyze slow queries&lt;br&gt;
PostgreSQL: Enable auto_explain&lt;br&gt;
Spring Boot Actuator: Monitor database metrics&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Code review checklist:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When you see this in a PR:&lt;/p&gt;

&lt;p&gt;List orders = orderRepository.findAll();&lt;br&gt;
orders.forEach(order -&amp;gt; {&lt;br&gt;
    String customerName = order.getCustomer().getName(); // RED FLAG&lt;br&gt;
});&lt;br&gt;
Ask: "Is there a way to fetch this with a single query?"&lt;/p&gt;

&lt;p&gt;The Lesson&lt;br&gt;
The N+1 query problem is invisible until it hits production.&lt;/p&gt;

&lt;p&gt;It doesn't show up in:&lt;/p&gt;

&lt;p&gt;❌ Unit tests (usually tiny datasets)&lt;br&gt;
❌ Local development (cache hides the problem)&lt;br&gt;
❌ Early production (low traffic)&lt;br&gt;
It explodes when:&lt;/p&gt;

&lt;p&gt;✅ Real data volume arrives&lt;br&gt;
✅ Multiple concurrent users&lt;br&gt;
✅ Database connection pool saturated&lt;br&gt;
✅ API timeout starts happening&lt;br&gt;
Prevention is easier than debugging:&lt;/p&gt;

&lt;p&gt;Use fetch joins for relationships&lt;br&gt;
Use projections for DTOs&lt;br&gt;
Monitor queries in development&lt;br&gt;
Test with realistic data volumes&lt;br&gt;
Next Steps&lt;br&gt;
If you're experiencing slow Spring Boot APIs:&lt;/p&gt;

&lt;p&gt;Enable Hibernate query logging&lt;br&gt;
Make a single API request&lt;br&gt;
Count the SQL queries&lt;br&gt;
If &amp;gt; 2-3 queries for simple endpoint → N+1 problem&lt;br&gt;
Use fetch join or projection to fix&lt;br&gt;
It usually takes 30 minutes to fix and saves your users hours of waiting.&lt;/p&gt;

&lt;p&gt;That Tuesday at 2 AM was frustrating. But it taught me something valuable: always think about how many database queries your code executes.&lt;/p&gt;

&lt;p&gt;Your users will thank you.&lt;/p&gt;

&lt;p&gt;Questions? Comments? Drop them below! I read every single comment and reply within 24 hours.&lt;/p&gt;

&lt;p&gt;Related Reading&lt;br&gt;
Spring Boot with Hibernate: Why Your Queries Are Slow&lt;br&gt;
JPA Fetch Strategies: Lazy vs Eager Loading Explained&lt;br&gt;
How to Debug Spring Boot Database Performance&lt;/p&gt;

&lt;h2&gt;
  
  
  Next Article: "The Connection Pool Mistake That Cost Us $5,000 in RDS Bills"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cross-posted from my blog:&lt;/strong&gt; &lt;a href="https://sandeeptechieeblogs.blogspot.com/2026/09/why-your-spring-boot-api-is-slow-n1.html" rel="noopener noreferrer"&gt;Spring Boot N+1 Query Problem&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow for more production engineering insights on Java, Spring Boot, and distributed systems.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>java</category>
      <category>springboot</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
