Tag: Spring Boot

  • Hazelcast Transactional Outbox: Guaranteed Delivery

    Hazelcast Transactional Outbox: Guaranteed Delivery

    Part 8 in the “Building Event-Driven Microservices with Hazelcast” series


    Introduction

    In Part 7, we added circuit breakers and retry to protect saga listeners from transient failures on the consumer side. That covers what happens when a service receives an event and can’t process it. But we haven’t talked about what happens when the event never leaves the building.

    Quick refresher on our dual-instance architecture: each service runs an embedded Hazelcast instance for local Jet pipeline processing and a client connected to the shared cluster for cross-service ITopic communication. After the pipeline processes an event, the EventSourcingController republishes it to the shared cluster so saga listeners in other services can react.

    That republish step? It was a fire-and-forget call:

    // The old approach — fragile
    try {
        ITopic<GenericRecord> topic = sharedHazelcast.getTopic(pending.eventType);
        topic.publish(pending.eventRecord);
    } catch (Exception e) {
        logger.warn("Failed to republish event {}: {}", pending.eventType, e.getMessage());
        // Event is permanently lost!
    }
    

    If the shared cluster is unreachable — network partition, cluster restart, someone tripping over the power cable — the event vanishes. The saga never progresses. Eventually the saga timeout detector marks it as failed, but by then the original event data is gone and there’s nothing to retry.

    The Transactional Outbox Pattern fixes this. Instead of publishing directly to the shared cluster, the controller writes the event to a local outbox — an IMap on the embedded Hazelcast instance — and a separate publisher component picks it up and delivers it. If delivery fails, the entry stays in the outbox and gets retried.


    Why Direct Publishing Fails

    The problem is fundamental. Publishing to an external system (the shared cluster) and completing a local operation (the Jet pipeline) are two separate operations that can’t be made atomic.

    Failure timeline for direct publishing — the Jet pipeline updates the local event store and materialized view, but the publish to the shared cluster ITopic fails on a network partition and the event is lost with nothing left to retry

    The event is safely stored in the local event store and materialized view, but the cross-service notification is lost. You could retry in place, but that blocks the Jet pipeline for all events. You could schedule an async retry, but if the process restarts, that retry state is gone too.

    The outbox pattern trades immediate delivery for guaranteed delivery. Write to a durable local store, deliver asynchronously, retry until it works. It’s the standard solution in event-driven architectures for good reason.


    Architecture

    Transactional outbox architecture — the EventSourcingController writes each event to a durable local OUTBOX IMap and signals the OutboxPublisher via a semaphore; the publisher claims pending entries and delivers them to the shared cluster ITopic, retrying on failure

    The outbox IMap lives on the embedded Hazelcast instance — the same instance that hosts the event store and materialized views. Writing to it is a local operation. If the embedded instance is up (and it must be, since the pipeline just ran), the outbox write succeeds.


    The OutboxEntry

    Each outbox entry captures everything needed to deliver the event later:

    public class OutboxEntry {
    
        private String eventId;          // Matches the domain event's eventId
        private String eventType;        // ITopic name (e.g., "OrderCreated")
        private GenericRecord eventRecord; // The serialized event to publish
        private int retryCount;          // Delivery attempts so far
        private Status status;           // PENDING, DELIVERED, or FAILED
        private Instant createdAt;       // When the entry was created
        private Instant lastAttemptAt;   // When the last delivery attempt occurred
        private String failureReason;    // Most recent failure message
    
        public enum Status {
            PENDING,    // Awaiting delivery
            DELIVERED,  // Successfully published to shared cluster
            FAILED      // Permanently failed after max retries
        }
    }
    

    The eventRecord field is the full GenericRecord that needs to go to the shared cluster’s ITopic — same record the Jet pipeline produces, complete with saga metadata like sagaId and correlationId.


    OutboxStore: The Interface

    Six methods covering the full lifecycle:

    public interface OutboxStore {
    
        void write(OutboxEntry entry);
    
        List<OutboxEntry> pollPending(int maxBatchSize);
    
        void markDelivered(String eventId);
    
        void markFailed(String eventId, String reason);
    
        void incrementRetryCount(String eventId, String failureReason);
    
        long pendingCount();
    }
    

    Provider-agnostic. The Hazelcast implementation uses an IMap, but the interface could just as easily sit in front of a database table.


    HazelcastOutboxStore

    The Hazelcast implementation stores entries as Compact-serialized GenericRecord values in an IMap:

    public class HazelcastOutboxStore implements OutboxStore {
    
        private static final String SCHEMA_NAME = "OutboxEntry";
        private final IMap<String, GenericRecord> outboxMap;
    
        public HazelcastOutboxStore(HazelcastInstance hazelcast, MeterRegistry meterRegistry) {
            this.outboxMap = hazelcast.getMap(DEFAULT_MAP_NAME);
        }
    }
    

    You might wonder why we’re using GenericRecord instead of storing OutboxEntry Java objects directly. The problem is that OutboxEntry has an Instant field and a nested GenericRecord — neither of which Hazelcast’s zero-config Compact serialization can handle. We’d need a custom CompactSerializer registered on every Hazelcast instance configuration. Instead, we convert at the boundary:

    static GenericRecord toRecord(final OutboxEntry entry) {
        return GenericRecordBuilder.compact(SCHEMA_NAME)
                .setString("eventId", entry.getEventId())
                .setString("eventType", entry.getEventType())
                .setGenericRecord("eventRecord", entry.getEventRecord())
                .setInt32("retryCount", entry.getRetryCount())
                .setString("status", entry.getStatus().name())
                .setInt64("createdAt", entry.getCreatedAt().toEpochMilli())
                .setNullableInt64("lastAttemptAt",
                        entry.getLastAttemptAt() != null
                                ? entry.getLastAttemptAt().toEpochMilli() : null)
                .setString("failureReason", entry.getFailureReason())
                .build();
    }
    

    A few things going on here. Instant becomes int64 epoch millis — compact, sortable, unambiguous. lastAttemptAt uses setNullableInt64 because it’s null until the first delivery attempt. The nested eventRecord uses setGenericRecord, which Compact handles natively. And status is stored as the enum name string, which makes it readable in Management Center and queryable with Predicates.equal().

    Polling uses a Hazelcast predicate to filter by status, sorted by creation time so the oldest entries are delivered first:

    @Override
    public List<OutboxEntry> pollPending(final int maxBatchSize) {
        final Collection<GenericRecord> pending = outboxMap.values(
                Predicates.equal("status", OutboxEntry.Status.PENDING.name()));
    
        return pending.stream()
                .map(HazelcastOutboxStore::fromRecord)
                .sorted(Comparator.comparing(OutboxEntry::getCreatedAt))
                .limit(maxBatchSize)
                .collect(Collectors.toList());
    }
    

    The OutboxPublisher

    The publisher bridges the outbox and the shared cluster. The obvious approach is to poll on a fixed interval — once per second, say — but that adds latency we don’t need. We know exactly when a new entry arrives.

    Event-Driven Wake-Up

    The publisher uses a Semaphore to sleep until someone signals it:

    public class OutboxPublisher {
    
        private final Semaphore wakeUp = new Semaphore(0);
    
        public void notifyNewEntry() {
            // Release at most 1 permit — avoids unbounded accumulation
            if (wakeUp.availablePermits() == 0) {
                wakeUp.release();
            }
        }
    
        public boolean waitForWork() {
            try {
                return wakeUp.tryAcquire(
                        properties.getPollInterval().toMillis(),
                        TimeUnit.MILLISECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return false;
            }
        }
    }
    

    When the EventSourcingController writes an outbox entry, it calls notifyNewEntry() right after. The publisher wakes up, claims all pending entries, delivers them. Under normal conditions, the time from event creation to shared-cluster delivery is sub-millisecond.

    The poll interval (default 1 second) is the safety net. If a signal gets missed — maybe the publisher was busy with a previous batch — the timeout ensures nothing sits around for too long.

    This is a JVM-local semaphore, not a distributed one. That’s fine. When the service scales to multiple replicas with per-service clustering (ADR 013), each replica has its own publisher. The semaphore wakes the local publisher instantly for locally-written events. Events written by other replicas get picked up within the poll interval. The actual coordination — preventing two replicas from delivering the same event — happens in claimPending() via an atomic ClaimEntryProcessor on the IMap.

    The Publish Loop

    public void publishPendingEntries() {
        if (sharedHazelcast == null) {
            if (!noSharedClusterWarningLogged) {
                logger.warn("No shared Hazelcast instance — outbox delivery skipped");
                noSharedClusterWarningLogged = true;
            }
            return;
        }
    
        List<OutboxEntry> claimed = outboxStore.claimPending(
                properties.getMaxBatchSize(), memberUuid);
    
        if (claimed.isEmpty()) {
            return;
        }
    
        for (OutboxEntry entry : claimed) {
            try {
                ITopic<GenericRecord> topic = sharedHazelcast.getTopic(entry.getEventType());
                topic.publish(entry.getEventRecord());
                outboxStore.markDelivered(entry.getEventId());
            } catch (Exception e) {
                if (entry.getRetryCount() + 1 >= properties.getMaxRetries()) {
                    outboxStore.markFailed(entry.getEventId(),
                            "Max retries exceeded: " + e.getMessage());
                } else {
                    outboxStore.incrementRetryCount(entry.getEventId(), e.getMessage());
                }
            }
        }
    }
    

    Note claimPending rather than pollPending. The claiming mechanism uses an EntryProcessor to atomically transition entries from PENDING to CLAIMED, tagging them with the claiming member’s UUID. This prevents two publisher instances from delivering the same event — important once you’re running multiple replicas.

    When no shared cluster is configured (single-node dev mode), the publisher logs one warning and stops trying. Events pile up as PENDING in the outbox. They’ll drain as soon as a shared cluster appears.

    Retry escalation is per-entry:

    Attempt 1: fails → incrementRetryCount (retryCount=1)
    Attempt 2: fails → incrementRetryCount (retryCount=2)
    ...
    Attempt 5: fails → markFailed (retryCount=5 >= maxRetries=5)
    

    Once marked FAILED, the entry stops showing up in claim results. The failure reason is preserved for debugging.

    Scheduling

    OutboxAutoConfiguration hooks the publisher into Spring’s task scheduler:

    @EnableScheduling
    public class OutboxAutoConfiguration implements SchedulingConfigurer {
    
        @Override
        public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
            taskRegistrar.addFixedDelayTask(() -> {
                outboxPublisher.waitForWork();       // blocks until signaled or timeout
                outboxPublisher.publishPendingEntries();
            }, 1);  // 1ms loop delay — actual timing controlled by semaphore
        }
    }
    

    The 1ms fixed delay means the loop restarts almost immediately after each cycle, but waitForWork() controls the actual pacing. The thread blocks on the semaphore until either a permit is released or the poll interval elapses. Near-instant delivery under normal load, guaranteed pickup if a signal is missed.


    Integration with EventSourcingController

    The controller’s republishToSharedCluster now checks for an outbox store first:

    private void republishToSharedCluster(PendingCompletion<K> pending) {
        if (sharedHazelcast == null || pending.eventRecord == null || pending.eventType == null) {
            return;
        }
        if (outboxStore != null) {
            OutboxEntry entry = new OutboxEntry(
                    pending.completionInfo.getEventId(),
                    pending.eventType,
                    pending.eventRecord
            );
            outboxStore.write(entry);
            if (outboxPublisher != null) {
                outboxPublisher.notifyNewEntry();
            }
        } else {
            // Legacy direct publish (when outbox is disabled)
            try {
                ITopic<GenericRecord> topic = sharedHazelcast.getTopic(pending.eventType);
                topic.publish(pending.eventRecord);
            } catch (Exception e) {
                logger.warn("Failed to republish event {}: {}", pending.eventType, e.getMessage());
            }
        }
    }
    

    Fully backward compatible. When outboxStore is injected, events go through the durable path. When it’s null, you get the old fire-and-forget behavior. The OutboxStore is wired through each service’s config as an optional dependency:

    @Bean
    public EventSourcingController<Order, String, DomainEvent<Order, String>> orderController(
            HazelcastInstance hazelcastInstance,
            @Qualifier("hazelcastClient") HazelcastInstance hazelcastClient,
            @Autowired(required = false) OutboxStore outboxStore,
            ...) {
        return EventSourcingController.builder()
                .hazelcast(hazelcastInstance)
                .sharedHazelcast(hazelcastClient)
                .outboxStore(outboxStore)
                .build();
    }
    

    Delivery Guarantees

    The outbox provides at-least-once delivery. If the publisher crashes after publishing to the ITopic but before calling markDelivered(), the next cycle picks up the same entry and delivers it again. Events are never lost as long as the embedded Hazelcast instance’s IMap data is intact.

    At-least-once means consumers may see duplicates. That’s where the Idempotency Guard from Part 9 comes in — it deduplicates on the consumer side, complementing the outbox’s guaranteed delivery.

    As for ordering: events for the same aggregate are written to the outbox in sequence order (the Jet pipeline processes them sequentially), and claimPending sorts by createdAt. But if two events are pending simultaneously and the first one fails while the second succeeds, they’ll arrive out of order. For our saga use case that’s acceptable — each step is identified by sagaId and eventType, and the saga state machine handles duplicates and out-of-order delivery.


    Configuration

    framework.outbox.*

    Property Default Description
    enabled true Master toggle for the outbox pattern
    poll-interval 1000 (ms) Fallback interval if signal is missed
    max-batch-size 50 Maximum entries per poll cycle
    max-retries 5 Delivery attempts before permanent failure
    entry-ttl 24h How long DELIVERED entries survive in the map

    Metrics

    Metric Type Description
    outbox.entries.written Counter Events written to the outbox
    outbox.entries.delivered Counter Events delivered to shared cluster
    outbox.entries.failed Counter Events permanently failed
    outbox.publish.duration Timer Time per publish cycle

    To disable the outbox and use direct publishing:

    framework:
      outbox:
        enabled: false
    

    What’s Next

    The outbox guarantees events reach the shared cluster. But what happens when they get there and the consumer can’t process them? The consumer might crash, the business logic might throw, the circuit breaker might be open.

    In Part 9, we add two patterns that work together: a Dead Letter Queue that captures events that fail consumer-side processing, and an Idempotency Guard that prevents duplicate processing — the natural flip side of at-least-once delivery.


    Next up: Dead Letter Queues and Idempotency

    Previous: Circuit Breakers and Retry: Resilient Hazelcast Sagas

    Code: github.com/myawnhc/hazelcast-microservices-framework — clone it, docker-compose up, and the framework boots locally with sample data.
  • Circuit Breakers and Retry: Resilient Hazelcast Sagas

    Circuit Breakers and Retry: Resilient Hazelcast Sagas

    Part 7 in the “Building Event-Driven Microservices with Hazelcast” series


    Introduction

    A commercial airliner doesn’t fall out of the sky when an engine fails. It keeps flying. The remaining engine provides enough thrust to reach the nearest airport, the crew follows a well-rehearsed procedure, and the passengers — ideally — never know how close things got. Aviation engineers figured this out decades ago: you can’t prevent every failure, so you build the system to keep working when parts of it stop. (There’s even a great acronym for it — ETOPS, which officially stands for Extended Twin-engine Operations Performance Standards, but which pilots will tell you really means “Engines Turn Or Passengers Swim.”)

    Microservices need the same philosophy. Not because individual services fail as dramatically as a jet engine, but because they fail far more often. A garbage collection pause. A network blip. A downstream provider having a bad day. A deployment rolling through the cluster at 2 AM. In a monolith, these are minor hiccups — the kind of thing you might not even notice in the logs. In a distributed system where five services coordinate through asynchronous events, a hiccup in one service can propagate to all five in the time it takes to brew a cup of coffee.

    And the ways things go wrong are… creative. The catalog of distributed system failure modes is large enough to fill a textbook. Several textbooks, actually — and people have. Too many for a single pattern or a single blog post.

    So we’re spending the next three posts on resilience. This one covers circuit breakers and retry — protecting saga listeners when downstream services misbehave. Part 8 tackles the transactional outbox pattern, which guarantees events aren’t lost between producer and consumer. And Part 9 adds dead letter queues and idempotency guards — the safety nets for events that fail permanently or arrive more than once. Three different failure modes, three different mechanisms.

    Back in Part 4, we built a choreographed saga for order fulfillment. Three services — Inventory, Payment, and Order — coordinate through Hazelcast ITopic events published on a shared cluster. The happy path works beautifully. Without resilience patterns, though, a single struggling service can drag the whole saga down with it. A slow Payment Service fills up the Inventory Service’s thread pool with blocked calls. A transient network error permanently loses an event. A burst of failures overwhelms everything simultaneously.

    That’s what we’re fixing.


    The Problem: Cascading Failures

    Here’s the order fulfillment saga on a good day:

    Order fulfillment saga happy path — Inventory, Payment, and Order services exchanging OrderCreated, StockReserved, PaymentProcessed, and OrderConfirmed events over Hazelcast ITopic

    Each step is an ITopic message on the shared Hazelcast cluster. Each listener calls a local service method — IMap operations, Jet pipeline processing, further ITopic publishing. Events flow, state updates, everyone’s happy.

    Now imagine the Payment Service is having a rough morning. Some downstream payment provider is dragging, and every StockReserved event that arrives takes 30 seconds to process instead of the normal 50 milliseconds. Without any resilience mechanism, here’s what unfolds:

    1. Inventory keeps publishing StockReserved events at the normal rate
    2. Payment’s listener thread pool fills up with slow calls
    3. New events queue behind the blocked threads
    4. ITopic backpressure eventually slows the shared cluster itself
    5. Other listeners on the same cluster — including Inventory and Order — start seeing delays
    6. The entire saga grinds to a halt

    One service had a problem. Now every service has a problem. This is a cascade failure, and it’s the defining hazard of distributed architectures. The shared communication fabric that makes coordination possible is the same fabric that propagates failure.


    Enter Resilience4j

    The patterns we need — circuit breakers, retry with backoff, bulkheads, rate limiters — have been well understood for years. Netflix popularized them in the Java world with Hystrix, which became the standard library for microservice resilience through most of the 2010s. But Netflix put Hystrix into maintenance mode in 2018 and eventually stopped development entirely.

    The successor that emerged is Resilience4j. It’s a lightweight fault tolerance library for Java 8+ built around functional composition — you wrap a Supplier or Runnable with decorators, and the decorators handle the resilience logic. It’s not just a circuit breaker library, though that’s what most people know it for. It actually provides six core modules: circuit breaker, retry, bulkhead (resource isolation), rate limiter, time limiter, and cache. Each is standalone. You pick what you need and leave the rest on the shelf.

    There are other options — Failsafe is a solid zero-dependency alternative, and Alibaba’s Sentinel targets high-traffic rate limiting scenarios. But Resilience4j has become the de facto choice for Spring Boot microservices. The Spring integration is mature, Micrometer metrics work out of the box, and @ConfigurationProperties binding means your resilience settings live in the same YAML as everything else. For our framework, we’re using two of the six modules: CircuitBreaker and Retry.


    Circuit Breakers: Automatic Service Isolation

    A circuit breaker does what it sounds like. It monitors the failure rate of an operation and automatically stops calling it when failures exceed a threshold — the same idea as the breaker panel in your house. Too much current flows through the circuit, the breaker trips, the wiring doesn’t catch fire. In our case, “too much current” means too many failed calls, and “the wiring” is every other service sharing that communication path.

    Three States

    Circuit breaker state machine — CLOSED trips to OPEN when the failure rate crosses the threshold, OPEN moves to HALF-OPEN after the wait duration, and HALF-OPEN returns to CLOSED on success or back to OPEN on failure

    CLOSED is normal operation. All calls pass through, and the circuit breaker quietly records outcomes in a sliding window. OPEN means the breaker has tripped — all calls are immediately rejected with a CallNotPermittedException, and no load reaches the downstream service at all. HALF-OPEN is the recovery probe: a limited number of test calls pass through. If they succeed, the breaker returns to CLOSED. If they fail, back to OPEN. Rinse and repeat until the downstream service gets its act together.

    The Framework’s ResilientServiceInvoker

    Rather than sprinkling Resilience4j decorators at every call site, we centralized everything into ResilientServiceInvoker:

    public class ResilientServiceInvoker implements ResilientOperations {
    
        private final CircuitBreakerRegistry circuitBreakerRegistry;
        private final RetryRegistry retryRegistry;
        private final ResilienceProperties properties;
    
        public <T> T execute(final String name, final Supplier<T> operation) {
            if (!properties.isEnabled()) {
                return operation.get();
            }
    
            final CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(name);
            final Retry retry = retryRegistry.retry(name);
    
            final Supplier<T> decoratedSupplier = CircuitBreaker.decorateSupplier(circuitBreaker,
                    Retry.decorateSupplier(retry, operation));
    
            try {
                return decoratedSupplier.get();
            } catch (CallNotPermittedException e) {
                logger.warn("Circuit breaker '{}' is OPEN — rejecting call", name);
                throw new ResilienceException(
                        "Circuit breaker '" + name + "' is open, call rejected", name, e);
            } catch (Exception e) {
                logger.error("Operation '{}' failed after retries: {}", name, e.getMessage());
                throw new ResilienceException(
                        "Operation '" + name + "' failed after retries", name, e);
            }
        }
    }
    

    A few things to notice here. Each call to execute(“inventory-stock-reservation”, …) creates or retrieves a circuit breaker and retry instance with that name. This means each saga step gets its own independent circuit breaker — a payment failure won’t trip the inventory breaker.

    The decoration order matters: retry wraps the operation first, then the circuit breaker wraps the retry. So the circuit breaker sees the final outcome after all retries are exhausted. A transient failure that succeeds on the second attempt counts as a success for the circuit breaker. If you stacked them the other way around, every individual failed attempt would register as a circuit breaker failure, and you’d trip the breaker much faster than you intended.

    And there’s a kill switch. When framework.resilience.enabled=false, the execute method just calls the operation directly. Zero overhead. This matters for testing and for environments where resilience is handled at a different layer — a service mesh, maybe, or a cloud provider’s load balancer.

    The ResilientOperations Interface

    We extract an interface from the concrete class:

    public interface ResilientOperations {
        <T> T execute(String name, Supplier<T> operation);
        void executeRunnable(String name, Runnable operation);
        <T> CompletableFuture<T> executeAsync(String name, Supplier<CompletableFuture<T>> operation);
    }
    

    This is the same workaround we used for ServiceClientOperations in Part 6. Java 25’s Mockito inline mock maker can’t mock concrete classes in certain JVM configurations, so you extract an interface and mock that instead. Not the most glamorous reason to create an abstraction, but it works.

    Three Flavors

    The invoker supports three calling patterns:

    // Synchronous — returns a value
    String result = invoker.execute("orderSaga", () -> processEvent(event));
    
    // Fire-and-forget — void operation
    invoker.executeRunnable("paymentListener", () -> publishToTopic(event));
    
    // Async — returns CompletableFuture
    CompletableFuture<Product> future = invoker.executeAsync("inventory-stock-reservation",
            () -> inventoryService.reserveStockForSaga(productId, quantity, ...));
    

    The async variant is the one our saga listeners actually use — inventory, payment, and order service calls all return CompletableFuture.


    Wiring into the Saga Listeners

    The saga listeners from Part 4 now inject ResilientOperations as an optional dependency:

    @Component
    public class InventorySagaListener {
    
        private final ProductService inventoryService;
        private final HazelcastInstance hazelcast;
        private ResilientOperations resilientServiceInvoker;
    
        @Autowired(required = false)
        public void setResilientOperations(ResilientOperations resilientServiceInvoker) {
            this.resilientServiceInvoker = resilientServiceInvoker;
        }
    

    That @Autowired(required = false) is doing important work. If resilience is disabled — or if the Resilience4j dependency isn’t even on the classpath — the listener still functions. It just calls the service directly, no wrapping. The saga worked before we added resilience; it should keep working without it.

    Each listener has a helper that handles the null check:

    private <T> CompletableFuture<T> executeWithResilience(
            final String name, final Supplier<CompletableFuture<T>> operation) {
        if (resilientServiceInvoker != null) {
            return resilientServiceInvoker.executeAsync(name, operation);
        }
        return operation.get();
    }
    

    And the actual saga step looks like this:

    executeWithResilience("inventory-stock-reservation",
            () -> inventoryService.reserveStockForSaga(
                    productId, quantity, orderId, sagaId, correlationId,
                    customerId, total, currency, "CREDIT_CARD"
            )
    ).whenComplete((product, error) -> {
        if (error != null) {
            sendToDeadLetterQueue(record, "OrderCreated", error);
        } else {
            logger.info("Stock reserved for saga: productId={}, quantity={}, orderId={}, sagaId={}",
                    productId, quantity, orderId, sagaId);
        }
    });
    

    The circuit breaker name inventory-stock-reservation is specific to this saga step. Each step across the three services gets its own name and its own circuit breaker:

    Circuit Breaker Name Saga Step Service
    inventory-stock-reservation Reserve stock on OrderCreated Inventory
    inventory-stock-release Release stock on compensation Inventory
    payment-processing Process payment on StockReserved Payment
    payment-refund Refund payment on compensation Payment
    order-confirmation Confirm order on PaymentProcessed Order
    order-cancellation Cancel order on compensation Order

    Six independent circuit breakers. If payment processing is struggling, the inventory breakers stay closed and keep doing their job.


    Retry with Exponential Backoff

    Transient failures — network blips, temporary overload, brief GC pauses — are the most common failure mode in distributed systems. Most of them resolve on their own within seconds. Retry is the first line of defense.

    The Thundering Herd

    But naive retry — retry immediately, same interval, keep hammering — can make things actively worse. Picture this: a service buckles under load, and 100 clients all get errors simultaneously. They all retry at 500ms. The service sees a spike of 100 simultaneous requests. It fails again. They all retry at 1000ms. Another spike. Same result.

    This is the thundering herd problem. Everyone backs off at the same fixed interval, and everyone comes stampeding back at the same moment. The retry mechanism that was supposed to help is the thing keeping the service down.

    Exponential backoff breaks the herd apart:

    Attempt 1: immediate
    Attempt 2: wait 500ms
    Attempt 3: wait 1000ms  (500ms × 2.0)
    Attempt 4: wait 2000ms  (1000ms × 2.0)
    

    The growing intervals give the struggling service breathing room. And because different callers started their retry sequences at slightly different moments, the backoff naturally staggers the waves. Each one arrives smaller and more spread out than the last. The herd thins itself out.

    Configuration

    The framework exposes all of this through ResilienceProperties:

    framework:
      resilience:
        enabled: true
        retry:
          max-attempts: 3
          wait-duration: 500ms
          enable-exponential-backoff: true
          exponential-backoff-multiplier: 2.0
    

    The auto-configuration translates these into a Resilience4j RetryConfig:

    @Bean
    @ConditionalOnMissingBean
    public RetryRegistry retryRegistry(final ResilienceProperties properties) {
        final ResilienceProperties.RetryProperties retryProps = properties.getRetry();
    
        final RetryConfig.Builder<?> builder = RetryConfig.custom()
                .maxAttempts(retryProps.getMaxAttempts())
                .retryOnException(e -> !(e instanceof NonRetryableException));
    
        if (retryProps.isEnableExponentialBackoff()) {
            builder.intervalFunction(IntervalFunction
                    .ofExponentialBackoff(
                            retryProps.getWaitDuration(),
                            retryProps.getExponentialBackoffMultiplier()));
        } else {
            builder.waitDuration(retryProps.getWaitDuration());
        }
    
        return RetryRegistry.of(builder.build());
    }
    

    Two things to note. The retryOnException predicate excludes NonRetryableException — we’ll get to that in a moment. And when enable-exponential-backoff is false, it falls back to a fixed interval between attempts.


    NonRetryableException: When to Stop Trying

    Not every failure is transient. “Payment declined” will never succeed on retry — the credit card is invalid. “Insufficient stock” is deterministic — the warehouse genuinely doesn’t have the product. Retrying these wastes time, wastes resources, and — if the circuit breaker is counting — burns through your failure budget for no reason.

    The framework defines a marker interface:

    public interface NonRetryableException {
        // Marker interface — business exceptions implement this to skip retry
    }
    

    Service exceptions opt in:

    public class InsufficientStockException extends RuntimeException
            implements NonRetryableException {
        public InsufficientStockException(String message) {
            super(message);
        }
    }
    
    public class PaymentDeclinedException extends RuntimeException
            implements NonRetryableException {
        public PaymentDeclinedException(String message) {
            super(message);
        }
    }
    

    Why a marker interface instead of a base class? Because these exceptions already extend RuntimeException. Java doesn’t have multiple inheritance, but it does have multiple interfaces. The marker lets any exception opt out of retry without changing its class hierarchy.

    The retry configuration’s predicate is one line:

    .retryOnException(e -> !(e instanceof NonRetryableException))
    

    When retry encounters one of these, it fails immediately. No backoff, no additional attempts. But the circuit breaker still records it as a failure — it still counts toward the failure rate threshold. This is the right behavior. If a service is returning “payment declined” for every single request, something is systematically wrong, and the circuit breaker should trip.


    Retry Observability

    Resilience4j publishes events for every retry attempt, and the framework hooks into them for structured logging and a custom metric:

    public class RetryEventListener {
    
        public RetryEventListener(final RetryRegistry retryRegistry,
                                  final MeterRegistry meterRegistry) {
            this.meterRegistry = meterRegistry;
    
            retryRegistry.getAllRetries().forEach(this::registerListeners);
            retryRegistry.getEventPublisher().onEntryAdded(
                    event -> registerListeners(event.getAddedEntry()));
        }
    
        private void registerListeners(final Retry retry) {
            final var eventPublisher = retry.getEventPublisher();
            eventPublisher.onRetry(this::onRetry);
            eventPublisher.onSuccess(this::onSuccess);
            eventPublisher.onError(this::onError);
            eventPublisher.onIgnoredError(this::onIgnoredError);
        }
    }
    

    Four event types give you the full picture:

    Event Log Level What happened
    onRetry WARN An attempt failed, trying again
    onSuccess INFO Eventually succeeded
    onError ERROR All retries exhausted
    onIgnoredError INFO Non-retryable, skipped retry

    That last one — onIgnoredError — needed a custom Micrometer counter because Resilience4j’s built-in TaggedRetryMetrics doesn’t track ignored errors:

    private void onIgnoredError(final RetryOnIgnoredErrorEvent event) {
        logger.info("Non-retryable exception for '{}', skipping retry: {}",
                event.getName(), event.getLastThrowable().getMessage());
    
        Counter.builder("framework.resilience.retry.ignored")
                .description("Count of non-retryable exceptions that skipped retry")
                .tag("name", event.getName())
                .register(meterRegistry)
                .increment();
    }
    

    In practice, the logs tell you a clear story. A transient failure that recovers:

    WARN  RetryEventListener - Retry attempt #1 for 'payment-processing': Connection refused
    WARN  RetryEventListener - Retry attempt #2 for 'payment-processing': Connection refused
    INFO  RetryEventListener - 'payment-processing' succeeded after 2 attempt(s)
    

    A business exception that gets kicked straight to the dead letter queue:

    INFO  RetryEventListener - Non-retryable exception for 'payment-processing',
          skipping retry: Insufficient funds for amount 15000.00
    

    The ResilienceException Wrapper

    When an operation exhausts all retries or gets rejected by an open circuit breaker, the framework wraps the failure in a ResilienceException:

    public class ResilienceException extends RuntimeException {
    
        private final String operationName;
    
        public ResilienceException(String message, String operationName, Throwable cause) {
            super(message, cause);
            this.operationName = operationName;
        }
    }
    

    The operationName field tells downstream handlers which circuit breaker failed. The dead letter queue integration (Part 9) uses this to classify failures:

    if (error instanceof ResilienceException) {
        logger.warn("Circuit breaker open, saga step deferred: eventId={}", eventId);
    } else {
        logger.error("Failed to process event: {}", eventId, error);
    }
    

    Auto-Configuration

    The whole resilience stack is wired through a single auto-configuration class:

    @Configuration
    @ConditionalOnClass(CircuitBreakerRegistry.class)
    @ConditionalOnProperty(name = "framework.resilience.enabled", matchIfMissing = true)
    @EnableConfigurationProperties(ResilienceProperties.class)
    public class ResilienceAutoConfiguration {
    
        @Bean @ConditionalOnMissingBean
        public CircuitBreakerRegistry circuitBreakerRegistry(ResilienceProperties properties) { ... }
    
        @Bean @ConditionalOnMissingBean
        public RetryRegistry retryRegistry(ResilienceProperties properties) { ... }
    
        @Bean @ConditionalOnMissingBean
        public ResilientServiceInvoker resilientServiceInvoker(...) { ... }
    
        @Bean @ConditionalOnMissingBean(TaggedCircuitBreakerMetrics.class)
        public TaggedCircuitBreakerMetrics taggedCircuitBreakerMetrics(...) { ... }
    
        @Bean @ConditionalOnMissingBean(TaggedRetryMetrics.class)
        public TaggedRetryMetrics taggedRetryMetrics(...) { ... }
    
        @Bean @ConditionalOnMissingBean
        public RetryEventListener retryEventListener(...) { ... }
    }
    

    Three conditionals control activation. @ConditionalOnClass(CircuitBreakerRegistry.class) means the whole thing only activates when Resilience4j is on the classpath — services that don’t include the dependency don’t get any resilience beans. @ConditionalOnProperty(…, matchIfMissing = true) means it’s enabled by default; set framework.resilience.enabled=false to turn it off. And every individual bean is @ConditionalOnMissingBean, so the application can override any piece by defining its own bean.

    Six beans total:

    1. CircuitBreakerRegistry — circuit breaker instances, configured from properties
    2. RetryRegistry — retry instances with optional exponential backoff
    3. ResilientServiceInvoker — the decorator that wraps operations
    4. TaggedCircuitBreakerMetrics — binds circuit breaker metrics to Micrometer
    5. TaggedRetryMetrics — binds retry metrics to Micrometer
    6. RetryEventListener — structured logging and the custom ignored-error counter

    Per-Instance Tuning

    Different saga steps have different tolerance for failure. Stock reservation should be fast and reliable — if it’s failing, something is seriously wrong, and we want the circuit to trip quickly. Payment processing, on the other hand… payment providers are notoriously flaky. You’d rather tolerate a higher failure rate and give the provider more time to sort itself out before you start rejecting everything.

    The framework supports per-instance overrides in each service’s application.yml:

    framework:
      resilience:
        enabled: true
        circuit-breaker:
          failure-rate-threshold: 50
          wait-duration-in-open-state: 10s
          sliding-window-size: 10
          minimum-number-of-calls: 5
          permitted-number-of-calls-in-half-open-state: 3
        retry:
          max-attempts: 3
          wait-duration: 500ms
          enable-exponential-backoff: true
          exponential-backoff-multiplier: 2.0
        instances:
          inventory-stock-reservation:
            circuit-breaker:
              failure-rate-threshold: 40
              wait-duration-in-open-state: 5s
            retry:
              max-attempts: 2
          payment-processing:
            circuit-breaker:
              failure-rate-threshold: 60
              wait-duration-in-open-state: 15s
            retry:
              max-attempts: 5
              wait-duration: 1s
    

    The instances map lets any named circuit breaker override the defaults:

    public CircuitBreakerProperties getCircuitBreakerForInstance(final String name) {
        final InstanceProperties instance = instances.get(name);
        if (instance != null && instance.getCircuitBreaker() != null) {
            return instance.getCircuitBreaker();
        }
        return circuitBreaker; // Fall back to defaults
    }
    

    So in this configuration, inventory-stock-reservation trips at 40% failure rate with a 5-second open state and only 2 retry attempts — stock checks are idempotent and fast, no point dragging things out. payment-processing tolerates 60% failure rate with a 15-second open state and 5 retries starting at 1-second intervals. With exponential backoff, that last attempt waits about 16 seconds. Payment providers get the patience they’ve trained us to give them.


    Metrics and Monitoring

    The auto-configuration binds circuit breaker and retry metrics to Micrometer, which exports to Prometheus for Grafana dashboards:

    Circuit Breaker Metrics

    Metric Type Description
    resilience4j_circuitbreaker_state Gauge Current state (0=CLOSED, 1=OPEN, 2=HALF_OPEN)
    resilience4j_circuitbreaker_calls_total Counter Total calls by outcome (successful, failed, not_permitted)
    resilience4j_circuitbreaker_failure_rate Gauge Current failure rate percentage
    resilience4j_circuitbreaker_buffered_calls Gauge Calls in sliding window

    Retry Metrics

    Metric Type Description
    resilience4j_retry_calls_total Counter Total calls by outcome (successful_without_retry, successful_with_retry, failed_with_retry, failed_without_retry)
    framework.resilience.retry.ignored Counter Non-retryable exceptions (tagged by name)

    These feed into Grafana panels for saga health — circuit breaker state timeline showing when breakers trip and recover, retry rate over time where a spike tells you something transient is happening, failure rate broken out by saga step so you can see which one is misbehaving, and the non-retryable exception count that separates business logic failures from infrastructure problems.


    Configuration Reference

    framework.resilience.*

    Property Default Description
    enabled true Master toggle for all resilience features
    circuit-breaker.failure-rate-threshold 50 Failure rate (%) to trip the breaker
    circuit-breaker.wait-duration-in-open-state 10s How long to stay open before testing
    circuit-breaker.sliding-window-size 10 Number of calls in the measurement window
    circuit-breaker.sliding-window-type COUNT_BASED COUNT_BASED or TIME_BASED
    circuit-breaker.minimum-number-of-calls 5 Minimum calls before evaluating failure rate
    circuit-breaker.permitted-number-of-calls-in-half-open-state 3 Test calls in half-open state
    retry.max-attempts 3 Maximum retry attempts (including initial)
    retry.wait-duration 500ms Base wait between retries
    retry.enable-exponential-backoff true Use exponential backoff
    retry.exponential-backoff-multiplier 2.0 Backoff multiplier
    instances.<name>.circuit-breaker.* (defaults) Per-instance circuit breaker overrides
    instances.<name>.retry.* (defaults) Per-instance retry overrides

    What’s Next

    Circuit breakers and retry handle one category of failure: transient problems during event consumption. The saga listener tries, the call fails, the retry policy kicks in, the circuit breaker keeps the damage from spreading. That covers the consumer side.

    But what about the producer side? When EventSourcingController needs to republish an event to the shared cluster and the cluster is temporarily unreachable, the event just… vanishes. No retry. No circuit breaker. Gone.

    That’s a different failure mode, and it needs a different mechanism. In Part 8, we add the transactional outbox pattern — a durable buffer between event production and cross-cluster delivery that guarantees no events are lost, even when the shared cluster is down. Then Part 9 closes the loop with dead letter queues and idempotency guards for events that exhaust all retries or arrive more than once.


    Next up: The Transactional Outbox Pattern with Hazelcast

    Previous: MCP Server for Microservices: AI-Powered Debugging

    Code: github.com/myawnhc/hazelcast-microservices-framework — clone it, docker-compose up, and the framework boots locally with sample data.
  • MCP Server for Microservices: AI-Powered Debugging

    MCP Server for Microservices: AI-Powered Debugging

    Part 6 in the “Building Event-Driven Microservices with Hazelcast” series


    Introduction

    Over the first five articles, we built an event sourcing framework, a Jet pipeline, materialized views, a choreographed saga pattern, and vector similarity search. That’s a lot of infrastructure. It also means that investigating a problem — say, a failed saga — involves chaining together five or six curl commands across four different services, reading JSON output with your eyes, extracting IDs by hand, and constructing the next request.

    Which is fine. It’s what we’ve always done. But there’s a better option now.

    The Model Context Protocol (MCP) is an open standard that lets AI assistants — Claude, ChatGPT, Copilot, whoever — call tools exposed by external servers. Instead of the assistant guessing at curl commands or asking you to copy-paste output, it directly queries your materialized views, submits events, inspects saga state, and runs demo scenarios.

    In this article, we build an MCP server that bridges AI assistants to our eCommerce microservices. And yes, there is something a little meta about using Claude to build a framework and then building a bridge so Claude can operate the framework. We’re going with it.


    Why Give an AI Access to Your Microservices?

    Consider a typical debugging session. A saga has failed, and you want to know why:

    # Step 1: Find failed sagas
    curl http://localhost:8083/api/sagas?status=FAILED
    
    # Step 2: Copy a saga ID from the JSON output
    curl http://localhost:8083/api/sagas/saga-a7f3e2
    
    # Step 3: Check the order that triggered it
    curl http://localhost:8083/api/orders/ord-12345
    
    # Step 4: Check the event history
    curl http://localhost:8083/api/orders/ord-12345/events
    
    # Step 5: Check if stock was released as part of compensation
    curl http://localhost:8082/api/products/prod-67890
    

    Five commands. Each one requires reading JSON output, finding the right ID, and constructing the next request. You’re doing the orchestration in your head, and — let’s be honest — that’s exactly the kind of tedious mechanical chaining that humans are bad at and computers are good at.

    With MCP, the same investigation is a single sentence:

    “Why did the most recent saga fail?”

    The AI calls list_sagas(status=”FAILED”), then inspect_saga(sagaId=”saga-a7f3e2″), then get_event_history(aggregateId=”ord-12345″, aggregateType=”Order”), interprets all the responses, and gives you a summary:

    “Saga saga-a7f3e2 failed at the payment step. Order ORD-12345 had a total of $15,000 which exceeded the $10,000 payment limit. Compensation ran successfully — stock for product PROD-67890 was released.”

    Five tool calls, zero curl commands, a root-cause analysis, and a recommendation. From one question.


    What Is MCP?

    MCP (Model Context Protocol) is an open specification by Anthropic that defines a standard interface between AI assistants and external tools. Think of it as a contract:

    MCP protocol sequence: the AI assistant sends tools/list and tools/call to the MCP server, which returns tool definitions and JSON results over JSON-RPC

    The protocol uses JSON-RPC 2.0 over one of two transports:

    Transport How It Works Best For
    stdio AI assistant launches the server as a subprocess; communicates via stdin/stdout Local development with Claude Code or Claude Desktop
    SSE (HTTP) Server runs as a web service; AI connects over HTTP with Server-Sent Events Docker, remote deployment, multi-user

    The AI assistant doesn’t need to know anything about Hazelcast, Jet pipelines, or event sourcing. It sees ten tools with descriptions and parameters. The MCP server handles the translation between “query the customer view” and “GET http://account-service:8081/api/customers.&#8221;


    Designing Tools Around Event Sourcing

    The hardest part of building an MCP server isn’t the protocol — it’s deciding what tools to expose. Too many and the AI gets confused about which one to use. Too few and it can’t do useful work. We went back and forth on this and started with seven, organized around the three concerns of an event-sourced system. Three more got added later for dead letter queue recovery, which we’ll get to in a moment.

    Queries (Read Current State)

    Tool What It Does
    query_view Read materialized views — current state of customers, products, orders, payments
    get_event_history Read the event log — how an entity reached its current state

    These map to the read side of CQRS. Views give you the “what,” event history gives you the “why.”

    Commands (Produce New Events)

    Tool What It Does
    submit_event Create customers, products, orders; cancel orders; process payments; refund payments
    run_demo Execute multi-step scenarios (happy path, payment failure, saga timeout, sample data)

    Each command produces domain events that flow through the Jet pipeline. run_demo chains multiple commands together to set up investigation targets — a failed payment saga, a timeout scenario, a happy path to compare against.

    Observability (Inspect the System)

    Tool What It Does
    inspect_saga View a saga’s status, steps completed, timing, and failure reason
    list_sagas Browse sagas filtered by status
    get_metrics Aggregated system metrics — saga counts, event throughput, active gauges

    Dead Letter Queue (Investigate and Replay Failures)

    Tool What It Does
    list_dlq_entries List failed events that landed in the dead letter queue, with a pending-count summary for quick triage
    inspect_dlq_entry View a single DLQ entry: event data, failure reason, saga context, replay count
    replay_dlq_entry Republish a DLQ entry’s event for reprocessing — after the cause is fixed

    We hadn’t built the DLQ machinery yet when the MCP server first shipped, so these three were added later. The investigation workflow — list, inspect, then decide to replay or not — turned out to map cleanly onto how a human operator works through a queue of failed events. Asking the AI to walk that with you, one entry at a time, is dramatically less tedious than the curl version.

    Ten tools, four categories, no overlap. The AI handles any reasonable question about the system, and tool selection stays reliable — you’d never call get_metrics when you meant query_view, or list_dlq_entries when you meant list_sagas. The shape of the tool decides which question it answers.


    Architecture: A Pure REST Proxy

    The MCP server sits between the AI assistant and the microservices:

    MCP server architecture: an AI assistant connects via the MCP protocol to a Spring Boot MCP server on port 8085, which proxies REST calls to the Account, Inventory, Order, and Payment services

    We made a deliberate choice here: the MCP server has no Hazelcast dependency. It doesn’t join any cluster, doesn’t read IMaps, doesn’t run Jet jobs. It’s a thin REST proxy that translates MCP tool calls into HTTP requests against the existing service APIs.

    Why go to the trouble of keeping them separate? Because coupling the MCP server to Hazelcast would mean classpath conflicts with the services, a dependency on the data layer that makes testing painful, and another component that needs Hazelcast configuration. As a pure proxy, the server needs maybe 128-256 MB of heap, has no classpath conflicts, and you can test every tool by mocking REST responses without running a single service.


    Implementation

    The ServiceClient

    All HTTP communication goes through one class:

    @Component
    public class ServiceClient implements ServiceClientOperations {
    
        private final McpServerProperties properties;
        private final RestClient restClient;
    
        public Map<String, Object> getEntity(String viewName, String id) {
            String url = resolveUrl(viewName) + "/" + id;
            String json = restClient.get().uri(url).retrieve().body(String.class);
            return parseMap(json);
        }
    
        String resolveUrl(String viewName) {
            return switch (viewName.toLowerCase()) {
                case "customer" -> properties.getAccountUrl() + "/api/customers";
                case "product"  -> properties.getInventoryUrl() + "/api/products";
                case "order"    -> properties.getOrderUrl() + "/api/orders";
                case "payment"  -> properties.getPaymentUrl() + "/api/payments";
                default -> throw new IllegalArgumentException("Unknown view: " + viewName);
            };
        }
    }
    

    That resolveUrl switch is the only place that knows which service owns which view. Every tool delegates to ServiceClient rather than making HTTP calls directly.

    The ServiceClientOperations interface exists because Mockito’s inline mock maker on Java 25 cannot mock concrete classes. We hit this wall across the framework — the solution every time was to extract an interface so tests can mock it. It’s a slightly annoying pattern, but it works.

    A Tool Implementation

    Each tool is a Spring @Service with a @Tool-annotated method. Here’s QueryViewTool:

    @Service
    public class QueryViewTool {
    
        private final ServiceClientOperations serviceClient;
    
        @Tool(description = "Query a materialized view. "
                + "Available views: customer, product, order, payment. "
                + "Provide a key to get a specific entity, or omit to list entities.")
        public String queryView(
                @ToolParam(description = "View to query: customer, product, order, or payment")
                String viewName,
                @ToolParam(description = "Optional: specific entity ID", required = false)
                String key,
                @ToolParam(description = "Max results when listing (default: 10)", required = false)
                Integer limit) {
    
            if (key != null && !key.isBlank()) {
                return toJson(serviceClient.getEntity(viewName, key));
            } else {
                int effectiveLimit = (limit != null && limit > 0) ? limit : 10;
                List<Map<String, Object>> results = serviceClient.listEntities(viewName, effectiveLimit);
                return toJson(Map.of(
                        "view", viewName,
                        "count", results.size(),
                        "entities", results
                ));
            }
        }
    }
    

    That @Tool description is doing real work. The AI reads it to decide which tool to call and what parameters to provide. If you’re vague — “query data” instead of “Query a materialized view. Available views: customer, product, order, payment” — the AI picks the wrong tool or provides wrong parameters. We learned this the hard way. Be specific. Name the available views. Explain what happens with versus without a key.

    The optional parameters with defaults matter too. When the AI omits key, the tool lists entities. When it omits limit, you get 10. This lets a single tool handle “show me all customers” and “look up customer cust-123” without the AI needing to figure out everything every time.

    Tool Registration

    All ten tools get registered in one place:

    @Configuration
    public class McpToolConfig {
    
        @Bean
        public ToolCallbackProvider mcpTools(QueryViewTool queryView,
                                             SubmitEventTool submitEvent,
                                             GetEventHistoryTool getEventHistory,
                                             InspectSagaTool inspectSaga,
                                             ListSagasTool listSagas,
                                             GetMetricsTool getMetrics,
                                             RunDemoTool runDemo,
                                             ListDlqEntriesTool listDlqEntries,
                                             InspectDlqEntryTool inspectDlqEntry,
                                             ReplayDlqEntryTool replayDlqEntry) {
            return MethodToolCallbackProvider.builder()
                    .toolObjects(queryView, submitEvent, getEventHistory,
                            inspectSaga, listSagas, getMetrics, runDemo,
                            listDlqEntries, inspectDlqEntry, replayDlqEntry)
                    .build();
        }
    }
    

    Spring AI’s MethodToolCallbackProvider scans each object for @Tool methods and registers them with the MCP server. When the AI calls tools/list, it gets back all ten tool definitions with their descriptions and parameter schemas.


    The Event Dispatch Pattern

    SubmitEventTool deserves a closer look because it maps a single tool to seven different service endpoints:

    Map<String, Object> dispatch(String eventType, Map<String, Object> payload) {
        return switch (eventType) {
            case "CreateCustomer"  -> serviceClient.createEntity("customer", payload);
            case "CreateProduct"   -> serviceClient.createEntity("product", payload);
            case "CreateOrder"     -> serviceClient.createEntity("order", payload);
            case "CancelOrder"     -> {
                String orderId = requireField(payload, "orderId");
                yield serviceClient.performAction("order", orderId, "cancel", payload, true);
            }
            case "ReserveStock"    -> {
                String productId = requireField(payload, "productId");
                yield serviceClient.performAction("product", productId, "stock/reserve", payload, false);
            }
            case "ProcessPayment"  -> serviceClient.createEntity("payment", payload);
            case "RefundPayment"   -> {
                String paymentId = requireField(payload, "paymentId");
                yield serviceClient.performAction("payment", paymentId, "refund", payload, false);
            }
            default -> throw new IllegalArgumentException("Unknown event type: " + eventType);
        };
    }
    

    The alternative would be seven separate tools — create_customer, create_product, and so on. We went with a single submit_event tool with an eventType discriminator because it mirrors the event sourcing model (the system is event-driven, the tool should feel event-driven), it keeps the total tool count at ten instead of sixteen, and the AI handles the dispatch naturally. When you say “create a customer named Alice,” it maps that to eventType=”CreateCustomer” without difficulty.


    The Demo Tool

    RunDemoTool is the most complex tool because each scenario chains multiple service calls:

    private Map<String, Object> runHappyPath() {
        // Step 1: Create customer
        Map<String, Object> customer = serviceClient.createEntity("customer", Map.of(
                "name", "Demo Customer",
                "email", "demo-" + shortId() + "@example.com",
                "address", "123 Demo Street"
        ));
    
        // Step 2: Create product
        Map<String, Object> product = serviceClient.createEntity("product", Map.of(
                "sku", "DEMO-" + shortId(),
                "name", "Demo Widget",
                "price", "29.99",
                "quantityOnHand", 100
        ));
    
        // Step 3: Create order (uses IDs from previous steps)
        String customerId = extractId(customer, "customerId");
        String productId = extractId(product, "productId");
        Map<String, Object> order = serviceClient.createEntity("order", Map.of(
                "customerId", customerId,
                "customerName", "Demo Customer",
                "lineItems", List.of(Map.of(
                        "productId", productId,
                        "productName", "Demo Widget",
                        "quantity", 2,
                        "unitPrice", 29.99
                ))
        ));
    
        return Map.of("scenario", "happy_path", "steps", List.of(...));
    }
    

    Each scenario uses shortId() — a UUID fragment — so you can run the same scenario multiple times without naming collisions. The payment_failure scenario creates a $16,500 order that exceeds the $10,000 payment limit, triggering saga compensation. The saga_timeout scenario creates an order with minimal stock, designed to hit the deadline. These are pre-built investigation targets — the AI equivalent of a test fixture.


    Stdio vs. SSE: Two Transport Modes

    Default: stdio (Local Development)

    # application.properties
    spring.main.web-application-type=none
    spring.ai.mcp.server.name=ecommerce-mcp-server

    The AI assistant launches the server as a subprocess and communicates via stdin/stdout using JSON-RPC:

    stdio transport: Claude Code spawns the MCP server as a java -jar subprocess and communicates over stdin and stdout using JSON-RPC 2.0

    No network port needed. This is the default for local development with Claude Code or Claude Desktop.

    Docker: SSE/HTTP (Networked Deployment)

    # application-docker.properties
    spring.main.web-application-type=servlet
    spring.ai.mcp.server.stdio=false
    server.port=8085

    In Docker, the MCP server runs as a web service with Server-Sent Events on port 8085:

    mcp-server:
      build: ../mcp-server
      ports:
        - "8085:8085"
      environment:
        - SPRING_PROFILES_ACTIVE=docker
        - MCP_SERVICES_ACCOUNT_URL=http://account-service:8081
        - MCP_SERVICES_INVENTORY_URL=http://inventory-service:8082
        - MCP_SERVICES_ORDER_URL=http://order-service:8083
        - MCP_SERVICES_PAYMENT_URL=http://payment-service:8084

    The profile switch is the only difference between the two modes. Same tool code, same behavior.


    Testing

    Each tool has unit tests that mock ServiceClientOperations:

    @ExtendWith(MockitoExtension.class)
    class QueryViewToolTest {
    
        @Mock
        private ServiceClientOperations serviceClient;
    
        private QueryViewTool queryViewTool;
    
        @BeforeEach
        void setUp() {
            queryViewTool = new QueryViewTool(serviceClient);
        }
    
        @Test
        void shouldQueryByKey() throws JsonProcessingException {
            when(serviceClient.getEntity("customer", "c1"))
                    .thenReturn(Map.of("customerId", "c1", "name", "Alice"));
    
            String result = queryViewTool.queryView("customer", "c1", null);
    
            verify(serviceClient).getEntity("customer", "c1");
            Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {});
            assertNotNull(parsed.get("customerId"));
        }
    }
    

    Eleven test classes cover all ten tools plus the ServiceClient. Add another six for the security layer (more on that below) and one integration suite, and the mcp-server module sits at 143 tests total.

    Integration tests use Spring’s ApplicationContextRunner to verify bean wiring without starting the MCP stdio transport (which would block in a test environment):

    @DisplayName("MCP Tool Integration")
    class McpToolIntegrationTest {
    
        private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
                .withConfiguration(AutoConfigurations.of(McpToolConfig.class))
                .withUserConfiguration(TestServiceClientConfig.class)
                .withBean(McpServerProperties.class);
    
        @Test
        void shouldCreateAllToolBeans() {
            contextRunner.run(context -> {
                assertThat(context).hasSingleBean(QueryViewTool.class);
                assertThat(context).hasSingleBean(SubmitEventTool.class);
                // ... all 10 tools
            });
        }
    
        @Test
        void shouldRegisterToolCallbackProvider() {
            contextRunner.run(context -> {
                ToolCallbackProvider provider = context.getBean(ToolCallbackProvider.class);
                assertThat(provider.getToolCallbacks()).hasSize(10);
            });
        }
    }
    

    Configuration

    The MCP server has exactly four configuration properties:

    mcp.services.account-url=http://localhost:8081
    mcp.services.inventory-url=http://localhost:8082
    mcp.services.order-url=http://localhost:8083
    mcp.services.payment-url=http://localhost:8084

    In Docker, these are overridden by environment variables pointing to container hostnames. That’s it. No Hazelcast configuration, no cluster membership, no pipeline setup.


    Setting Up the AI Assistant

    Claude Code

    Add to ~/.claude/claude_code_config.json:

    {
      "mcpServers": {
        "hazelcast-ecommerce": {
          "command": "java",
          "args": ["-jar", "mcp-server/target/mcp-server-1.0.0-SNAPSHOT.jar"]
        }
      }
    }
    

    Claude Desktop

    Add to claude_desktop_config.json:

    {
      "mcpServers": {
        "hazelcast-ecommerce": {
          "command": "java",
          "args": ["-jar", "/absolute/path/to/mcp-server-1.0.0-SNAPSHOT.jar"]
        }
      }
    }
    

    Docker (SSE)

    Point the AI assistant to the SSE endpoint: http://localhost:8085/mcp

    In all three cases, the AI discovers the ten tools via tools/list and starts using them immediately.


    A Real Conversation

    Here’s what a debugging session looks like with MCP connected:

    You: “Something’s wrong with recent orders. Can you check?”

    Assistant: calls list_sagas(status=”FAILED”)

    calls get_metrics()

    “I found 2 failed sagas out of 142 total. Your overall success rate is 94.4%. Let me investigate the failures.”

    calls inspect_saga(sagaId=”saga-fail-1″)

    calls inspect_saga(sagaId=”saga-fail-2″)

    “Both failures are payment rejections:

    1. saga-fail-1 (Order ORD-088): Total $15,000 exceeded the $10,000 payment limit. Stock was released via compensation.
    2. saga-fail-2 (Order ORD-091): Total $16,500, same payment limit issue. Stock also released.

    These aren’t bugs — the payment service is correctly rejecting orders above the limit. You might want to either raise the limit or add client-side validation to prevent orders over $10,000 from being submitted.”

    Five tool calls, zero curl commands, and a root-cause analysis with a recommendation. From one question. I’ll be honest — the first time I watched the AI chain together the right sequence of calls and arrive at a correct diagnosis, it felt a little eerie. Like watching someone drive your car better than you do.


    Authentication and Tool Authorization

    The first version of this server had no authentication, which is fine for local development and obviously not fine for anything else. So we’ve added API key authentication and role-based tool access — disabled by default to preserve backward compatibility, and enabled with a single property when you need it.

    mcp:
      security:
        enabled: true
        api-keys:
          viewer-key-12345: VIEWER
          operator-key-67890: OPERATOR
          admin-key-99999: ADMIN

    In HTTP/SSE mode the key arrives in the X-API-Key request header. In stdio mode it’s read from the MCP_API_KEY environment variable. Either way, the server resolves the key to a role, and a ToolAuthorizer checks whether the role is permitted to invoke the tool the AI just asked for.

    Three roles are defined:

    • VIEWER — Read-only. Can call query_view, get_event_history, inspect_saga, list_sagas, get_metrics, list_dlq_entries, and inspect_dlq_entry. Cannot modify state.
    • OPERATOR — Read plus write. Adds submit_event, run_demo, and replay_dlq_entry.
    • ADMIN — Same as OPERATOR today, reserved for future admin-only tools.

    run_demo is a good example of why the role split matters — it’s the kind of tool you absolutely do not want firing in production, and the default VIEWER key keeps that off the table. The viewer can do everything an SRE wants to do during an incident — query, inspect, look at metrics — but it can’t accidentally place an order.

    One layer is still missing: the MCP server authenticates its callers, but it doesn’t forward caller identity to the downstream microservices. For a real production deployment you’d want both. We’ll come back to that.


    Where This Goes Next

    A few directions we haven’t explored yet.

    MCP supports streaming responses, which we’d want for large result sets — listing thousands of events as a single JSON blob isn’t great. MCP also has resources, read-only data endpoints that the AI can reference as context without explicitly calling a tool. The materialized views are a natural fit for that.

    OAuth forwarding is the gap mentioned above — the MCP server’s caller identity needs to propagate down to the backend services if we want end-to-end auth in production. The plumbing exists in Spring Security; we just haven’t wired it up.

    And with the MCP server as a foundation, you could build specialized AI agents — an operations agent that monitors sagas and flags anomalies, a demo agent that walks users through the system, a testing agent that creates targeted test data and verifies compensation paths. We haven’t built any of these yet, but the tool layer is there.


    The MCP server adds a natural-language interface to everything we’ve built so far. Ten tools, a thin REST proxy, two transport modes, role-based authorization, 143 tests. It doesn’t add new capabilities to the data layer — it makes the existing capabilities accessible through conversation. And that turns out to matter more than it sounds like it should. The investigation that took five curl commands now takes one sentence. The demo that required a script and documentation now requires “show me the happy path.” The system that was only inspectable by people who knew the API endpoints is now inspectable by anyone who can ask a question.

    That’s where we’ll leave things for today.


    Next up: Circuit Breakers and Retry for Saga Resilience

    Previous: Vector Similarity Search with Hazelcast

    Code: github.com/myawnhc/hazelcast-microservices-framework — clone it, docker-compose up, and the framework boots locally with sample data.