Skip to content

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.