MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Event-Driven Architecture with Apache Kafka

Implement event-driven microservices with Kafka: topics, producers, consumers, exactly-once semantics.

KafkaEvent-DrivenMicroservicesBackend

By MinhVo

Introduction

Apache Kafka has become the de facto standard for building event-driven architectures at scale. Originally developed at LinkedIn to handle high-throughput event streaming, Kafka has evolved into a distributed event streaming platform capable of processing trillions of events per day. Its append-only log structure, horizontal scalability, and fault tolerance make it the backbone of modern data pipelines and microservice communication. In this comprehensive guide, we will explore Kafka's core concepts, production implementation patterns, and best practices for building robust event-driven systems.

Unlike traditional message brokers that delete messages after consumption, Kafka retains events for configurable periods, enabling multiple consumers to process the same events independently. This fundamental design choice enables powerful patterns like event sourcing, change data capture, and real-time analytics alongside operational microservice communication. Whether you are building a real-time recommendation engine, a fraud detection system, or a simple service mesh, understanding Kafka is essential for modern backend engineering.

Apache Kafka Architecture

Understanding Kafka: Core Concepts

Kafka's architecture revolves around several key abstractions that work together to provide reliable, scalable event streaming. A Kafka cluster consists of one or more brokers—servers that store and serve data. Topics are logical channels to which producers publish events and from which consumers read. Each topic is divided into partitions, which are the unit of parallelism and ordering in Kafka.

Partitions are ordered, immutable sequences of records. Each record within a partition has a unique offset—a sequential identifier that increases monotonically. Kafka guarantees ordering within a partition but not across partitions. This means that if you need strict ordering for related events, you must ensure they are published to the same partition using a consistent partition key.

Producers are client applications that publish records to Kafka topics. When a producer sends a record, it can specify a partition key, and Kafka uses a hash of this key to determine which partition receives the record. If no key is provided, records are distributed round-robin across partitions. The producer can also choose to wait for acknowledgments from brokers to ensure durability.

Consumers read records from topics and process them. Consumer groups enable horizontal scaling of consumption. Each partition is assigned to exactly one consumer within a group, ensuring that records are processed once per group. If a consumer fails, its partitions are automatically reassigned to other consumers in the group—a process called rebalancing. This provides both load balancing and fault tolerance.

Consumer offsets track the position of each consumer group in each partition. Consumers commit their offsets after processing records, and Kafka stores these offsets in an internal topic called __consumer_offsets. When a consumer restarts, it resumes from its last committed offset, enabling at-least-once delivery semantics. For exactly-once semantics, Kafka provides transactional producers and idempotent producer modes.

Kafka's retention policy determines how long records are kept. Unlike traditional queues where messages are deleted after consumption, Kafka retains records for a configurable duration (default 7 days) or until a size limit is reached. This retention enables new consumers to replay historical events, which is essential for event sourcing and rebuilding read models.

Message Queue Patterns

Architecture and Design Patterns

Pattern 1: Event Sourcing with Kafka

Kafka's append-only log naturally aligns with event sourcing. Each aggregate's event stream can be stored as a topic, with each partition representing an ordered sequence of events for a subset of aggregates. The compacted topic feature allows Kafka to retain only the latest event per key, which is useful for maintaining materialized views.

Pattern 2: CQRS with Kafka Streams

Kafka Streams provides a client library for building stream processing applications that consume from Kafka topics, transform data, and produce results back to Kafka. This makes it ideal for implementing CQRS read models: the write side produces events to a topic, and the read side uses Kafka Streams to maintain denormalized query views.

Pattern 3: Change Data Capture (CDC)

CDC captures changes in a database and publishes them as events to Kafka. Tools like Debezium read database transaction logs and produce events to Kafka topics, enabling event-driven architectures without modifying application code. This pattern is invaluable for integrating legacy systems into modern event-driven architectures.

Pattern 4: Event-Driven Saga

Sagas coordinate distributed transactions across multiple services using events. With Kafka, each step in a saga publishes an event to a topic, and the next service consumes that event to perform its local transaction. If any step fails, compensating events trigger rollback logic in previously completed steps.

Pattern 5: Exactly-Once Semantics

Kafka supports exactly-once semantics through idempotent producers and transactional APIs. An idempotent producer assigns a unique producer ID and sequence number to each record, allowing the broker to detect and discard duplicates. Transactions enable atomic writes across multiple partitions and topics.

Step-by-Step Implementation

Let us build a complete event-driven order processing system using Apache Kafka with TypeScript and the kafkajs client library.

First, set up the Kafka producer:

import { Kafka, Partitioners } from 'kafkajs';
 
const kafka = new Kafka({
  clientId: 'order-service',
  brokers: ['localhost:9092'],
  retry: {
    initialRetryTime: 100,
    retries: 8,
    maxRetryTime: 30000,
  },
});
 
