Skip to main content

Event-Driven Architecture

Multi-Region Shopping Mall implements an event-driven architecture (EDA) centered on MSK Kafka. This enables loose coupling between services, asynchronous processing, and distributed transactions based on the SAGA pattern.

MSK Kafka Topic Structure

Topic Overview

A total of 35 Kafka topics are organized by domain.

Topic Details by Domain

Order Domain (4 Topics)

TopicPartitionsProducerConsumerDescription
order-created6Order ServicePayment, Inventory, NotificationOrder creation event
order-updated6Order ServiceAnalytics, NotificationOrder status change
order-cancelled3Order ServicePayment, Inventory, NotificationOrder cancellation
order-completed3Order ServiceAnalytics, RecommendationOrder completion
// order-created event schema
{
"eventId": "evt-uuid-123",
"eventType": "ORDER_CREATED",
"timestamp": "2024-03-10T14:30:00Z",
"version": "1.0",
"source": "order-service",
"correlationId": "corr-uuid-456",
"payload": {
"orderId": "ORD-12345",
"userId": "USER-001",
"items": [
{
"productId": "PROD-001",
"sku": "S24U-256-BLK",
"quantity": 1,
"unitPrice": 1550000
}
],
"totalAmount": 1550000,
"currency": "KRW",
"shippingAddressId": "ADDR-001",
"paymentMethod": "CREDIT_CARD"
}
}

Payment Domain (4 Topics)

TopicPartitionsProducerConsumerDescription
payment-requested6Payment ServicePayment ProcessorPayment request
payment-completed6Payment ServiceOrder, Inventory, NotificationPayment complete
payment-failed3Payment ServiceOrder, NotificationPayment failed
refund-processed3Payment ServiceOrder, Notification, AnalyticsRefund processed

Inventory Domain (4 Topics)

TopicPartitionsProducerConsumerDescription
inventory-reserved6Inventory ServiceOrderStock reserved
inventory-released3Inventory ServiceAnalyticsStock reservation released
inventory-updated6Inventory ServiceProduct Catalog, SearchStock quantity changed
inventory-low-stock3Inventory ServiceNotification, SellerLow stock alert

Shipping Domain (4 Topics)

TopicPartitionsProducerConsumerDescription
shipment-created6Shipping ServiceOrder, NotificationShipment created
shipment-status-updated6Shipping ServiceOrder, NotificationShipment status change
shipment-delivered3Shipping ServiceOrder, Notification, AnalyticsDelivery complete
shipment-failed3Shipping ServiceOrder, Notification, ReturnsDelivery failed

Notification Domain (4 Topics)

TopicPartitionsProducerConsumerDescription
notification-requested6Various ServicesNotification ServiceNotification request
notification-sent3Notification ServiceAnalyticsNotification sent
notification-failed3Notification ServiceAnalytics, Retry HandlerNotification failed
notification-scheduled3Notification ServiceSchedulerScheduled notification

User Domain (3 Topics)

TopicPartitionsProducerConsumerDescription
user-registered3User AccountNotification, AnalyticsUser registration
user-profile-updated3User ProfileRecommendationProfile change
user-preferences-changed3User ProfileNotification, RecommendationPreferences change

Product Domain (4 Topics)

TopicPartitionsProducerConsumerDescription
product-created6Product CatalogSearch, NotificationProduct created
product-updated6Product CatalogSearch, Cache InvalidatorProduct updated
product-price-changed6Pricing ServiceSearch, Notification, WishlistPrice change
product-discontinued3Product CatalogSearch, Wishlist, CartProduct discontinued

Review Domain (2 Topics)

TopicPartitionsProducerConsumerDescription
review-created6Review ServiceProduct Catalog, Search, NotificationReview created
review-moderated3Review ServiceNotificationReview moderated

Analytics Domain (3 Topics)

TopicPartitionsProducerConsumerDescription
analytics-page-view12API GatewayAnalyticsPage view
analytics-user-action12Various ServicesAnalyticsUser action
analytics-conversion6Order ServiceAnalyticsConversion event

Infrastructure Domain (2 Topics)

TopicPartitionsProducerConsumerDescription
system-health3All ServicesMonitoringHealth check
dead-letter-queue6All ConsumersDLQ HandlerFailed events

SAGA Pattern - Order Flow

SAGA Orchestration

Order creation is a distributed transaction requiring collaboration of multiple services. The SAGA pattern manages this.

Compensating Transactions

StepNormal ActionCompensating ActionTrigger Event
1Create OrderCancel Orderorder-cancelled
2Reserve StockRelease Stockinventory-released
3Process PaymentProcess Refundrefund-processed
4Create ShipmentCancel Shipmentshipment-cancelled

CQRS Pattern

Command and Query Separation

