Ecosystem
Kafka stores bytes. Real systems need more: an agreement on what the bytes mean, a way to move data in and out of databases without writing code, and a plan for records that cannot be processed. This module covers the tools and patterns for those three needs.
Try it: is this schema change safe?
A schema describes the fields of a record. Pick a change to the Order schema and see which compatibility rules accept it. The lab runs three of these against a real schema registry.
What it is and why it exists
Brokers do not look inside records. To them a key and a value are two arrays of bytes. This keeps Kafka fast and general, and it leaves three problems to you.
- Meaning. A producer team changes a field and every consumer breaks. A schema registry stores the agreed shape of each topic's records and refuses changes that would break readers.
- Integration. Most data starts in a database and ends in a database, a search index or a warehouse. Kafka Connect is a framework, part of Apache Kafka, that runs ready-made connectors for those systems, so that you configure a pipeline and do not program it.
- Consistency and failure. A service must change its database and publish an event, and both must happen or neither. And some records will always fail in processing. The outbox pattern, change data capture and dead-letter topics are the standard answers.
The one-sentence version. A schema registry guards what records mean, Kafka Connect moves data in and out without custom code, and a few well-known patterns handle the cases where a database and Kafka must agree or a record cannot be processed.
Mental model
A schema is a contract with versions
Producers and consumers of a topic are released at different times. For a while, new producers write data that old consumers read, or new consumers read data that old producers wrote. A schema change is safe only if it works in the mixed state. The registry checks this for you with a compatibility mode.
| Mode | Guarantee | Who upgrades first | Allowed changes |
|---|---|---|---|
BACKWARD (the default) | Consumers with the new schema can read data written with the previous one. | Consumers | Remove fields. Add fields that have a default. |
FORWARD | Consumers with the previous schema can read data written with the new one. | Producers | Add fields. Remove fields that have a default. |
FULL | Both of the above. | Any order | Add or remove only fields that have a default. |
NONE | No check. | — | Anything. You carry the risk. |
Each mode checks against the latest version only. The variants with _TRANSITIVE in the name check against every earlier version, which matters when consumers replay old data.
Connect is a pipeline you configure
Two writes that must agree
A service saves an order in its database and publishes "order created" to Kafka. These are two systems. If the service crashes between the two writes, one happened and the other did not. This is the dual write problem. Step through the failure and the fix:
Step through it: dual write and the outbox
database
kafka topic
How it works internally
Schema registry
A schema registry is a separate service. It is not part of Apache Kafka; the most used one is from Confluent, and others exist. It stores its data in a compacted Kafka topic, _schemas, so it has no database of its own.
- Schemas are grouped under a subject. By default the subject is the topic name plus
-valueor-key. - The producer's serializer sends the record's schema to the registry. If it is new, the registry checks it against the subject's compatibility mode. If it passes, it gets a version number and a global schema id.
- The serializer writes the schema id in front of the record's bytes. The schema itself is not sent with each record.
- The consumer's deserializer reads the id, fetches that schema from the registry once, caches it, and decodes the record.
Three formats are common: Avro, Protobuf and JSON Schema. Avro and Protobuf produce small binary records and have clear rules for adding and removing fields.
Kafka Connect
Connect runs in two modes. Standalone is one process with configuration in files, for tests. Distributed is a group of workers that you control through an HTTP interface on port 8083. Use distributed mode for anything real.
A distributed worker group keeps all its state in three Kafka topics:
| Topic setting | Holds |
|---|---|
config.storage.topic | Every connector's configuration. |
offset.storage.topic | How far each source connector has read in its source system. |
status.storage.topic | The state of each connector and task. |
Workers with the same group.id form one cluster. If a worker stops, its tasks move to the others, in the same way that partitions move in a consumer group.
A converter decides the byte format in the topic: plain text, JSON, or Avro with a schema registry. A transform makes a small change to each record, such as renaming a field or routing to a different topic. Keep transforms simple; real processing belongs in Kafka Streams.
Errors in Connect
By default one bad record stops a task: errors.tolerance=none. With errors.tolerance=all, the task skips records that fail in the converter or a transform. For a sink connector you can name a dead-letter topic. The failed record is written there unchanged, with headers that say what failed and where. The lab shows both behaviours.
Change data capture
A database already keeps an ordered log of every change, for its own recovery and replication. Change data capture reads that log and publishes each insert, update and delete as a record. The best-known tool is Debezium, a set of source connectors for Kafka Connect.
- It first takes a snapshot of the existing rows, then follows the log.
- Each record carries the row before and after the change and the type of operation.
- It sees every change in commit order, including changes made by other applications and by hand.
- It adds little load, because it reads the log and does not query the tables.
Module 13 runs Debezium against PostgreSQL: it shows the snapshot, the insert, update and delete records, and an outbox with a rolled-back transaction.
The outbox pattern
The service writes its business change and an event row into the same database, in one database transaction. The event row goes into an outbox table. A change data capture connector reads new outbox rows from the database log and publishes them to Kafka. The database transaction makes the two writes atomic. The connector delivers each event at least once, so consumers must ignore repeats by event id.
Dead-letter topics in your own consumers
The same idea applies outside Connect. When your consumer cannot process a record after its retries, it writes the record to a separate topic with the error and the original topic, partition and offset, then commits and continues. Someone, or something, must then read that topic: alert on it, fix the cause, and replay.
Two cautions. Moving a record aside breaks ordering for its key: later records for that key may be processed before it is replayed. And an error that affects every record, such as a database that is down, should stop the consumer, not send everything to the dead-letter topic.
Every relevant config
Connect defaults come from the Kafka 4.3 Connect configuration reference. Where that page gave no value, the cell says so.
Connect worker
| Worker config | Default | What it does | When to change it |
|---|---|---|---|
bootstrap.servers | required | Broker addresses. | — |
group.id | required | Names the worker cluster. Workers with the same value work together. | One value for each Connect cluster. It must differ from every consumer group id. |
key.converter, value.converter | required | The byte format of keys and values in topics. | Set a cluster default here and override it for a connector when needed. |
header.converter | SimpleHeaderConverter | The format of record headers. | Rarely. |
config.storage.topic, offset.storage.topic, status.storage.topic | required | The three internal topics. | Unique names for each Connect cluster. |
config.storage.replication.factor, offset.storage.replication.factor, status.storage.replication.factor | 3 | Copies of those topics. | Keep 3. |
offset.storage.partitions, status.storage.partitions | 25, 5 | Partition counts of two internal topics. The config topic always has one. | Rarely. |
plugin.path | null | Where connector files are found. | Always set. The lab points it at the file connector that ships with Kafka. |
listeners | http://:8083 | Address of the HTTP interface. | Protect it. Anyone who can reach it can create connectors. |
offset.flush.interval.ms | 60000 (1 minute) | How often source positions are saved. | Lower it to repeat less after a crash. |
exactly.once.source.support | disabled | Lets source connectors write records and their source position in one transaction. | Turn on when a source connector supports it and duplicates matter. |
connector.client.config.override.policy | All | Whether a connector may override producer and consumer settings. | Restrict it on a shared Connect cluster. |
scheduled.rebalance.max.delay.ms | 300000 (5 minutes) | How long the tasks of a worker that left wait for it to return before they move. | Lower for faster failover; higher to avoid moves during a rolling restart. |
task.shutdown.graceful.timeout.ms | 5000 | Time given to tasks to stop cleanly. | Rarely. |
Each connector
| Connector config | Default | What it does | When to change it |
|---|---|---|---|
connector.class | required | Which connector to run. | — |
tasks.max | 1 | The most tasks the connector may create. | Raise for parallel work. A sink cannot use more tasks than partitions. |
topics (sink) | required for sinks | The topics to consume. | — |
transforms, predicates | empty | A list of small changes to apply, and conditions for them. | For renames, routing, masking a field. |
errors.tolerance | none | all skips records that fail in conversion or transforms. | Use all only together with a dead-letter topic or error logging. |
errors.deadletterqueue.topic.name | no value given in the reference page; empty in practice | Sink connectors only: where failed records go. | Set it whenever tolerance is all. |
errors.deadletterqueue.context.headers.enable | no value given in the reference page; off in the lab until set | Adds headers with the error and the original position. | Turn on. Without it you cannot tell why a record failed. |
errors.retry.timeout, errors.retry.delay.max.ms | 0, 60000 | How long to retry a failed operation, and the longest pause between tries. | Raise the first for target systems that are briefly unavailable. |
errors.log.enable, errors.log.include.messages | false, false | Writes failures to the worker log, with or without record content. | Turn on the first. Think about private data before the second. |
exactly.once.support, transaction.boundary (source) | requested, poll | Exactly-once settings for a source connector. | With exactly.once.source.support on the worker. |
Schema registry and its serializers
| Setting | Default | What it does | When to change it |
|---|---|---|---|
| Compatibility level (global or for one subject) | BACKWARD (read from the lab registry) | The rule for new schema versions. | Use FULL or a transitive variant for topics with many independent consumers or long retention. |
| Storage topic | _schemas, one partition, compacted (seen in the lab) | Where the registry keeps everything. | Protect it. If it is lost, no consumer can decode old records. |
schema.registry.url (client) | required | Where serializers find the registry. | — |
auto.register.schemas (client) | true | Lets a producer register a new schema at run time. | Set false in production and register schemas in the build pipeline, so that a schema change is reviewed. unverified default |
Failure scenarios
A schema change breaks consumers
- What breaks
- Consumers throw on every record, or silently read wrong values.
- Cause
- No registry, or compatibility set to
NONE, or a field was renamed or its type changed. - Fix
- Check compatibility in the build pipeline before a release, with the registry's test call. To rename or retype a field, add a new field, move readers and writers over, then remove the old one.
The registry is unavailable
- What breaks
- New producers and consumers cannot start, and any client that meets a schema id it has not cached fails. Clients that already have their schemas in memory keep working.
- Fix
- Run more than one registry instance. Treat it as a critical dependency of every client.
A connector task fails on one record
- What breaks
- The task stops. Nothing after that record is delivered. The connector itself still reports "running".
- You observe
- In the lab: connector state
RUNNING, task stateFAILED, with the trace "Tolerance exceeded in error handler". The output file stayed empty although two of the three records were good. - Fix
- Monitor task states, not only connector states. Decide on tolerance and a dead-letter topic for each connector. Restart a failed task through the HTTP interface after fixing the cause.
The dead-letter topic fills and nobody looks
- What breaks
- Records are "handled" by moving them aside, and are lost in practice.
- Fix
- Alert on any record arriving in a dead-letter topic. Give the topic an owner and a replay procedure. Set its retention long enough to act.
Dual write leaves the database and Kafka different
- What breaks
- An order exists with no event, or an event exists for an order that was rolled back.
- Cause
- Two separate writes with a crash, a timeout or an error between them.
- Fix
- The outbox pattern, or change data capture on the business tables directly.
A stopped capture connector fills the database disk
- What breaks
- The database keeps its change log until the connector confirms it has read it. If the connector is down for long, the log grows until the disk is full and the database stops.
- Fix
- Alert on the connector's lag behind the database log and on the size of the retained log. Have a procedure to drop the connector's position in an emergency and take a new snapshot. Module 13 measured this: a stopped connector left its replication slot behind by 8.6 MB after 60,000 rows.
A source connector repeats records after a crash
- Cause
- Source positions are saved every
offset.flush.interval.ms, 1 minute. Records sent after the last save are sent again. - Fix
- Make consumers ignore repeats, or turn on exactly-once source support if the connector allows it.
Hands-on lab
Every command below was run on 4 October 2026 against the lab cluster. Part A uses Kafka Connect, which is inside the Kafka image. Part B uses a schema registry container that I added to docker-compose.yml; it listens on port 8086 of your machine.
k() { docker exec -i kafka-1 /opt/kafka/bin/"$@"; }
B=kafka-1:19092Part A, step 1. Start a Connect worker
docker exec kafka-1 sh -c 'cat > /tmp/connect.properties <<EOF
bootstrap.servers=kafka-1:19092
group.id=connect-lab
key.converter=org.apache.kafka.connect.storage.StringConverter
value.converter=org.apache.kafka.connect.storage.StringConverter
offset.storage.topic=connect-offsets
config.storage.topic=connect-configs
status.storage.topic=connect-status
plugin.path=/opt/kafka/libs/connect-file-4.3.1.jar
listeners=http://0.0.0.0:8083
EOF'
docker exec -d kafka-1 sh -c '/opt/kafka/bin/connect-distributed.sh /tmp/connect.properties > /tmp/connect.log 2>&1'
# the image has wget but not curl; define two helpers for the HTTP interface
get() { docker exec kafka-1 wget -q -O - "http://localhost:8083$1"; }
post() { docker exec kafka-1 wget -q -O - --header 'Content-Type: application/json' --post-data "$2" "http://localhost:8083$1"; }
# wait about 20 seconds
get /
get /connector-plugins
k kafka-topics.sh --bootstrap-server $B --list | grep connect{"version":"4.3.1","commit":"26b251a451ce941d","kafka_cluster_id":"4L6g3nShT-eMCtK--X86sw"}
FileStreamSinkConnector (sink), FileStreamSourceConnector (source),
MirrorCheckpointConnector, MirrorHeartbeatConnector, MirrorSourceConnector
connect-configs
connect-offsets
connect-status
Part A, step 2. File to topic to file
docker exec kafka-1 sh -c 'printf "line one\nline two\n" > /tmp/in.txt'
post /connectors '{"name":"file-source","config":{"connector.class":"org.apache.kafka.connect.file.FileStreamSourceConnector","tasks.max":"1","file":"/tmp/in.txt","topic":"connect-lines"}}'
post /connectors '{"name":"file-sink","config":{"connector.class":"org.apache.kafka.connect.file.FileStreamSinkConnector","tasks.max":"1","file":"/tmp/out.txt","topics":"connect-lines"}}'
# wait 8 seconds, then add a line while the connectors run
docker exec kafka-1 sh -c 'echo "line three" >> /tmp/in.txt'
# wait 6 seconds
docker exec kafka-1 cat /tmp/out.txt
get /connectors
get /connectors/file-source/status
k kafka-consumer-groups.sh --bootstrap-server $B --describe --group connect-file-sinkline one
line two
line three
["file-source","file-sink"]
{"name":"file-source","connector":{"state":"RUNNING",…},"tasks":[{"id":0,"state":"RUNNING",…}],"type":"source"}
GROUP TOPIC PARTITION LOG-END-OFFSET
connect-file-sink connect-lines 0 3
No code was written. The third line appeared in the output file a few seconds after it was added to the input file. The sink connector is a consumer group named connect- plus its name, so the lag tools from module 03 work on it.
Part A, step 3. One bad record, no tolerance
Put three records in a topic. The middle one is not JSON. Start a sink that expects JSON.
k kafka-topics.sh --bootstrap-server $B --create --topic json-in --partitions 1 --replication-factor 3
printf '{"id":1}\nthis is not json\n{"id":2}\n' | k kafka-console-producer.sh --bootstrap-server $B --topic json-in
post /connectors '{"name":"json-strict","config":{"connector.class":"org.apache.kafka.connect.file.FileStreamSinkConnector","tasks.max":"1","file":"/tmp/strict.txt","topics":"json-in","value.converter":"org.apache.kafka.connect.json.JsonConverter","value.converter.schemas.enable":"false"}}'
# wait 10 seconds
get /connectors/json-strict/status
docker exec kafka-1 cat /tmp/strict.txt{"name":"json-strict","connector":{"state":"RUNNING",…},
"tasks":[{"id":0,"state":"FAILED",…,"trace":"org.apache.kafka.connect.errors.ConnectException: Tolerance exceeded in error handler …
(strict.txt is empty)
The connector says RUNNING and its task says FAILED. The output file is empty: the two good records were not delivered either.
Part A, step 4. The same, with a dead-letter topic
post /connectors '{"name":"json-dlq","config":{"connector.class":"org.apache.kafka.connect.file.FileStreamSinkConnector","tasks.max":"1","file":"/tmp/dlq-ok.txt","topics":"json-in","value.converter":"org.apache.kafka.connect.json.JsonConverter","value.converter.schemas.enable":"false","errors.tolerance":"all","errors.deadletterqueue.topic.name":"json-in-dlq","errors.deadletterqueue.topic.replication.factor":"3","errors.deadletterqueue.context.headers.enable":"true"}}'
# wait 12 seconds
get /connectors/json-dlq/status
docker exec kafka-1 cat /tmp/dlq-ok.txt
k kafka-console-consumer.sh --bootstrap-server $B --topic json-in-dlq --from-beginning \
--formatter-property print.headers=true --timeout-ms 6000{"name":"json-dlq","connector":{"state":"RUNNING",…},"tasks":[{"id":0,"state":"RUNNING",…}],"type":"sink"}
{id=1}
{id=2}
__connect.errors.topic:json-in,__connect.errors.partition:0,__connect.errors.offset:1,
__connect.errors.connector.name:json-dlq,__connect.errors.task.id:0,
__connect.errors.stage:VALUE_CONVERTER,
__connect.errors.class.name:org.apache.kafka.connect.json.JsonConverter,
__connect.errors.exception.class.name:org.apache.kafka.connect.errors.DataException,
__connect.errors.exception.message:Converting byte[] to Kafka Connect data failed due to serialization error: ,
__connect.errors.exception.stacktrace:… Unrecognized token 'this': was expecting (JSON String, Number, …)
this is not json
The task keeps running and both good records are delivered. The bad record is in the dead-letter topic with its original value, and the headers say exactly where it came from (topic json-in, partition 0, offset 1), which stage failed, and why.
Part B, step 1. Start the schema registry
docker compose up -d schema-registry
R=http://localhost:8086
H='Content-Type: application/vnd.schemaregistry.v1+json'
curl -s $R/config{"compatibilityLevel":"BACKWARD"}
Part B, step 2. Register a schema and evolve it
Version 1 has id and amount. Version 2 adds currency with a default. Version 3 tries to add customerId without a default.
V1='{"schema":"{\"type\":\"record\",\"name\":\"Order\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"},{\"name\":\"amount\",\"type\":\"double\"}]}"}'
V2='{"schema":"{\"type\":\"record\",\"name\":\"Order\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"},{\"name\":\"amount\",\"type\":\"double\"},{\"name\":\"currency\",\"type\":\"string\",\"default\":\"CAD\"}]}"}'
V3='{"schema":"{\"type\":\"record\",\"name\":\"Order\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"},{\"name\":\"amount\",\"type\":\"double\"},{\"name\":\"currency\",\"type\":\"string\",\"default\":\"CAD\"},{\"name\":\"customerId\",\"type\":\"string\"}]}"}'
curl -s -X POST -H "$H" -d "$V1" $R/subjects/orders-value/versions
curl -s -X POST -H "$H" -d "$V2" $R/subjects/orders-value/versions
curl -s -X POST -H "$H" -d "$V3" $R/subjects/orders-value/versions
curl -s $R/subjects/orders-value/versions{"id":1,"version":1,…,"schemaType":"AVRO",…}
{"id":2,"version":2,…}
{"error_code":40901,"message":"Schema being registered is incompatible with an earlier schema for subject
\"orders-value\", details: [{errorType:'READER_FIELD_MISSING_DEFAULT_VALUE', description:'The field
'customerId' at path '/fields/3' in the new schema has no default value and is missing in the old schema' …
[1,2]
The registry refused version 3. A consumer with the new schema could not read an old record, because the old record has no customerId and the schema gives no default to use.
Part B, step 3. Test a change without registering it
Version 4 removes amount. Test it under the default mode, then switch the subject to FULL and test again.
V4='{"schema":"{\"type\":\"record\",\"name\":\"Order\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"},{\"name\":\"currency\",\"type\":\"string\",\"default\":\"CAD\"}]}"}'
curl -s -X POST -H "$H" -d "$V4" "$R/compatibility/subjects/orders-value/versions/latest?verbose=true"
curl -s -X PUT -H "$H" -d '{"compatibility":"FULL"}' $R/config/orders-value
curl -s -X POST -H "$H" -d "$V4" "$R/compatibility/subjects/orders-value/versions/latest?verbose=true"{"is_compatible":true,"messages":[]}
{"compatibility":"FULL"}
{"is_compatible":false,"messages":["{errorType:'READER_FIELD_MISSING_DEFAULT_VALUE', description:'The field
'amount' at path '/fields/1' in the old schema has no default value and is missing in the new schema' …
Removing a field is allowed under BACKWARD: new consumers simply ignore it in old data. Under FULL it is refused, because old consumers still expect amount and would find nothing. This test call is what a build pipeline should run before a release.
Part B, step 4. See where the registry keeps its data
k kafka-topics.sh --bootstrap-server $B --describe --topic _schemas | head -1Topic: _schemas PartitionCount: 1 ReplicationFactor: 3 Configs: min.insync.replicas=2,cleanup.policy=compact
Common mistakes and misconceptions
| People think | What is true |
|---|---|
| Kafka checks the format of records. | Brokers store bytes. Checks happen in the clients, with a registry. |
| The schema registry is part of Apache Kafka. | It is a separate product. Kafka Connect is part of Apache Kafka. |
BACKWARD means old consumers keep working. | It means new consumers can read old data. Old consumers reading new data is FORWARD. |
| JSON needs no schema. | It has one; it is only unwritten. Unwritten contracts break without warning. |
| A connector in state "running" is healthy. | Its tasks can be failed. Check task states. |
| A dead-letter topic solves bad records. | It moves them. Someone must read, fix and replay them. |
| Writing to the database and then to Kafka is safe if I retry. | A crash between the two leaves them different. Use an outbox or change data capture. |
| Polling a table for new rows is the same as change data capture. | Polling misses deletes and intermediate updates, and adds query load. Reading the database log does neither. |
Staff-level questions
Which compatibility mode do you set as the company default?
BACKWARD fits the common case where consumers can be upgraded before producers and data is not replayed from far back. For topics with long retention or with consumers that reprocess history, use BACKWARD_TRANSITIVE, because a new consumer must read every old version.
For topics that many teams consume, where you cannot make them all upgrade first, FULL_TRANSITIVE is the safe choice: only optional fields can come and go, and any version can read any other.
How do you make a breaking change to a topic's schema?
You do not change it in place. Create a new topic with the new schema, for example with a version in its name. Produce to both for a time, or run a small job that converts one into the other. Move consumers one by one. Stop the old topic when its last consumer has left and its retention has passed.
This is slow on purpose. A topic's schema is a public interface.
Outbox table or change data capture on the business tables?
Capture on business tables is quick to start, needs no application change, and publishes the internal table layout. Every consumer then depends on your columns, and a refactoring of the database breaks them.
An outbox publishes events you designed, with a stable shape and a business meaning ("order confirmed"), at the cost of writing them in the application. For events that other teams consume, prefer the outbox. For copying data to a warehouse or a cache you own, direct capture is fine.
How do you run Kafka Connect for many teams?
Decide between one shared cluster and one for each team or workload. Shared is cheaper, but connectors share memory and a bad one can disturb the others, and a rebalance touches all tasks. Separate clusters isolate failures and upgrades.
In either case: lock down the HTTP interface, restrict which client settings connectors may override, keep credentials in a secrets provider and out of connector configurations, monitor every task state and sink lag, and manage connector configurations from version control.
Design the retry and dead-letter handling for a consumer that calls a payment service.
Separate the errors. A temporary error, such as a timeout, deserves retries with growing pauses. If the payment service is down, pause the consumer; do not push thousands of records aside. A permanent error on one record, such as an invalid field, goes to a dead-letter topic at once with the error and the original position.
Then handle order. If payments for one account must stay in order, moving one aside lets later ones overtake it. Either hold all later records for that key until it is resolved, or design the operation so that order does not matter. Finally make the call safe to repeat with a request id, because the consumer delivers at least once.
What breaks if the _schemas topic is lost?
Every record written with the registry contains only a schema id. Without the registry's data, nobody can decode records, old or new. Re-registering schemas may give different ids, which does not help for existing data.
So treat that topic as critical data: three copies, a backup of its content outside the cluster, and a tested restore that keeps the ids.
Self-test
1. What does a broker know about the content of a record?
Nothing. Keys and values are bytes to it.
2. What does BACKWARD compatibility guarantee?
A consumer with the new schema can read data written with the previous schema.
3. Under BACKWARD, is "add a field without a default" allowed?
No. The lab registry refused it with READER_FIELD_MISSING_DEFAULT_VALUE.
4. What travels with each record when a schema registry is used?
A schema id, not the schema.
5. Name the three internal topics of a distributed Connect cluster.
The config, offset and status storage topics.
6. What is the difference between a connector and a task?
The connector defines the job and splits it. Tasks do the copying, and workers share them.
7. What does errors.tolerance=all do without a dead-letter topic?
It skips records that fail in conversion or transforms, without keeping them.
8. What is the dual write problem?
Writing to a database and to Kafka as two separate steps, so that a failure between them leaves the two different.
9. How does the outbox pattern solve it?
The event is written to a table in the same database transaction as the business change, and a capture connector publishes it to Kafka afterwards.
10. What does change data capture read?
The database's own log of changes.