Building a Resilient Microservice Backbone with Kafka and Spring Boot
Introduction: From Synchronous Chains to Event-Driven Decoupling
In traditional microservice architectures, services often communicate via synchronous REST calls. While straightforward, this creates tight coupling: if the notification-service is down, the budget-service might fail to save a budget, or at least hang waiting for a response.
To build a truly resilient Personal Finance Management System (PFMS), we transitioned to an event-driven architecture using Apache Kafka. This allows services to publish events without knowing who consumes them, enabling asynchronous processing and fault tolerance.
Docker Setup: Orchestrating the Backbone
We use Docker Compose to spin up our Kafka cluster and monitoring tools. The configuration below defines three services: kafka (the broker), kafka-ui (a web interface), and portainer (container management).
A critical aspect of this setup is the network configuration. All services join the pfms-network, allowing them to communicate via service names (e.g., global-service-kafka) rather than IP addresses.
Key Environment Variables
KAFKA_ADVERTISED_LISTENERS: This is the most crucial setting. It tells Kafka how clients (our Spring Boot apps) should connect.INTERNAL://global-service-kafka:29092: Used by services running inside the Docker network.EXTERNAL://localhost:9092: Used by tools running on your host machine (like Kafka UI or your IDE).
KAFKA_LISTENERS: Binds Kafka to accept connections on all interfaces (0.0.0.0) inside the container.
docker-compose.yml
services:
kafka:
image: confluentinc/cp-kafka:7.5.0
container_name: global-service-kafka
ports:
- "9092:9092"
environment:
KAFKA_NODE_ID: 1
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT'
KAFKA_ADVERTISED_LISTENERS: 'INTERNAL://global-service-kafka:29092,EXTERNAL://localhost:9092'
KAFKA_INTER_BROKER_LISTENER_NAME: 'INTERNAL'
KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
KAFKA_PROCESS_ROLES: 'broker,controller'
KAFKA_CONTROLLER_QUORUM_VOTERS: '1@global-service-kafka:29093'
KAFKA_LISTENERS: 'INTERNAL://0.0.0.0:29092,EXTERNAL://0.0.0.0:9092,CONTROLLER://0.0.0.0:29093'
CLUSTER_ID: 'MkU3OEVBNTcwNTJENDM2Qk'
networks:
- pfms-network
kafka-ui:
image: provectuslabs/kafka-ui:latest
container_name: kafka-ui
ports:
- "9094:8080"
depends_on:
- kafka
environment:
KAFKA_CLUSTERS_0_NAME: local-cluster
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: global-service-kafka:29092
DYNAMIC_CONFIG_ENABLED: 'true'
networks:
- pfms-network
portainer:
image: portainer/portainer-ce:latest
container_name: portainer
restart: always
ports:
- "9443:9443"
- "9000:9000"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- portainer_data:/data
networks:
- pfms-network
networks:
pfms-network:
name: pfms-network # This forces the name to be exactly 'pfms-network'
volumes:
portainer_data:
The Full Event Chain: Budget Save → Kafka → Notification
When a user creates a budget, the event doesn’t go straight to Kafka. There are three classes involved, and the ordering matters for data consistency.
Step 1: Save and Publish a Spring Event
BudgetService.saveBudget() saves to the database inside a @Transactional boundary, then publishes a Spring application event — not a Kafka message.
// budget-service/.../service/BudgetService.java
@Transactional
public Budget saveBudget(Budget budget) {
Budget savedBudget = this.budgetRepository.save(budget);
BudgetNotification notification = budgetMapper.toNotification(savedBudget);
eventPublisher.publishEvent(notification); // Spring event, not Kafka yet
return savedBudget;
}
Step 2: Post-Commit Handler Triggers Kafka
The BudgetNotificationHandler listens for that Spring event, but only fires after the database transaction commits. This is the key reliability guarantee: if the DB save fails and rolls back, no Kafka message is ever sent.
// budget-service/.../producer/BudgetNotificationHandler.java
@Component
public class BudgetNotificationHandler {
private final BudgetNotificationProducer budgetNotificationProducer;
@Async // Runs on a separate thread pool
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleBudgetNotification(BudgetNotification request) {
budgetNotificationProducer.sendBudgetRequest(request);
}
}
The combination of @TransactionalEventListener(phase = AFTER_COMMIT) and @Async means: wait for the DB commit, then send the Kafka message on a background thread so the HTTP response isn’t blocked.
Step 3: The Kafka Producer
// budget-service/.../producer/BudgetNotificationProducer.java
@Service
public class BudgetNotificationProducer {
private final KafkaTemplate<String, BudgetNotification> kafkaTemplate;
private static final String TOPIC = "notification_requests_topic";
@Async
public void sendBudgetRequest(BudgetNotification request) {
kafkaTemplate.send(TOPIC, request.getUserId(), request);
}
}
Using request.getUserId() as the Kafka key ensures all events for the same user land on the same partition, preserving per-user ordering.
Step 4: The Consumer
The notification-service has a dedicated NotificationConsumer component (not the service class itself) that listens on the topic and delegates:
// notification-service/.../consumer/NotificationConsumer.java
@Component
public class NotificationConsumer {
private final NotificationService notificationService;
@KafkaListener(topics = "notification_requests_topic", groupId = "notification-service-group")
public void listen(NotificationRequest request) {
notificationService.sendNotificationAsync(request);
}
}
Testing the Event Chain with Embedded Kafka
You don’t need a running Kafka broker to test this. Spring’s @EmbeddedKafka spins up an in-process broker:
// notification-service/.../consumer/NotificationConsumerTest.java
@SpringBootTest
@DirtiesContext
@EnableKafka
@EmbeddedKafka
@ActiveProfiles("dev")
public class NotificationConsumerTest {
@Autowired
private KafkaTemplate<String, NotificationRequest> kafkaTemplate;
private static final int MESSAGE_COUNT = 5;
private static final String TOPIC = "notification_requests_topic";
@Test
void testConsumerReceivesAndProcessesMessage() throws Exception {
NotificationService.processedMessageCount.set(0);
for (int i = 0; i < 5; i++) {
NotificationRequest request = new NotificationRequest(
1L, "user" + i, "Test Subject " + i, "Test Body " + i, "EMAIL"
);
kafkaTemplate.send(TOPIC, request.getUserId(), request).get(1, TimeUnit.SECONDS);
}
Awaitility.await()
.atMost(10, TimeUnit.SECONDS)
.pollInterval(200, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
assertEquals(MESSAGE_COUNT, NotificationService.processedMessageCount.get());
});
}
}
Awaitility is the right tool here — Thread.sleep() is unreliable for async assertions. The test produces 5 messages, then polls until all are consumed or 10 seconds elapse.
Conclusion
By leveraging Apache Kafka and Docker, the PFMS architecture achieves high scalability and resilience. Services are decoupled; failures in the notification-service no longer block the budget-service. The pfms-network ensures seamless internal communication, while the event-driven pattern lays the foundation for complex, real-time features like fraud detection and automated reporting.