Write Model vs Read Model

AspectWrite ModelRead Model
PurposeApply business rulesFast queries
DataNormalizedDenormalized
StorageAurora PostgreSQLElastiCache, OpenSearch
ConsistencyStrongEventual
SchemaTransaction-centricQuery pattern optimized

Example: Product Detail Query

# Command Side - Product update
@app.post("/products/{product_id}")
async def update_product(product_id: str, request: ProductUpdateRequest):
# 1. Save to DocumentDB (Source of Truth)
await docdb.products.update_one(
{"productId": product_id},
{"$set": request.dict()}
)

# 2. Publish event
await kafka.send("product-updated", {
"eventType": "PRODUCT_UPDATED",
"productId": product_id,
"changes": request.dict(),
"timestamp": datetime.utcnow().isoformat()
})

return {"status": "updated"}

# Event Consumer - Read Model sync
async def handle_product_updated(event):
product = await docdb.products.find_one({"productId": event["productId"]})

# 1. Update OpenSearch (for search)
await opensearch.index(
index="products",
id=event["productId"],
body=transform_for_search(product)
)

# 2. Invalidate ElastiCache (cache)
await cache.delete(f"product:{event['productId']}")

# 3. Notify wishlist users on price change
if "pricing" in event["changes"]:
await notify_wishlist_users(event["productId"], product["pricing"])

# Query Side - Product query
@app.get("/products/{product_id}")
async def get_product(product_id: str):
# 1. Check cache
cached = await cache.get(f"product:{product_id}")
if cached:
return json.loads(cached)

# 2. Query DocumentDB
product = await docdb.products.find_one({"productId": product_id})

# 3. Save to cache
await cache.set(
f"product:{product_id}",
json.dumps(product),
ex=3600 # 1 hour
)

return product

DocumentDB Change Stream → OpenSearch Sync

Architecture

Implementation

// Go - Change Stream Consumer
package main

import (
"context"
"encoding/json"
"log"

"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"github.com/opensearch-project/opensearch-go"
)

type ChangeStreamConsumer struct {
docdbClient *mongo.Client
osClient *opensearch.Client
}

func (c *ChangeStreamConsumer) WatchProducts(ctx context.Context) error {
collection := c.docdbClient.Database("mall").Collection("products")

pipeline := mongo.Pipeline{
{{"$match", bson.D{
{"operationType", bson.D{{"$in", bson.A{"insert", "update", "replace", "delete"}}}},
}}},
}

opts := options.ChangeStream().SetFullDocument(options.UpdateLookup)
stream, err := collection.Watch(ctx, pipeline, opts)
if err != nil {
return err
}
defer stream.Close(ctx)

for stream.Next(ctx) {
var change bson.M
if err := stream.Decode(&change); err != nil {
log.Printf("Error decoding change: %v", err)
continue
}

if err := c.processChange(ctx, change); err != nil {
log.Printf("Error processing change: %v", err)
// Send to DLQ
c.sendToDLQ(change)
}
}

return stream.Err()
}

func (c *ChangeStreamConsumer) processChange(ctx context.Context, change bson.M) error {
operationType := change["operationType"].(string)

switch operationType {
case "insert", "update", "replace":
fullDoc := change["fullDocument"].(bson.M)
return c.indexProduct(ctx, fullDoc)
case "delete":
docKey := change["documentKey"].(bson.M)
productId := docKey["productId"].(string)
return c.deleteProduct(ctx, productId)
}

return nil
}

func (c *ChangeStreamConsumer) indexProduct(ctx context.Context, doc bson.M) error {
// Transform to OpenSearch document
searchDoc := map[string]interface{}{
"productId": doc["productId"],
"name": doc["name"],
"brand": doc["brand"],
"category": doc["category"],
"description": doc["description"].(bson.M)["short"],
"tags": doc["tags"],
"price": doc["pricing"].(bson.M)["listPrice"],
"salePrice": doc["pricing"].(bson.M)["salePrice"],
"rating": doc["ratings"].(bson.M)["average"],
"reviewCount": doc["ratings"].(bson.M)["count"],
"sellerId": doc["seller"].(bson.M)["sellerId"],
"status": doc["status"],
"updatedAt": doc["updatedAt"],
}

body, _ := json.Marshal(searchDoc)

_, err := c.osClient.Index(
"products",
bytes.NewReader(body),
c.osClient.Index.WithDocumentID(doc["productId"].(string)),
c.osClient.Index.WithRefresh("true"),
)

return err
}

Cross-Region MSK Replicator

Configuration

Topic Replication Settings

TopicReplication DirectionReason
order-*Primary → SecondaryOrder events created at Primary
payment-*Primary → SecondaryPayments processed only at Primary
inventory-*BidirectionalInventory info needed in both regions
product-*BidirectionalProduct info sync
notification-*Primary → SecondaryNotifications coordinated at Primary
analytics-*Both → PrimaryAnalytics data aggregated at Primary