const producer = kafka.producer({
  createPartitioner: Partitioners.DefaultPartitioner,
  idempotent: true,
  transactionalId: 'order-producer-1',
  maxInFlightRequests: 5,
});
 
async function publishOrderEvent(event: {
  type: string;
  orderId: string;
  data: Record<string, unknown>;
}): Promise<void> {
  await producer.send({
    topic: 'order-events',
    messages: [
      {
        key: event.orderId,
        value: JSON.stringify({
          ...event,
          timestamp: new Date().toISOString(),
          eventId: crypto.randomUUID(),
        }),
        headers: {
          'correlation-id': crypto.randomUUID(),
          'event-type': event.type,
        },
      },
    ],
  });
}

Next, implement the consumer with exactly-once processing:

const consumer = kafka.consumer({
  groupId: 'order-processing-group',
  sessionTimeout: 30000,
  heartbeatInterval: 3000,
});
 
async function startOrderConsumer(): Promise<void> {
  await consumer.subscribe({ topic: 'order-events', fromBeginning: false });
 
  await consumer.run({
    eachBatchAutoResolve: true,
    eachBatch: async ({ batch, resolveOffset, heartbeat }) => {
      for (const message of batch.messages) {
        const event = JSON.parse(message.value!.toString());
 
        try {
          await processOrderEvent(event);
          resolveOffset(message.offset);
          await heartbeat();
        } catch (error) {
          console.error(`Failed to process event ${event.eventId}:`, error);
          // Retry logic or dead letter queue
          await publishToDeadLetter(batch.topic, message);
        }
      }
    },
  });
}
 
async function processOrderEvent(event: {
  type: string;
  orderId: string;
  data: Record<string, unknown>;
}): Promise<void> {
  switch (event.type) {
    case 'OrderCreated':
      await handleOrderCreated(event);
      break;
    case 'PaymentProcessed':
      await handlePaymentProcessed(event);
      break;
    case 'OrderShipped':
      await handleOrderShipped(event);
      break;
    default:
      console.warn(`Unknown event type: ${event.type}`);
  }
}

Implement Kafka Streams for real-time aggregation:

import { StreamsBuilder, Topology } from 'kafka-streams';
 
