Change Data Capture Pipelines with Debezium 3.x and Kafka Connect
Introduction
In modern distributed systems, keeping data in sync across multiple services is one of the hardest problems architects face. Traditional approaches — polling databases, writing custom triggers, building ETL jobs — all come with significant drawbacks: high latency, tight coupling, performance overhead, or blind spots in the change history.
Change Data Capture (CDC) offers a fundamentally different approach. Instead of asking "what's in the database now?", CDC asks "what changed, and when?" By reading directly from the database's transaction log (WAL in PostgreSQL, binlog in MySQL), CDC captures every insert, update, and delete as it happens — with zero overhead on the source database — and publishes those changes as a stream of events.
Debezium, the leading open-source CDC platform, has matured significantly with its 3.x series. Paired with Kafka Connect as the distributed runtime, it forms the backbone of countless real-time data pipelines at scale. In this tutorial, you'll build a production-ready CDC pipeline that captures changes from a PostgreSQL orders table and makes them available to downstream Spring Boot consumers.
What you'll build:
- A PostgreSQL database with logical replication enabled
- Kafka and Kafka Connect running via Docker Compose
- A Debezium 3.x PostgreSQL connector capturing order changes
- A Spring Boot 3.4 consumer processing CDC events
- Integration tests using Testcontainers
Prerequisites
- Java 21+
- Docker and Docker Compose
- Maven 3.9+
- Basic familiarity with Kafka and Spring Boot
Tool versions used in this tutorial:
- Debezium 3.6.2.Final
- Apache Kafka 3.9.0
- Kafka Connect 3.9.0 (bundled with Kafka)
- Spring Boot 3.4.5
- PostgreSQL 16
Architecture Overview
The CDC pipeline follows this data flow:
PostgreSQL (WAL)
│
▼ (logical replication slot)
Debezium PostgreSQL Connector
│
▼ (Kafka Connect)
Kafka Topic: orders.public.orders
│
▼ (Kafka Consumer)
Spring Boot Service
│
▼ (business logic)
Read Model / Search Index / Audit Log
Debezium acts as a PostgreSQL replication client. It creates a replication slot in PostgreSQL, which holds the WAL position Debezium has processed. This ensures no events are lost even if Debezium restarts — Kafka Connect stores the offset in Kafka itself, and Debezium re-reads from the replication slot on recovery.
Step 1: PostgreSQL Configuration
Logical replication requires specific PostgreSQL settings. Create docker/postgres/postgresql.conf:
wal_level = logical
max_replication_slots = 5
max_wal_senders = 5
Create the initialization SQL in docker/postgres/init.sql:
-- Application user
CREATE USER app_user WITH PASSWORD 'app_password';
CREATE DATABASE orders_db OWNER app_user;
-- Dedicated replication user with minimal privileges
CREATE USER debezium WITH REPLICATION PASSWORD 'debezium_password';
\c orders_db;
GRANT USAGE ON SCHEMA public TO debezium;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO debezium;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO debezium;
-- Application table
CREATE TABLE IF NOT EXISTS orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
product_code VARCHAR(50) NOT NULL,
quantity INT NOT NULL,
total_amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Named publication is safer than FOR ALL TABLES:
-- limits replication scope and makes schema changes deliberate.
CREATE PUBLICATION dbz_publication FOR TABLE orders;
Step 2: Docker Compose Environment
version: '3.9'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: admin_password
ports:
- "5432:5432"
volumes:
- ./docker/postgres/postgresql.conf:/etc/postgresql/postgresql.conf
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
command: postgres -c config_file=/etc/postgresql/postgresql.conf
zookeeper:
image: confluentinc/cp-zookeeper:7.7.0
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
kafka:
image: confluentinc/cp-kafka:7.7.0
depends_on: [zookeeper]
ports:
- "9092:9092"
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'false'
kafka-connect:
image: debezium/connect:3.0
depends_on: [kafka, postgres]
ports:
- "8083:8083"
environment:
BOOTSTRAP_SERVERS: kafka:29092
GROUP_ID: 1
CONFIG_STORAGE_TOPIC: connect-configs
OFFSET_STORAGE_TOPIC: connect-offsets
STATUS_STORAGE_TOPIC: connect-status
CONFIG_STORAGE_REPLICATION_FACTOR: 1
OFFSET_STORAGE_REPLICATION_FACTOR: 1
STATUS_STORAGE_REPLICATION_FACTOR: 1
The official debezium/connect:3.0 image packages Kafka Connect with Debezium connectors pre-installed. In production, consider Strimzi or a custom image containing only the connectors you need.
Step 3: Registering the Debezium Connector
With the stack running, register the connector via Kafka Connect's REST API. Create connector-config/orders-connector.json:
{
"name": "orders-postgres-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.port": "5432",
"database.user": "debezium",
"database.password": "debezium_password",
"database.dbname": "orders_db",
"database.server.name": "orders-server",
"topic.prefix": "orders",
"plugin.name": "pgoutput",
"publication.name": "dbz_publication",
"publication.autocreate.mode": "disabled",
"table.include.list": "public.orders",
"snapshot.mode": "initial",
"decimal.handling.mode": "double",
"time.precision.mode": "connect",
"tombstones.on.delete": "true",
"transforms": "unwrap",
"transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState",
"transforms.unwrap.drop.tombstones": "false",
"transforms.unwrap.delete.handling.mode": "rewrite",
"transforms.unwrap.add.fields": "op,source.ts_ms,before",
"key.converter": "org.apache.kafka.connect.json.JsonConverter",
"key.converter.schemas.enable": "false",
"value.converter": "org.apache.kafka.connect.json.JsonConverter",
"value.converter.schemas.enable": "false"
}
}
Register it:
curl -X POST http://localhost:8083/connectors \
-H "Content-Type: application/json" \
-d @connector-config/orders-connector.json
# Verify status
curl http://localhost:8083/connectors/orders-postgres-connector/status
Key configuration choices:
plugin.name: pgoutput— the native PostgreSQL logical decoding plugin (available since PG 10), requiring no separate installation.wal2jsonwas deprecated in Debezium 1.x and removed in 2.0; never use it for new pipelines.publication.autocreate.mode: disabled— Debezium uses the publication you created ininit.sqlinstead of attempting to create one (which requires superuser privileges).snapshot.mode: initial— takes a consistent snapshot of existing rows on first run, then switches to streaming. Useneverif you only care about changes going forward.transforms: unwrapwithExtractNewRecordState— strips Debezium's envelope format and presents just the row fields plus metadata. This dramatically simplifies consumers.decimal.handling.mode: double— avoids the Debezium-specificDECIMALstruct for monetary fields. Usestringif you need exact precision.
Step 4: Spring Boot Consumer
Project Structure
src/main/java/com/vimleshpandey/demo/
├── CdcPipelineApplication.java
├── cdc/
│ ├── OrderEventConsumer.java
│ ├── config/
│ │ └── KafkaConfig.java
│ ├── model/
│ │ └── OrderChangeEvent.java
│ └── service/
│ └── OrderSyncService.java
The Change Event Model
With ExtractNewRecordState, the Kafka message value looks like:
{
"id": 1,
"customer_id": 42,
"product_code": "PROD-001",
"quantity": 2,
"total_amount": 149.98,
"status": "PENDING",
"__op": "c",
"__source_ts_ms": 1730000000000,
"__before": null
}
Map this to a Java record:
package com.vimleshpandey.demo.cdc.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.math.BigDecimal;
import java.time.Instant;
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrderChangeEvent(
Long id,
@JsonProperty("customer_id") Long customerId,
@JsonProperty("product_code") String productCode,
Integer quantity,
@JsonProperty("total_amount") BigDecimal totalAmount,
String status,
@JsonProperty("created_at") Instant createdAt,
@JsonProperty("updated_at") Instant updatedAt,
String __op,
@JsonProperty("__source_ts_ms") Long sourceTimestampMs,
@JsonProperty("__before") OrderSnapshot before
) {
public enum Operation {
CREATE, UPDATE, DELETE, READ;
public static Operation fromDebezium(String code) {
return switch (code) {
case "c" -> CREATE;
case "u" -> UPDATE;
case "d" -> DELETE;
case "r" -> READ;
default -> throw new IllegalArgumentException("Unknown op: " + code);
};
}
}
public Operation operation() {
return Operation.fromDebezium(__op);
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrderSnapshot(
Long id,
String status,
@JsonProperty("total_amount") BigDecimal totalAmount
) {}
}
Kafka Configuration
package com.vimleshpandey.demo.cdc.config;
import com.vimleshpandey.demo.cdc.model.OrderChangeEvent;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
import org.springframework.kafka.listener.ContainerProperties;
import org.springframework.kafka.support.serializer.JsonDeserializer;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class KafkaConfig {
@Value("${spring.kafka.bootstrap-servers}")
private String bootstrapServers;
@Bean
public ConsumerFactory<String, OrderChangeEvent> orderEventConsumerFactory() {
JsonDeserializer<OrderChangeEvent> deserializer =
new JsonDeserializer<>(OrderChangeEvent.class);
deserializer.setUseTypeHeaders(false);
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ConsumerConfig.GROUP_ID_CONFIG, "order-cdc-consumer");
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
return new DefaultKafkaConsumerFactory<>(
props, new StringDeserializer(), deserializer);
}
@Bean
public ConcurrentKafkaListenerContainerFactory<String, OrderChangeEvent>
orderEventListenerContainerFactory(
ConsumerFactory<String, OrderChangeEvent> factory) {
var cf = new ConcurrentKafkaListenerContainerFactory<String, OrderChangeEvent>();
cf.setConsumerFactory(factory);
cf.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
return cf;
}
}
The Event Consumer
package com.vimleshpandey.demo.cdc;
import com.vimleshpandey.demo.cdc.model.OrderChangeEvent;
import com.vimleshpandey.demo.cdc.service.OrderSyncService;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Component;
@Component
public class OrderEventConsumer {
private static final Logger log = LoggerFactory.getLogger(OrderEventConsumer.class);
private final OrderSyncService syncService;
public OrderEventConsumer(OrderSyncService syncService) {
this.syncService = syncService;
}
@KafkaListener(
topics = "orders.public.orders",
containerFactory = "orderEventListenerContainerFactory"
)
public void onOrderChange(
ConsumerRecord<String, OrderChangeEvent> record, Acknowledgment ack) {
OrderChangeEvent event = record.value();
// Tombstone records (delete markers on compacted topics) have null value
if (event == null) {
log.debug("Tombstone for key={}", record.key());
ack.acknowledge();
return;
}
try {
switch (event.operation()) {
case CREATE, READ -> syncService.upsertOrder(event);
case UPDATE -> syncService.handleOrderUpdate(event);
case DELETE -> syncService.deleteOrder(event.id());
}
ack.acknowledge();
} catch (Exception e) {
log.error("Failed to process CDC event order id={}", event.id(), e);
throw e; // Do NOT ack — let the container retry via its error handler
}
}
}
The Sync Service
package com.vimleshpandey.demo.cdc.service;
import com.vimleshpandey.demo.cdc.model.OrderChangeEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class OrderSyncService {
private static final Logger log = LoggerFactory.getLogger(OrderSyncService.class);
public void upsertOrder(OrderChangeEvent event) {
log.info("Upserting order id={} status={} amount={}",
event.id(), event.status(), event.totalAmount());
// Write to read model, search index, analytics store, etc.
}
public void handleOrderUpdate(OrderChangeEvent event) {
if (event.before() != null
&& !event.before().status().equals(event.status())) {
log.info("Order {} status changed: {} -> {}",
event.id(), event.before().status(), event.status());
}
upsertOrder(event);
}
public void deleteOrder(Long orderId) {
log.info("Removing order id={} from read model", orderId);
}
}
Step 5: Understanding the Event Envelope
Without ExtractNewRecordState, Debezium's raw message looks like:
{
"before": null,
"after": {
"id": 1,
"customer_id": 42,
"product_code": "PROD-001",
"quantity": 2,
"total_amount": 149.98,
"status": "PENDING"
},
"source": {
"version": "3.6.2.Final",
"connector": "postgresql",
"name": "orders-server",
"ts_ms": 1730000000000,
"lsn": 12345678
},
"op": "c",
"ts_ms": 1730000000100
}
The SMT flattens after to the top level and adds __op, __source_ts_ms, and __before fields. This simplifies deserialization but also discards the raw source block — if you need fields like lsn (log sequence number) for idempotency tracking, add them to transforms.unwrap.add.fields.
For UPDATE events, the __before field contains the row's previous state (only the columns Debezium tracks — by default all). This is invaluable for detecting which fields actually changed without maintaining your own shadow state.
Step 6: Testing with Testcontainers
package com.vimleshpandey.demo;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.KafkaContainer;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import java.time.Duration;
import java.util.List;
import java.util.Properties;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
class CdcPipelineIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withInitScript("init.sql")
.withCommand(
"postgres",
"-c", "wal_level=logical",
"-c", "max_replication_slots=5",
"-c", "max_wal_senders=5"
);
@Container
static KafkaContainer kafka =
new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.7.0"));
@Test
void consumerCanPollKafkaTopic() {
// In a full integration test you would:
// 1. Register the Debezium connector via REST, pointing at the Testcontainer Postgres
// 2. Insert a row into the orders table
// 3. Poll the topic and assert event fields
// Debezium's EmbeddedEngine (debezium-api) is the cleanest way to do this
// without running a full Kafka Connect cluster in the test.
try (KafkaConsumer<String, String> consumer = buildConsumer()) {
consumer.subscribe(List.of("orders.public.orders"));
var records = consumer.poll(Duration.ofSeconds(5));
assertThat(records).isNotNull();
}
}
private KafkaConsumer<String, String> buildConsumer() {
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers());
props.put(ConsumerConfig.GROUP_ID_CONFIG, "test-group");
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
return new KafkaConsumer<>(props);
}
}
Running the Application
# Start infrastructure
docker-compose up -d postgres zookeeper kafka kafka-connect
# Wait for Kafka Connect REST API to become available
until curl -sf http://localhost:8083/connectors; do
echo "Waiting for Kafka Connect..."; sleep 3
done
# Register the connector
curl -X POST http://localhost:8083/connectors \
-H "Content-Type: application/json" \
-d @connector-config/orders-connector.json
# Verify connector and task are RUNNING
curl http://localhost:8083/connectors/orders-postgres-connector/status
# Insert a test order
docker exec $(docker-compose ps -q postgres) \
psql -U app_user -d orders_db \
-c "INSERT INTO orders (customer_id, product_code, quantity, total_amount, status)
VALUES (42, 'PROD-001', 2, 149.98, 'PENDING');"
# Watch the CDC topic
docker exec $(docker-compose ps -q kafka) \
kafka-console-consumer.sh \
--bootstrap-server localhost:9092 \
--topic orders.public.orders \
--from-beginning
# Start the Spring Boot consumer
./mvnw spring-boot:run
Common Pitfalls and Tips
1. Replication slot accumulation
Every Debezium connector instance creates a replication slot. If a connector is deleted without dropping its slot, PostgreSQL keeps buffering WAL indefinitely, growing disk usage until the server crashes. Monitor and clean up:
SELECT slot_name, active, restart_lsn, pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
) AS lag
FROM pg_replication_slots;
-- Drop an orphaned slot
SELECT pg_drop_replication_slot('orders-server');
2. Snapshot mode choices
initial is safe but slow on large tables. For millions of rows, consider running with initial_only first, then changing to no_data on subsequent restarts. Debezium 3.x's incremental snapshots (snapshot.mode: when_needed) re-snapshot only tables that were offline during streaming, which avoids full re-snapshots after brief outages.
3. Consumer idempotency is mandatory
Debezium guarantees at-least-once delivery. Your consumer must handle duplicate events. Using the id as a natural key and doing upserts (not blind inserts) in your read model achieves this cheaply.
4. Schema changes
Adding a nullable column or a new table to the publication is safe with a connector restart. Renaming or dropping a column requires coordinating the change with a new snapshot — plan it as a maintenance window and test in staging first.
5. Monitoring lag
Track the Kafka consumer group lag on your CDC topic. A growing lag means the consumer is falling behind. Check for slow downstream writes, insufficient consumer instances, or a max.poll.records that's set too high relative to processing time.
6. Don't point at a read replica
Despite Debezium 3.4+ supporting read replicas for incremental snapshots, the logical replication slot lives on the primary. The connector must connect to the primary for streaming; routing it to a replica is not supported.
Conclusion
You've built a complete CDC pipeline with Debezium 3.x and Kafka Connect: a PostgreSQL source database with logical replication, a connector using the native pgoutput plugin and ExtractNewRecordState SMT for simplified event shapes, and an idempotent Spring Boot consumer that handles inserts, updates, and deletes.
CDC unlocks use cases that polling can't match: real-time search indexing, audit trails that can never miss an event, cache invalidation keyed to actual database changes, and cross-service data sync without tight coupling. The same pattern applies to MySQL (via the Debezium MySQL connector, using the binlog) and MongoDB — once you understand the PostgreSQL setup, other sources are mostly connector configuration.
For production hardening, focus next on: monitoring replication slot lag, alerting on connector state transitions, credential rotation without downtime, and a clear schema evolution strategy — Avro with Schema Registry if you have multiple consumer teams, JSON with strict discipline if you control all consumers.