Terraform Configuration

resource "aws_msk_replicator" "cross_region" {
replicator_name = "cross-region-replicator"
description = "Replicate events between us-east-1 and us-west-2"

service_execution_role_arn = aws_iam_role.msk_replicator.arn

kafka_cluster {
amazon_msk_cluster {
msk_cluster_arn = aws_msk_cluster.use1.arn
}
vpc_config {
security_groups_to_add = [aws_security_group.msk_use1.id]
subnet_ids = aws_subnet.use1_private[*].id
}
}

kafka_cluster {
amazon_msk_cluster {
msk_cluster_arn = aws_msk_cluster.usw2.arn
}
vpc_config {
security_groups_to_add = [aws_security_group.msk_usw2.id]
subnet_ids = aws_subnet.usw2_private[*].id
}
}

replication_info_list {
source_kafka_cluster_arn = aws_msk_cluster.use1.arn
target_kafka_cluster_arn = aws_msk_cluster.usw2.arn

topic_replication {
topics_to_replicate = ["order-*", "payment-*", "product-*", "inventory-*"]
copy_topic_configurations = true
copy_access_control_lists_for_topics = true
detect_and_copy_new_topics = true
}

consumer_group_replication {
consumer_groups_to_replicate = [".*"]
synchronise_consumer_group_offsets = true
}

target_compression_type = "GZIP"
}
}

Dead Letter Queue (DLQ) Strategy

DLQ Architecture

DLQ Message Schema

{
"dlqId": "dlq-uuid-123",
"originalTopic": "order-created",
"originalKey": "ORD-12345",
"originalEvent": {
"eventId": "evt-uuid-123",
"eventType": "ORDER_CREATED",
"payload": { }
},
"error": {
"type": "ProcessingException",
"message": "Inventory service unavailable",
"stackTrace": "...",
"consumerGroup": "inventory-consumer"
},
"retryCount": 3,
"firstFailedAt": "2024-03-10T14:30:00Z",
"lastFailedAt": "2024-03-10T14:35:00Z",
"status": "PENDING" // PENDING, RETRYING, RESOLVED, DISCARDED
}

Consumer Group Design

Consumer GroupServiceSubscribed TopicsInstances
order-payment-consumerPaymentorder-created3
order-inventory-consumerInventoryorder-created, order-cancelled3
order-notification-consumerNotificationorder-*2
payment-order-consumerOrderpayment-*3
shipment-order-consumerOrdershipment-*2
product-search-consumerSearchproduct-*3
analytics-consumerAnalyticsanalytics-*6

Event Processing Guarantees

At-Least-Once Processing

// Java Spring Kafka Consumer
@KafkaListener(
topics = "order-created",
groupId = "payment-order-consumer",
containerFactory = "kafkaListenerContainerFactory"
)
public void handleOrderCreated(
@Payload OrderCreatedEvent event,
@Header(KafkaHeaders.RECEIVED_KEY) String key,
Acknowledgment ack
) {
try {
// Idempotency check (verify if already processed)
if (processedEventRepository.exists(event.getEventId())) {
log.info("Event already processed: {}", event.getEventId());
ack.acknowledge();
return;
}

// Process business logic
paymentService.initiatePayment(event);

// Record processing complete
processedEventRepository.save(new ProcessedEvent(
event.getEventId(),
Instant.now()
));

// Manual commit
ack.acknowledge();

} catch (RetryableException e) {
// Retryable error - don't commit
throw e;
} catch (Exception e) {
// Non-retryable error - send to DLQ
dlqProducer.send(event, e);
ack.acknowledge();
}
}

Idempotency Guarantee

# Python - Idempotent processing
class IdempotentEventHandler:
def __init__(self, redis_client, handler_func):
self.redis = redis_client
self.handler = handler_func

async def handle(self, event: dict):
event_id = event["eventId"]
lock_key = f"event_lock:{event_id}"
processed_key = f"event_processed:{event_id}"

# 1. Check if already processed
if await self.redis.exists(processed_key):
logger.info(f"Event {event_id} already processed, skipping")
return

# 2. Acquire distributed lock
lock_acquired = await self.redis.set(
lock_key, "1",
ex=30, # 30 second timeout
nx=True # Fail if already exists
)

if not lock_acquired:
logger.info(f"Event {event_id} is being processed by another instance")
return

try:
# 3. Process event
await self.handler(event)

# 4. Record processing complete (retain for 7 days)
await self.redis.set(processed_key, "1", ex=604800)

finally:
# 5. Release lock
await self.redis.delete(lock_key)

Next Steps