DEV Community

sandeep chagalakonda
sandeep chagalakonda

Posted on

How My Microservices Architecture Collapsed (And How I Fixed It)

It was 11 PM on a Friday when my phone started buzzing non-stop.

Not the good kind of buzzing. The kind that means production is on fire.

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.

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.

Here's what happened, why it happened, and how I fixed it so it never happened again.

The Setup: When Microservices Felt Like a Good Idea
Six months earlier, we split our monolith into five services:

Order Service → Payment Service → Inventory Service → Notification Service → Shipping Service
On paper, this looked great:

Each team owns their service
Independent deployments
Scale services individually
Clean separation of concerns
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.

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.

The Cascade: How One Slow Service Took Down Everything
Here's the actual chain of failure, step by step:

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

Step 2: Order service calls inventory service synchronously with a default timeout of... nothing. No timeout was configured. It just waited.

// This is what we had - no timeout, no fallback
@Service
public class OrderService {

@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
    );
}
Enter fullscreen mode Exit fullscreen mode

}
Step 3: Every incoming order request now held a thread hostage for 8+ seconds waiting on inventory.

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.

Step 5: New order requests couldn't get a thread at all. They started timing out immediately.

Step 6: The payment service, which calls order service to confirm order status before processing payment, started timing out too.

Step 7: Customers saw failed payments, stuck loaders, and some got charged without their order confirming.

One slow database query in ONE service brought down the ENTIRE checkout flow.

That's the nature of distributed systems: failures don't stay isolated unless you design them to.

Why This Happens (The Concept Behind the Chaos)
This is called a cascading failure, and it happens because of three missing safeguards:

  1. No Timeouts
    Without a timeout, a slow dependency becomes an infinitely slow dependency from your service's perspective. Your thread just waits.

  2. No Circuit Breakers
    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.

  3. No Bulkheads
    A bulkhead isolates resources (like thread pools) per dependency, so a problem in one integration can't consume ALL your available threads.

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.

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

@Configuration
public class RestTemplateConfig {

@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);
}
Enter fullscreen mode Exit fullscreen mode

}
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.

Fix #2: Circuit Breaker with Resilience4j
We added Resilience4j to stop hammering a struggling service and to fail fast once we know it's unhealthy.

Add dependency:


io.github.resilience4j
resilience4j-spring-boot3
2.1.0

Configure the circuit breaker:

application.yml

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

@Service
public class OrderService {

@Autowired
private RestTemplate restTemplate;

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

// Called automatically when circuit is open or call fails
public CompletableFuture<InventoryResponse> 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);
}
Enter fullscreen mode Exit fullscreen mode

}
What this gives us:

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

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

resilience4j:
bulkhead:
instances:
inventoryService:
max-concurrent-calls: 20
max-wait-duration: 500ms
paymentService:
max-concurrent-calls: 30
max-wait-duration: 500ms
@Bulkhead(name = "inventoryService", type = Bulkhead.Type.THREADPOOL)
public CompletableFuture checkInventory(String productId) {
// Only 20 concurrent calls to inventory service allowed,
// regardless of what's happening elsewhere in the app
...
}
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.

Fix #4: Move Non-Critical Calls to Async (Message Queue)
The biggest architectural change: not everything needs to be synchronous.

Before (synchronous, blocking):

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

After (event-driven with RabbitMQ):

@Service
public class OrderService {

@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");
}
Enter fullscreen mode Exit fullscreen mode

}

// Separate consumers handle the non-critical work independently
@Component
public class OrderEventConsumer {

@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());
}
Enter fullscreen mode Exit fullscreen mode

}
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.

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.

Fix #5: Health Checks + Proper Monitoring
We had no visibility into which service was struggling until customers started complaining. That's backwards.

@Component
public class InventoryServiceHealthIndicator implements HealthIndicator {

@Autowired
private RestTemplate restTemplate;

@Override
public Health health() {
    try {
        ResponseEntity<String> 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();
    }
}
Enter fullscreen mode Exit fullscreen mode

}
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.

Alert rule (Prometheus)

  • alert: SlowInventoryResponses expr: histogram_quantile(0.95, http_request_duration_seconds{service="inventory"}) > 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.

The Results
Metric Before After
Cascading failures (per quarter) 3 0
Mean time to detect an issue 25+ mins (customer reports) 2 mins (automated alert)
Thread pool exhaustion incidents 2 0
Checkout success rate during partial outages ~15% ~92%
On-call pages at 11 PM Too many Way fewer
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.

The Lessons

  1. Microservices don't remove complexity — they redistribute it.
    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.

  2. Every synchronous call is a liability until proven otherwise.
    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.

  3. Timeouts, circuit breakers, and bulkheads are not optional extras.
    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).

  4. Not every operation needs to be synchronous.
    If the customer doesn't need to wait for it, it belongs in a queue, not in the request path.

  5. You can't fix what you can't see.
    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.

What I'd Tell Someone Starting Microservices Today
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.

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.

Because "later" showed up for us at 11 PM on a Friday. It probably will for you too.

Questions? Comments? Drop them below. I read and reply to every comment.

Related Reading
Why Your Spring Boot API is Slow: The N+1 Query Problem
How Redis Reduced My Spring Boot API Response Time from 800ms to 5ms
Building a Secure JWT Authentication Filter in Spring Boot 3
Cross-posted from my blog:

Follow for more production engineering insights on Java, Spring Boot, and distributed systems.

Top comments (0)