Advanced Patterns — Microservices Interview
Target: Senior Engineer · Engineering Lead · Pre-Architect Focus: CQRS, Event Sourcing, async APIs, saga patterns, DDD
Q: What is CQRS (Command Query Responsibility Segregation)?
Why interviewers ask this: CQRS is powerful but adds complexity. Tests understanding of trade-offs and when to apply it.
Answer
CQRS separates read and write models:
Traditional:
User → API → Database (all ops)
↑ (read after write)
CQRS:
Commands (write):
User → CreateOrder → Order Database (normalized, transactional)
Queries (read):
User → SearchOrders → Search Index (denormalized, optimized)
→ Cache (Redis)
→ Analytics DB
Benefits:
- Independent scaling (10x read traffic → scale read model only)
- Optimized data shapes per use case
- Event sourcing + audit trail
Trade-offs:
- More complex (two data stores to sync)
- Eventual consistency (queries lag writes by milliseconds)
- Harder debugging
Example — Order management:
// WRITE SIDE (commands)
@Service
public class CreateOrderCommandHandler {
public void handle(CreateOrderCommand cmd) {
Order order = new Order(cmd.orderId, cmd.customerId, cmd.amount);
orderRepository.save(order);
// Publish event for consistency
eventBus.publish(new OrderCreatedEvent(cmd.orderId, cmd.customerId));
}
}
// READ SIDE (queries)
@Service
public class OrderQueryService {
@Autowired
private OrderSearchIndex searchIndex; // Elasticsearch
public List<Order> findOrdersByCustomer(String customerId) {
return searchIndex.query("customer_id:" + customerId);
}
}
// Sync the two sides
@KafkaListener(topics = "order-events")
public void onOrderCreated(OrderCreatedEvent event) {
// Update read model when write model changes
searchIndex.index(new OrderSearchDocument(event.orderId, event.customerId));
}
Common Mistake
Don't use CQRS for simple CRUD apps. Start with traditional models, add CQRS only when you have truly different read/write patterns.
Q: What is Event Sourcing?
Answer
Event Sourcing stores all state changes as immutable events:
Traditional DB:
orders = {id: 1, status: "SHIPPED", amount: 100}
Event Sourcing:
events = [
{id: 1, type: "OrderCreated", amount: 100, timestamp: t1},
{id: 1, type: "PaymentProcessed", amount: 100, timestamp: t2},
{id: 1, type: "ShippingDispatched", timestamp: t3}
]
Order state = replay all events in order
Benefits:
- Complete audit trail (who did what when)
- Temporal queries (what was the state at time T?)
- Replay for debugging
- Natural fit for event-driven systems
Implementation:
@Service
public class OrderEventStore {
@Autowired
private EventRepository eventRepo;
public void apply(OrderEvent event) {
// Immutable append-only log
eventRepo.append(event);
}
public Order getOrderState(String orderId) {
// Reconstruct state by replaying events
List<OrderEvent> events = eventRepo.getEventsForAggregate(orderId);
Order order = new Order();
for (OrderEvent event : events) {
order.apply(event); // Mutate order by applying each event
}
return order;
}
}
Event types:
public abstract class OrderEvent {
public String orderId;
public LocalDateTime timestamp;
}
public class OrderCreated extends OrderEvent {
public String customerId;
public BigDecimal amount;
}
public class PaymentProcessed extends OrderEvent {
public String paymentId;
}
public class OrderShipped extends OrderEvent {
public String trackingNumber;
}
Q: How do you implement async APIs with webhooks?
Answer
Webhook pattern for long-running operations:
Client request:
POST /api/reports/generate
{
"format": "pdf",
"webhook": "https://client.com/webhook"
}
Response (async):
202 Accepted
{
"requestId": "req-123",
"status": "processing"
}
(Server processes asynchronously...)
Server callback (webhook):
POST https://client.com/webhook
{
"requestId": "req-123",
"status": "completed",
"result": "s3://bucket/report.pdf"
}
Implementation:
@PostMapping("/reports/generate")
public ResponseEntity<AsyncResponse> generateReport(
@RequestBody ReportRequest req) {
String requestId = UUID.randomUUID().toString();
// Queue async work
reportQueue.send(new GenerateReportJob(requestId, req));
// Return immediately
return ResponseEntity.accepted().body(
new AsyncResponse(requestId, "processing")
);
}
@KafkaListener(topics = "report-jobs")
public void processReport(GenerateReportJob job) {
try {
Report report = generatePDF(job.request);
String s3Url = uploadToS3(report);
// Call client's webhook
httpClient.post(job.webhookUrl, new WebhookPayload(
job.requestId, "completed", s3Url
));
} catch (Exception e) {
// Retry webhook if it fails
retryQueue.send(new WebhookRetry(job.webhookUrl, ...));
}
}
Webhook reliability:
- Implement retry logic (exponential backoff)
- Sign webhooks (HMAC for verification)
- Include idempotency keys (client deduplicates)
- Timeout after max retries
Q: How do you decompose a monolith using Domain-Driven Design?
Answer
DDD gives you the map:
1. Event storming (with domain experts)
→ Identify all domain events
2. Find bounded contexts (natural domain boundaries)
→ Order context, Payment context, Shipping context
3. Define each microservice
→ One service per bounded context
4. Identify anti-corruption layers
→ Legacy system → [ACL] → Modern service
E-commerce example:
| Bounded Context | Service | Entities |
|---|---|---|
| Order | order-service | Order, LineItem, OrderStatus |
| Payment | payment-service | Payment, Transaction, Refund |
| Inventory | inventory-service | Product, Stock, Reservation |
| Shipping | shipping-service | Shipment, Carrier, TrackingEvent |
| Catalog | catalog-service | Product, Category, Price |
Communication between contexts:
OrderContext uses OrderPlaced event
↓
PaymentContext listens & processes
↓
PaymentProcessed event
↓
InventoryContext reserves stock
Diagram — Event-Driven CQRS + Event Sourcing
graph LR
Cmd["Commands\nCreateOrder"]
Write["Write Side\nOrder Service"]
Events["Event Store\nAll events\nAppend-only"]
Read["Read Side\nSearch Index\nCache"]
Query["Queries\nFindOrders"]
Cmd --> Write
Write -->|OrderCreated| Events
Events -->|onOrderCreated| Read
Query --> Read
style Write fill:#4ecdc4
style Events fill:#51cf66
style Read fill:#ffe066
Event-Driven Architecture
Q: How do Kafka Streams and traditional message consumers differ? When do you need a streaming pipeline?
Why interviewers ask this: Many teams use Kafka just as a message queue and miss its stream processing power. Tests whether a candidate can distinguish between event consumption and stateful stream computation.
Answer
Traditional consumer: Read events, apply business logic, write results. Stateless per message.
Kafka Streams: A DSL for stateful stream processing — aggregations, joins, windowed computations, directly on Kafka topics without a separate cluster (Flink, Spark).
Traditional Consumer:
Topic → @KafkaListener → business logic → DB write
Good for: per-event processing, simple transforms
Kafka Streams:
Topic(s) → Streams topology → aggregations/joins → output Topic
Good for: windowed aggregation, joins across streams, real-time analytics
Kafka Streams — real-time order metrics example:
@Configuration
public class OrderMetricsTopology {
@Bean
public KStream<String, OrderEvent> buildTopology(StreamsBuilder builder) {
KStream<String, OrderEvent> orders =
builder.stream("order-events",
Consumed.with(Serdes.String(), orderEventSerde()));
// Windowed aggregation: count orders per minute per region
orders
.selectKey((k, order) -> order.getRegion())
.groupByKey()
.windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(1)))
.count(Materialized.as("order-count-per-region"))
.toStream()
.map((windowedKey, count) -> KeyValue.pair(
windowedKey.key(),
new RegionMetric(windowedKey.key(), count, windowedKey.window().start())
))
.to("order-metrics", Produced.with(Serdes.String(), regionMetricSerde()));
// Stream join: enrich orders with customer data
KTable<String, Customer> customers =
builder.table("customers", Consumed.with(Serdes.String(), customerSerde()));
orders
.join(customers,
(order, customer) -> new EnrichedOrder(order, customer),
Joined.with(Serdes.String(), orderEventSerde(), customerSerde()))
.to("enriched-orders");
return orders;
}
}
Kafka vs Apache Pulsar — which to choose:
| Dimension | Apache Kafka | Apache Pulsar |
|---|---|---|
| Architecture | Log-based, partitions pinned to brokers | Compute (broker) + Storage (BookKeeper) separated |
| Scalability | Scale by adding partitions/brokers | Scale brokers and storage independently |
| Multi-tenancy | Namespace isolation, limited | First-class tenant/namespace/topic hierarchy |
| Geo-replication | MirrorMaker2 (complex) | Built-in, native |
| Message TTL/retention | Topic-level log retention | Per-message TTL, namespace-level policies |
| Queuing model | Consumer groups (log replay) | Subscription types: exclusive, shared, failover |
| Ecosystem | ✔ Mature, large ecosystem (Kafka Streams, Connect, ksqlDB) | Smaller ecosystem, growing |
| Operational complexity | Medium (ZooKeeper historically, KRaft now) | Higher (ZooKeeper + BookKeeper ensemble) |
| Best for | High-throughput event streaming, existing Kafka investment | SaaS/multi-tenant, complex geo-replication, message queuing hybrid |
graph LR
OrderSvc["Order Service"]
Topic["Kafka Topic
ord er-events"]
KStream["Kafka Streams
Aggregation · Windowing"]
MetricsTopic["Metrics Topic"]
Grafana["Grafana Dashboard"]
Consumer["Notification Service
@KafkaListener"]
OrderSvc -->|Publish| Topic
Topic -->|Stream| KStream
Topic -->|Consume| Consumer
KStream -->|Aggregated events| MetricsTopic
MetricsTopic -->|Query| Grafana
style KStream fill:#ffe066
style Topic fill:#4ecdc4
Architect Insight
If your use case is just "do something when an event arrives", a @KafkaListener is all you need. Reach for Kafka Streams when you need time-windowed aggregations (orders in last 5 minutes), stream-table joins (enrich event with current DB state), or stateful filtering (deduplicate within a time window). Don't add Kafka Streams infrastructure for simple message forwarding.
Q: What is the Pub/Sub pattern? How does it differ from point-to-point messaging, and when do you use each?
Why interviewers ask this: Event-driven architecture is foundational to microservices decoupling. Tests understanding of messaging topologies and their trade-offs.
Answer
Point-to-Point (Queue): A message is produced to a queue and consumed by exactly one consumer. Used when a task must be processed once.
Pub/Sub (Topic): A message is published to a topic and delivered to all subscribers. Used when multiple services need to react to the same event.
Point-to-Point (Queue):
OrderService → [Queue: process-payment] → PaymentWorker (1 consumer)
- Guaranteed single processing
- Used for task delegation, work queues
Pub/Sub (Topic):
OrderService → [Topic: order-placed] → PaymentService
→ InventoryService
→ NotificationService
- Each subscriber gets a copy
- Used for domain events, fan-out, cross-cutting concerns
Kafka topic with consumer groups (combines both models):
// Publisher — OrderService publishes one event
@Service
public class OrderEventPublisher {
@Autowired
private KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate;
public void publish(Order order) {
kafkaTemplate.send("order-placed", order.getId(),
new OrderPlacedEvent(order.getId(), order.getCustomerId(), order.getTotal()));
}
}
// Subscriber 1 — PaymentService (its own consumer group)
@KafkaListener(topics = "order-placed", groupId = "payment-service")
public void handleOrderPlaced(OrderPlacedEvent event) {
paymentService.initiatePayment(event.getOrderId(), event.getTotal());
}
// Subscriber 2 — InventoryService (independent consumer group)
@KafkaListener(topics = "order-placed", groupId = "inventory-service")
public void reserveStock(OrderPlacedEvent event) {
inventoryService.reserve(event.getOrderId());
}
Pattern comparison:
| Dimension | Point-to-Point (Queue) | Pub/Sub (Topic) |
|---|---|---|
| Consumers | One consumer per message | All subscribers get a copy |
| Coupling | Sender knows the queue name | Sender knows only the topic |
| Fan-out | Not supported natively | Built-in |
| Replay | Not possible once consumed | Kafka retains log — consumers can replay |
| Ordering | FIFO per queue | Per-partition in Kafka |
| Use case | Work queues, task distribution | Domain events, notifications |
graph LR
Producer["Order Service\n(Publisher)"]
Topic["Kafka Topic\norder-placed"]
G1["Consumer Group\npayment-service"]
G2["Consumer Group\ninventory-service"]
G3["Consumer Group\nnotification-service"]
P1["Payment Worker 1"]
P2["Payment Worker 2"]
Producer -->|Publish| Topic
Topic -->|Fan-out| G1
Topic -->|Fan-out| G2
Topic -->|Fan-out| G3
G1 --> P1
G1 --> P2
style Producer fill:#4ecdc4
style Topic fill:#ffe066
style G1 fill:#51cf66
style G2 fill:#51cf66
style G3 fill:#51cf66
Architect Insight
Kafka's consumer group model elegantly merges both patterns: each consumer group receives every message (Pub/Sub), but within a group, each message is processed by exactly one instance (Point-to-Point). This gives you both fan-out and horizontal scaling of consumers simultaneously.