async function buildOrderAnalytics(): Promise<Topology> {
  const builder = new StreamsBuilder();
 
  const orderEvents = builder.stream('order-events');
 
  // Count orders by status per hour
  const orderCounts = orderEvents
    .filter((event) => event.type === 'OrderCreated')
    .map((event) => ({
      key: new Date(event.timestamp).toISOString().slice(0, 13),
      value: 1,
    })
    .reduce(
      (key, value, accumulator) => accumulator + value,
      'order-counts-store'
    );
 
  // Calculate total revenue
  const revenue = orderEvents
    .filter((event) => event.type === 'PaymentProcessed')
    .map((event) => ({
      key: new Date(event.timestamp).toISOString().slice(0, 13),
      value: event.data.amount as number,
    })
    .reduce(
      (key, value, accumulator) => accumulator + value,
      'revenue-store'
    );
 
  orderCounts.to('order-analytics');
  revenue.to('revenue-analytics');
 
  return builder.build();
}

Implement transactional outbox for atomic event publishing:

class TransactionalOutbox {
  constructor(
    private db: Database,
    private producer: Producer
  ) {}
 
  async publishTransactional(
    businessData: { table: string; data: Record<string, unknown> },
    event: { type: string; aggregateId: string; data: Record<string, unknown> }
  ): Promise<void> {
    const transaction = await this.producer.transaction();
    try {
      // Write business data
      await this.db.insert(businessData.table, businessData.data);
 
      // Write event to outbox
      await this.db.insert('outbox', {
        id: crypto.randomUUID(),
        aggregate_id: event.aggregateId,
        event_type: event.type,
        payload: JSON.stringify(event.data),
        created_at: new Date(),
        published: false,
      });
 
      // Publish event
      await transaction.send({
        topic: 'order-events',
        messages: [{
          key: event.aggregateId,
          value: JSON.stringify(event.data),
        }],
      });
 
      await transaction.sendOffsets({
        consumerOffsets: { groupId: 'outbox-publisher' },
      });
 
      // Mark outbox entry as published
      await this.db.update('outbox', { published: true }, { aggregate_id: event.aggregateId });
 
      await transaction.commit();
    } catch (error) {
      await transaction.abort();
      throw error;
    }
  }
}

Kafka Monitoring Dashboard

Real-World Use Cases and Case Studies

Use Case 1: Real-Time Fraud Detection

Financial institutions use Kafka to process millions of transactions per second. Each transaction is an event that flows through a Kafka topic to a fraud detection service. The service uses stream processing to detect anomalies in real-time by comparing transaction patterns against historical baselines. Suspicious transactions are flagged and routed to a manual review queue, while legitimate transactions proceed without delay. Kafka's low latency and high throughput make it ideal for this time-sensitive application.

Use Case 2: Microservice Communication

Netflix uses Kafka as the backbone for communication between hundreds of microservices. When a user watches a movie, events flow to services responsible for recommendations, viewing history, billing, and analytics. Each service consumes events independently, allowing teams to deploy and scale their services without coordinating with others. Kafka's retention policy enables new services to be added and replay historical events to build their state.

Use Case 3: Log Aggregation and Monitoring

Uber aggregates application logs from thousands of servers into Kafka topics. Log processing consumers index logs into Elasticsearch for search, feed real-time monitoring dashboards, and trigger alerts when error rates exceed thresholds. Kafka's ability to handle burst traffic ensures no logs are lost during peak hours, and its retention policy allows replaying logs for incident investigation.

Use Case 4: Event Sourcing for Banking

A major bank implemented event sourcing with Kafka for its core banking system. Every account transaction—deposits, withdrawals, transfers, and interest calculations—is stored as an immutable event. The current account balance is derived by replaying events. This provides a complete audit trail required by regulators and enables the bank to reconstruct the state of any account at any point in time.

Best Practices for Production

  1. Use partition keys for ordering: If your events require ordering (e.g., all events for a specific order), use a consistent partition key based on the entity ID. This ensures all events for the same entity go to the same partition and are processed in order.

  2. Implement idempotent producers: Enable idempotent production (idempotent: true) to prevent duplicate messages from network retries. This ensures each message is written exactly once to each partition, even if the producer retries.

  3. Set appropriate consumer group sizes: The number of consumers in a group should not exceed the number of partitions. Extra consumers will sit idle. If you need more parallelism, increase the number of partitions (but note that this cannot be decreased).

  4. Monitor consumer lag: Track the lag between the latest offset in each partition and each consumer group's committed offset. Increasing lag indicates a consumer falling behind, which may require scaling up or optimizing processing logic.

  5. Configure appropriate retention policies: Set retention periods based on your use case. For event sourcing, you may want longer retention (30+ days). For operational events, 7 days is usually sufficient. Use log compaction for topics that represent the latest state of entities.

  6. Use dead letter queues for failed messages: When a consumer cannot process a message after retries, route it to a dead letter topic instead of blocking the partition. Monitor dead letter topics and alert on new entries.

  7. Implement graceful shutdown: When stopping consumers, commit offsets and allow in-flight processing to complete before shutting down. This prevents duplicate processing and ensures clean rebalancing.

  8. Schema management with Schema Registry: Use Confluent Schema Registry or a similar tool to manage event schemas. Enforce backward compatibility to prevent breaking changes and enable independent evolution of producers and consumers.

Common Pitfalls and Solutions

PitfallImpactSolution
Too few partitionsLimits parallelism and scalabilityPlan partitions based on expected throughput; start with 2-3x consumer count
No partition key for ordered eventsEvents processed out of orderUse entity ID as partition key to ensure ordering
Consumer not committing offsetsDuplicate processing after restartCommit offsets after successful processing; use manual offset management
Large message sizesSlow processing and broker memory issuesKeep messages under 1MB; use external storage for large payloads with references in events
Ignoring rebalancingDowntime during consumer changesUse cooperative rebalancing; implement static group membership
No schema evolution strategyBreaking changes in productionUse schema registry with backward compatibility enforcement

Performance Optimization

Kafka performance depends on producer batching, consumer parallelism, and broker configuration. Tuning these parameters can dramatically improve throughput and reduce latency.

// Optimized producer configuration
const optimizedProducer = kafka.producer({
  // Batch messages for higher throughput
  maxInFlightRequests: 5,
  idempotent: true,
  compression: CompressionTypes.LZ4,
  transactionalId: 'order-producer',
});
 
// Send with batching configuration
await producer.send({
  topic: 'order-events',
  messages: batchMessages,
  acks: -1,            // Wait for all in-sync replicas
  timeout: 30000,       // 30 second timeout
  compression: CompressionTypes.LZ4,
});
 
// Consumer with parallel processing
const parallelConsumer = kafka.consumer({
  groupId: 'order-processing',
  maxBytesPerPartition: 1048576,  // 1MB per partition
  minBytes: 1,                    // Don't wait to fill batch
  maxBytes: 10485760,             // 10MB total
  maxWaitTimeInMs: 100,           // Wait up to 100ms for data
});

Key optimizations include: using LZ4 or Snappy compression to reduce network bandwidth, batching messages on the producer side to improve throughput, tuning fetch sizes on the consumer to balance latency and throughput, and using SSD storage on brokers for low-latency access.

Comparison with Alternatives

FeatureApache KafkaRabbitMQAWS SQSApache Pulsar
Message ModelLog-basedQueue/ExchangeQueueLog-based
OrderingPer-partitionPer-queueBest-effortPer-partition
ThroughputVery HighMediumMediumVery High
RetentionConfigurableUntil consumed14 daysConfigurable
Consumer GroupsNativeVia exchangesVia visibility timeoutNative
Exactly-OnceYes (transactions)With publisher confirmsFIFO onlyYes
Operational ComplexityHighLowManagedHigh
Best ForEvent streaming, EDATask queues, RPCSimple queuingMulti-tenancy

Advanced Patterns

Kafka Connect for CDC

Kafka Connect provides a framework for streaming data between Kafka and external systems. The Debezium CDC connector reads database transaction logs and produces change events to Kafka topics, enabling event-driven architectures without modifying application code.

// Debezium CDC connector configuration
const cdcConfig = {
  name: 'postgres-cdc-connector',
  config: {
    'connector.class': 'io.debezium.connector.postgresql.PostgresConnector',
    'database.hostname': 'db.example.com',
    'database.port': '5432',
    'database.user': 'debezium',
    'database.password': '${secrets:db-password}',
    'database.dbname': 'orders',
    'database.server.name': 'orders-db',
    'table.include.list': 'public.orders,public.order_items',
    'plugin.name': 'pgoutput',
    'slot.name': 'debezium_slot',
    'publication.name': 'debezium_publication',
  },
};

Event Replay and Projections

One of Kafka's most powerful capabilities is enabling event replay for rebuilding projections. When a bug is fixed in projection logic, you can reset the consumer offset to the beginning and replay all events to rebuild the read model from scratch. This is only possible because Kafka retains events rather than deleting them after consumption.

Testing Strategies

Testing Kafka-based systems requires both unit tests for individual components and integration tests with a real Kafka broker. Use Testcontainers to spin up a Kafka broker in Docker for integration tests.

import { KafkaContainer } from '@testcontainers/kafka';
 
describe('Order Event Processing', () => {
  let container: StartedKafkaContainer;
  let kafka: Kafka;
 
  beforeAll(async () => {
    container = await new KafkaContainer().start();
    kafka = new Kafka({
      clientId: 'test',
      brokers: [container.getHost() + ':' + container.getMappedPort(9093)],
    });
  });
 
  it('should process OrderCreated event', async () => {
    const producer = kafka.producer();
    await producer.connect();
    await producer.send({
      topic: 'order-events',
      messages: [{ key: 'order-1', value: JSON.stringify({ type: 'OrderCreated', orderId: 'order-1' }) }],
    });
 
    const consumer = kafka.consumer({ groupId: 'test-group' });
    await consumer.connect();
    await consumer.subscribe({ topic: 'order-events', fromBeginning: true });
 
    const messages = await new Promise<EachMessagePayload[]>((resolve) => {
      const collected: EachMessagePayload[] = [];
      consumer.run({
        eachMessage: async (payload) => {
          collected.push(payload);
          if (collected.length === 1) resolve(collected);
        },
      });
    });
 
    expect(JSON.parse(messages[0].message.value!.toString()).type).toBe('OrderCreated');
  });
});

Future Outlook

Kafka continues to evolve with features like tiered storage for cost-effective long-term retention, KRaft consensus for removing ZooKeeper dependency, and improved exactly-once semantics across multiple topics. The Kafka Streams and ksqlDB ecosystems are making real-time stream processing more accessible to developers who are not data engineers.

The convergence of event streaming with AI/ML is an exciting development. Real-time event streams can feed online learning models, trigger inference pipelines, and enable adaptive systems that respond to patterns in the data. As edge computing grows, Kafka is being deployed at the edge to process events locally before forwarding aggregated results to central clusters.

Conclusion

Apache Kafka is the foundation of modern event-driven architectures, providing the scalability, durability, and flexibility needed for complex distributed systems. The patterns we explored—event sourcing, CQRS with Kafka Streams, CDC, and transactional outbox—demonstrate Kafka's versatility beyond simple message queuing.

Key takeaways: (1) Use partition keys to ensure ordering for related events; (2) Implement idempotent producers and transactional APIs for exactly-once semantics; (3) Monitor consumer lag to prevent processing bottlenecks; (4) Use dead letter queues for failed message handling; (5) Leverage Kafka's retention policy for event replay and projection rebuilding.

Start with a simple producer-consumer pattern and progressively adopt more advanced features like Kafka Streams and transactions as your needs grow. Kafka's learning curve is steep, but the investment pays off in system reliability, scalability, and the ability to build truly decoupled architectures.