SevyDevy

Guide · System design

Event Sourcing and CDC: Store Events, Derive State

26 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • Event sourcing CDC means pairing two ideas. Event sourcing saves every change as an event, a record of something that happened, and works out the current state from those events. CDC (change data capture) copies each committed database change to other systems.
  • In event sourcing, the stored events are the truth. A balance is a result you can throw away and rebuild by replaying the events in order.
  • Log-based CDC, the kind this guide covers, reads the log the database already writes for itself, such as PostgreSQL's write-ahead log. Other systems receive every committed insert, update and delete without querying your tables.
  • Never save to the database and then send a message as a separate step. Write an outbox row (a message waiting to be sent) in the same database transaction as the change, so both commit or neither does. Let CDC publish it, and expect duplicates.
  • Beginners can stop after the table comparing the two. Engineers then get checks tested on PostgreSQL 16. Two writers race and only one wins, and with default settings a rolled-back outbox row never leaves. One query shows how much log a stuck reader holds.

What goes wrong when a database keeps only the latest balance?

You lose the story. When every update overwrites the old value, nobody can prove how the balance got there, and other systems never see the changes in between. The pairing this guide explains, event sourcing CDC, fixes both: keep every change as an event, and stream each committed change out.

Picture a common setup. A prepaid wallet app serves housing societies in Ahmedabad. Residents top it up, then pay monthly maintenance and clubhouse bookings from it. The setup is made up, but the problem is real.

The first version has one table called wallets, with a wallet id and a balance column. A payment runs one SQL statement that subtracts the amount from the balance. About 38,000 wallets work this way.

One Monday, a resident raises a complaint. She topped up Rs 5,000, paid Rs 2,100 maintenance and booked the clubhouse for Rs 400. She expects Rs 2,500 left. The app shows Rs 400.

The team looks at the wallets table. It says Rs 400, and that is all it says. Nobody can prove what happened, so support refunds Rs 2,100 on trust.

The data team has a second problem. Society treasurers want daily dashboards of who paid. A nightly job copies the whole wallets table to the data warehouse, a separate database built for reports. It sees only end-of-day balances and misses every change in between.

Both problems have one root. The system stores the result of changes, not the changes themselves. Event sourcing keeps the changes, and change data capture streams them out.

Which words do you need before event sourcing and CDC?

Six words carry this whole guide. Each one is used again later in exactly this sense.

  • State: the current value your app shows, such as a wallet balance.
  • Event: a record of something that already happened, named in the past tense, such as MaintenancePaid. Events are never edited.
  • Transaction: a group of database changes that all succeed or all fail together.
  • Log: a file the database writes every change into, in order, before it changes the tables. PostgreSQL's log is the write-ahead log, or WAL.
  • Message broker: a system such as Kafka that carries messages between services. Kafka keeps messages in topics, which are named streams that many readers can follow.
  • Idempotent: handling the same message twice has the same effect as handling it once.

What does event sourcing CDC mean, and how do the two fit?

Event sourcing CDC means using two separate ideas together. Event sourcing decides what you store: every change, as an event. CDC decides how changes leave the database and reach other systems.

With event sourcing you never edit or delete an event. You only add new ones, so the list is append-only. The place that stores them is called an event store; here it is a plain PostgreSQL table.

Martin Fowler's 2005 article on the pattern puts it this way. All changes to application state are stored as a sequence of events. You can then throw the state away and rebuild it by replaying the events.

CDC does not change how your application stores data. It reads the log the database keeps for itself, and turns each committed change into a message other systems can read.

Picture the milk collection centre of a village dairy cooperative near Anand. Each morning the secretary writes a slip for every pouring: farmer number, litres and fat reading. Every slip is kept.

At month end, each farmer's payment is worked out by adding up that farmer's slips. If a farmer disputes the total, the secretary lays out the slips and checks them one by one. Each evening, a copy of the day's slips goes to the district union office.

  • The slips are events: written once, never changed.
  • The monthly total is state: worked out from the slips, never trusted on its own.
  • Laying out the slips to check a total is a replay.
  • The evening copy for the union office is CDC: others get every change without visiting each farmer.

Where the picture breaks: a paper copy can go missing and nobody notices. A CDC reader instead keeps its place in the database log, and the database keeps old log files until the reader confirms them. That safety has its own cost, covered later.

How does event sourcing work out the current balance?

It reads a wallet's events in order and applies each one to a running total. A top-up adds money, a payment subtracts it, and a refund adds it back.

The team replaces the balance column with a table of events. Each row holds the wallet id, a version number, the event kind, the amount and, for payments, the bill being paid. The version is 1 for a wallet's first event, 2 for the second, and so on.

Amounts are whole numbers of paise, where 100 paise make a rupee. Floats cannot hold most decimal fractions exactly: in Python, 0.1 + 0.2 prints 0.30000000000000004.

Pause and predict. The resident's wallet holds the four events below. What balance will the code print, and can you spot what went wrong?

python
from collections import Counter
from dataclasses import dataclass

@dataclass(frozen=True)
class Event:
    wallet_id: str
    version: int      # 1, 2, 3 ... per wallet
    kind: str         # "TopUp", "Payment" or "Refund"
    amount: int       # in paise (100 paise = 1 rupee), never a float
    bill: str = ""    # which bill a payment or refund is for

def balance(events):
    total = 0
    for e in sorted(events, key=lambda e: e.version):
        if e.kind in ("TopUp", "Refund"):
            total += e.amount
        elif e.kind == "Payment":
            total -= e.amount
        else:
            raise ValueError(f"Unknown event kind: {e.kind}")
    return total

history = [
    Event("W-2231", 1, "TopUp", 500000),
    Event("W-2231", 2, "Payment", 210000, "maintenance-2026-10"),
    Event("W-2231", 3, "Payment", 210000, "maintenance-2026-10"),
    Event("W-2231", 4, "Payment", 40000, "clubhouse-2026-10-12"),
]
paid = Counter(e.bill for e in history if e.kind == "Payment")
print("balance:", balance(history) // 100, "rupees")
print("charged twice:", [bill for bill, n in paid.items() if n > 1])

Save it as wallet.py and run python3 wallet.py (we ran it on Python 3.13). It prints balance: 400 rupees, then charged twice: ['maintenance-2026-10'].

What just happened? The Rs 400 balance was right all along. The events show why: the October maintenance bill was paid twice, at versions 2 and 3.

Two lines do the important work. Sorting by version fixes the order of the replay. A plain sum gives the same total in any order. Order matters once a rule reads the running balance, such as refusing a payment that would go below zero. The final else raises an error for an unknown kind, so a new event type is never skipped silently.

The cause was a slow request that the app retried, and the server charged twice. The team does not delete version 3. It appends version 5, a Refund of Rs 2,100 for the same bill, and the balance becomes Rs 2,500.

Stopping two writers at once

Two requests can read the same last version at the same moment. Both see Rs 400, and both try to spend it. A unique key on wallet id plus version stops this.

Each writer reads the last version, say 1, and inserts version 2. If another writer inserted version 2 first, the database rejects the second insert. The loser reloads the events, checks the balance again and retries.

This is optimistic concurrency control: no lock up front, just a check at the moment of writing. A second unique index guards the double charge itself, allowing one Payment per wallet per bill. Try both in psql, PostgreSQL's command-line client, on a scratch database.

sql
CREATE TABLE wallet_events (
  wallet_id text,
  version   int,
  kind      text   NOT NULL,
  amount    bigint NOT NULL,          -- paise
  bill      text,
  PRIMARY KEY (wallet_id, version)    -- one event per version per wallet
);
CREATE UNIQUE INDEX one_payment_per_bill
  ON wallet_events (wallet_id, bill) WHERE kind = 'Payment';

INSERT INTO wallet_events VALUES ('W-2231', 1, 'TopUp', 500000, NULL);
INSERT INTO wallet_events VALUES ('W-2231', 2, 'Payment', 210000, 'maintenance-2026-10');

-- A second writer that also read version 1:
INSERT INTO wallet_events VALUES ('W-2231', 2, 'Payment', 40000, 'clubhouse-2026-10-12');
-- A retried request for a bill that is already paid:
INSERT INTO wallet_events VALUES ('W-2231', 3, 'Payment', 210000, 'maintenance-2026-10');

On PostgreSQL 16.15 the first two inserts succeed. The third fails with duplicate key value violates unique constraint wallet_events_pkey. The fourth fails with the same message naming one_payment_per_bill.

We also raced two real writers 20 times, each reading version 1 before inserting version 2. Every time exactly one insert succeeded, and the balance never went below zero. If a refunded bill can be paid again, key the second guard on a request id instead.

What are snapshots and projections in event sourcing?

A snapshot is a saved copy of the state at one version, so you replay only the events after it. A projection is a ready-made view built from events and shaped for one kind of read, such as the balance screen.

Replaying every event on every request gets slow as wallets grow. A busy wallet might have 600 events after two years. So every 100 events, the service saves a snapshot row: wallet W-2231, version 500, balance Rs 1,850.

To get the balance, the service loads that row and applies only the events after version 500. Fowler's article describes the same move: start from an overnight snapshot, then replay the events since. A snapshot is only a speed-up; if one is wrong, delete it and rebuild.

Projections answer the questions that the event list answers badly. The app's home screen needs one balance, fast. Treasurers need a list of flats with unpaid maintenance. Each gets its own table, filled by a worker that reads new events in version order.

Follow the write path in the diagram from the resident to the event store, then the arrows out to the two views. Notice that the wallet service writes to only one place.

Society wallet with event sourcing

Writes go only to the event store. Both views are built from it, so either can be dropped and rebuilt by replaying the events.

Diagram connections:

  • Resident's wallet app to Wallet service (Pay Rs 2,100)
  • Wallet service to Event store in PostgreSQL (Append MaintenancePaid)
  • Event store in PostgreSQL to Projection worker (New events)
  • Projection worker to Balance view
  • Projection worker to Unpaid dues view
  • Resident's wallet app to Balance view (Read balance)
  • Resident's wallet app: Pays maintenance, books the clubhouse
  • Wallet service: Checks the balance, then appends an event
  • Event store in PostgreSQL: Append-only, one row per event
  • Projection worker: Reads new events in version order
  • Balance view: Current balance per wallet, for the app
  • Unpaid dues view: Flats that owe maintenance, for treasurers

The picture hides one cost. The views update a moment after the event is stored, and how long depends on how fast the worker runs. A resident who pays and refreshes at once might see the old balance briefly.

This delay is called eventual consistency: the views agree with the events soon, not instantly. A simple fix for the payer is to return the new balance in the payment reply itself.

How does change data capture work?

Log-based change data capture reads the log a database writes before it changes its tables, and turns each committed change into a message. Other systems subscribe to those messages instead of querying the database.

Some CDC tools instead poll each table for rows with a newer timestamp. The blog of Debezium, an open-source CDC tool, notes that polling can miss changes made between two polls. This guide covers the log-based kind.

PostgreSQL's documentation states the rule behind its WAL. Changes to data files are written only after the log records describing them reach permanent storage. MySQL CDC tools read a different log, the binary log (binlog), which MySQL writes for replication and recovery.

The WAL is written for the database's own use, in a low-level format. PostgreSQL's logical decoding turns it into readable changes, such as a new row in a table. An output plugin decides the exact format the reader receives.

A reader connects through a replication slot. The docs describe a slot as a stream of changes replayed to a client in the order they were made. The slot remembers how far its reader has got, and it survives crashes.

With the default settings we tested, that order is commit order. In our test, transaction A wrote first but committed second, and the slot sent B's change before A's. With those settings, changes from a transaction that rolls back never appear at all.

The data team uses Debezium. Its PostgreSQL connector reads the logical replication stream, and its MySQL connector reads the binlog. Most often it runs inside Kafka Connect, a framework for moving data in and out of Kafka.

By default each table's changes go to their own Kafka topic, named topicPrefix.schemaName.tableName, such as wallet.public.wallets. Many readers can follow one topic, and reading a message does not delete it.

Here is the data team's pipeline. Start at the wallet database and follow each change to the two places that use it. Notice that nothing in the pipeline runs a query against the wallet tables.

CDC pipeline for the wallet

Changes leave through the database's own log, in commit order. The slot holds the reader's place, so a stopped connector resumes instead of missing changes.

Diagram connections:

  • Wallet database to Replication slot (Logical decoding)
  • Replication slot to Debezium connector (Changes in commit order)
  • Debezium connector to Kafka topic
  • Kafka topic to Warehouse loader
  • Kafka topic to Receipt sender
  • Wallet database: Every commit is logged in the WAL first
  • Replication slot: Remembers how far the reader has got
  • Debezium connector: Turns decoded changes into change events
  • Kafka topic: Keeps change events in order per key
  • Warehouse loader: Feeds the treasurer dashboards
  • Receipt sender: Sends an SMS receipt per payment

When a pipeline starts, it first needs the rows that already exist. In its default mode, Debezium's PostgreSQL connector takes a consistent snapshot of the tables the first time it starts. It then streams changes from the exact point where that snapshot was made.

PostgreSQL supports this directly. Creating a slot through the replication protocol exports a snapshot showing the database exactly as it was when the slot's stream begins.

The nightly copy job is gone. Treasurer dashboards now follow changes as they commit, and nothing reads every row of the wallets table each night.

What is the difference between event sourcing and CDC?

Event sourcing is a choice about how your application stores data, while CDC is a way to copy changes out of a database. Event sourcing events carry business meaning; CDC events describe row changes.

The difference shows in what a reader receives. An event-sourced event says MaintenancePaid for October. A CDC event on the old wallets table says only that the balance went from 2,900 to 800. The reason is lost.

Event sourcing and CDC compared on the society wallet
Event sourcingChange data capture
What it isA way to store your dataA way to copy changes out
Source of truthThe list of eventsThe normal tables
What one event saysMaintenancePaid, Rs 2,100, OctoberRow 2231: balance 2,900 became 800
Who writes the eventsYour code, on purposeThe database log, for every commit
Changes to existing codeLarge: a new data modelSmall: the app barely notices
Can rebuild past stateYes, by replaying eventsOnly while the log or topic still holds them
Best use in the walletMoney movementsFeeding the warehouse and receipts

The team uses both. Money movements are event-sourced, because every rupee needs a reason. Member lists, flats and settings stay as plain tables, and CDC streams them out unchanged.

How do you publish events without losing any?

Write each event to an outbox table in the same transaction as the change, then let a separate relay send it to the broker. The event leaves if, and only if, the change commits.

The tempting first try is a dual write: save the payment, then send a message to Kafka on the next line. It is wrong, and it fails in two ways.

A crash after the save loses the message. Sending first and then rolling back announces a payment that never happened. The wallet team hit the first failure: a server restarted between the two lines, and 37 residents got no SMS receipt.

The transactional outbox pattern fixes this. The service inserts the business change and an outbox row in one transaction. A relay then reads the outbox and publishes each row.

The microservices.io page on the pattern names two ways to build that relay. One polls the outbox table; the other tails the transaction log, which is exactly what CDC does.

Debezium ships a piece for this, the outbox event router. It is a transformation that runs inside the connector and turns each new outbox row into a Kafka message. An aggregate is the thing a group of events belongs to, here one wallet. As of 19 October 2026 (Debezium 3.7 docs), the default outbox columns are id, aggregatetype, aggregateid, type and payload.

By default the router sends each row to a topic named outbox.event.wallet: the prefix outbox.event plus the aggregatetype value. The aggregateid becomes the Kafka message key, and the row's id goes into a message header named id.

The router expects only inserts on the outbox table and filters out deletes. So a cleanup job can remove old outbox rows without sending anything.

Kafka splits a topic into partitions. Events with the same key are written to the same partition, and a reader of one partition sees its events in the order written. So each wallet's events stay in order, but only per wallet.

Kafka's docs add a warning. Adding partitions later changes which partition a key maps to, which can break ordering for existing keys. Choose the partition count up front.

In the diagram, follow one payment from the service to the SMS, and notice the check near the end.

Outbox plus CDC for payment receipts

One transaction writes both rows, so the event exists only if the payment does. The receiver drops repeats, because a delivery can arrive twice.

Diagram connections:

  • Wallet service to One database transaction (Commit both rows)
  • One database transaction to Debezium with outbox router (Read from the log)
  • Debezium with outbox router to outbox.event.wallet
  • outbox.event.wallet to Event id already handled?
  • Event id already handled? to Skip the repeat (Yes)
  • Event id already handled? to Send SMS receipt (No)
  • Wallet service: Handles the maintenance payment
  • One database transaction: Event row plus outbox row, or neither
  • Debezium with outbox router: Connector reads the log; router picks the topic
  • outbox.event.wallet: Keyed by wallet id, so order holds
  • Event id already handled?: Checked in the receipt worker's table
  • Skip the repeat: No second SMS for the same payment
  • Send SMS receipt: Then record the event id as handled

Duplicates are normal in this design. The microservices.io page warns that the relay might publish a message more than once. Debezium's FAQ says that after a crash, consumers see every event at least once.

We saw the database side of this. A change already read from a slot came back after an immediate shutdown and restart, 3 runs out of 3. PostgreSQL saves a slot's position only at a checkpoint, the moment it flushes changed data to disk, which explains it.

So every consumer must be idempotent. Here is a receipt worker you can run as it is. An in-memory SQLite table stands in for the worker's table of handled event ids, and send_receipt_sms stands in for the SMS gateway.

python
import sqlite3

db = sqlite3.connect(":memory:")   # the worker's own table of handled event ids
db.execute("CREATE TABLE handled (event_id TEXT PRIMARY KEY)")
sent = []

def send_receipt_sms(resident_id, amount, bill):
    sent.append((resident_id, amount, bill))   # stand-in: looks up the phone by id, sends the SMS

def handle_outbox_event(event):
    if event["type"] != "MaintenancePaid":
        return                                  # this worker only sends receipts
    row = db.execute("SELECT 1 FROM handled WHERE event_id = ?", (event["id"],)).fetchone()
    if row:
        return                                  # a repeat after a relay restart
    p = event["payload"]
    send_receipt_sms(p["resident_id"], p["amount"], p["bill"])
    db.execute("INSERT INTO handled VALUES (?)", (event["id"],))
    db.commit()                                 # only after the SMS went out

event = {"id": "evt-91", "type": "MaintenancePaid",
         "payload": {"resident_id": "R-118", "amount": 210000, "bill": "maintenance-2026-10"}}
for _ in range(3):                              # the same event delivered three times
    handle_outbox_event(event)
print("SMS sent:", len(sent))

It prints SMS sent: 1, although the same event arrived three times. The payload carries a resident id, not a phone number, for a reason the failure section explains.

The order of the last two steps is deliberate. Marking after sending means a crash in between causes a second SMS, not a missing one. That holds only if the worker tells Kafka it is done with a message after this function returns. Our crash test sent exactly two. For money, make the handled check and the balance change one database transaction.

How can you watch CDC read your own PostgreSQL log?

Create a logical replication slot with test_decoding, an example output plugin included with PostgreSQL, and read what it emits. That is the raw material every PostgreSQL CDC tool works from.

You need a scratch PostgreSQL server with wal_level set to logical, which needs a restart, and a superuser connection, meaning a role that skips all permission checks. Then run this in a scratch database. We ran it on PostgreSQL 16.15.

sql
CREATE TABLE outbox (
  id            text PRIMARY KEY,
  aggregatetype text NOT NULL,
  aggregateid   text NOT NULL,
  type          text NOT NULL,
  payload       jsonb NOT NULL
);
SELECT 'slot created' FROM pg_create_logical_replication_slot('demo_slot', 'test_decoding');

BEGIN;
INSERT INTO outbox VALUES ('evt-91', 'wallet', 'W-2231', 'MaintenancePaid', '{"amount": 210000}');
COMMIT;

BEGIN;
INSERT INTO outbox VALUES ('evt-92', 'wallet', 'W-2231', 'ClubhouseBooked', '{"amount": 40000}');
ROLLBACK;

SELECT data FROM pg_logical_slot_get_changes('demo_slot', NULL, NULL);
SELECT pg_drop_replication_slot('demo_slot');

Expect three rows from the get_changes query: BEGIN with a transaction number, one line for evt-91, then COMMIT with the same number. Your numbers will differ. With these default settings, the rolled-back evt-92 never appears, which is the outbox promise in action.

pg_logical_slot_get_changes consumes what it returns, so a second call shows nothing new. Use pg_logical_slot_peek_changes to look without consuming. The last line drops the slot, and the next section shows why that matters.

What can go wrong with event sourcing and CDC?

Most problems come from time: event formats change, logs grow and replays can repeat side effects. Each one has a known guard, and you should plan them before launch.

  • Event formats change. A payment may later need a field for GST, India's goods and services tax. Old events never change, so the replay code must read every version, or convert old events to the newest shape as it loads them.
  • Replays repeat side effects. Rebuilding a view must never send 38,000 SMS again. Fowler's article suggests gateways to outside systems that can be switched off during a replay.
  • A stuck reader fills the disk. A slot keeps WAL until its reader confirms it, even with nobody connected. On PostgreSQL 16, the default max_slot_wal_keep_size of -1 means no limit.
  • Order holds only per key. Kafka keeps order within a partition, not across a topic. Design consumers so that only each wallet's own order matters.
  • Personal data outlives erasure requests. Events are kept for good, but a resident may ask you to delete her phone number. Keep personal details out of events and refer to them by id.

We tested the stuck reader on PostgreSQL 16.15, with max_wal_size set to 64MB. We created a slot, never read from it, and inserted about 1,000,000 rows. The slot then held 253 MB of WAL, and the pg_wal folder grew from 33 MB to 273 MB.

Once the slot was advanced to the current position, a checkpoint brought the folder back to 65 MB. Run this query on any server with slots; wal_held is how much log each slot keeps alive.

sql
SELECT slot_name, active, wal_status,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS wal_held
FROM pg_replication_slots;

A healthy, connected slot shows active as t and a small wal_held. Our stuck slot showed active f, wal_status extended and wal_held 253 MB. pg_size_pretty counts in powers of 2, so its MB means 1,048,576 bytes.

You can cap the damage with max_slot_wal_keep_size. With it set to 100MB, the same load turned the slot's wal_status to lost. Reading from it then failed with can no longer get changes from replication slot.

The disk is safe, but that slot is finished. Drop it and set the reader up again, including a fresh copy of the existing rows. The PostgreSQL docs add that a forgotten slot also stops VACUUM, the cleanup process, from removing some rows in the system catalogs, the internal tables that describe your tables.

The data team adds two alarms. One fires when any slot holds more than 5 GB of WAL. The other fires when the newest change in the warehouse is more than five minutes old. Both thresholds are made up; set yours from your disk size.

When should you not use event sourcing?

Skip event sourcing when nobody needs the history of changes, or when the data is simple settings edited in place. It adds code, storage and new ways to fail, so it must earn its place.

The team uses a simple test. Would a person ever ask why this value is what it is, and would the answer matter? For a wallet balance, yes, because money and disputes are on the line. For a society's display name, no.

  • Good fits: wallets and ledgers, order and booking life cycles, approvals, and anything an auditor may ask about.
  • Poor fits: user profiles, app settings, product descriptions, and data where only the latest value has ever mattered.
  • Warning sign: the team is new to it and the deadline is close. Event sourcing is hard to remove once events are the truth.
  • Middle path: keep normal tables, add an outbox for the events others need, and stream it with CDC.

CDC is a much smaller step. It leaves your data model alone, so it suits most teams that need changes elsewhere, such as search, a cache or a warehouse. Its main costs are running the connector and watching the slots.

How do you explain event sourcing and CDC in a system design interview?

Explain them as two separate tools: event sourcing for how one part stores its data, and CDC for how changes reach other systems. Then justify each with a concrete need.

  1. Find the part that needs history

    Point at the data where people ask why, such as a wallet balance. Say that only that part is event-sourced.

  2. Name the events

    List a few in the past tense: WalletToppedUp, MaintenancePaid, RefundIssued. Say they are append-only and carry a version per wallet.

  3. Show how state is derived

    Replay events to get a balance, use snapshots for speed, and build projections for each kind of read.

  4. Guard the writes

    Use a unique key on wallet id plus version for concurrency, and a bill id so a retry cannot pay twice.

  5. Get changes out with outbox and CDC

    Write an outbox row in the same transaction, tail the log with a CDC connector, and publish to Kafka keyed by wallet id.

  6. Cover the failure cases

    Idempotent consumers for duplicates, silent replays, event versioning, slot lag alarms with a WAL cap, and personal data kept out of events.

Questions people ask

Is event sourcing the same as CDC?
No. Event sourcing is a way to store data, where events written by your code are the source of truth. CDC copies changes out of a database's log, whatever data model the tables use. You can use CDC without event sourcing, and the middle path in this guide does exactly that.
Is Kafka an event store?
It can hold events for as long as each topic's retention setting allows, but it is a poor fit as the only event store here. Event sourcing needs to load one wallet's events fast and reject a stale write. A database table with a unique key on wallet id plus version does both, and Kafka then carries the events to other services.
Does event sourcing need CQRS?
Not strictly, but they usually travel together. CQRS, short for command query responsibility segregation, means separate models for changing data and for reading it. The event list is a poor shape for most reads, so you build projections anyway. That is CQRS in practice, even if nobody calls it that.
How do you delete personal data from an event store?
Keep personal data out of events where you can. Store a resident id in the event and the phone number in a normal table you can delete from. Mathias Verraes describes crypto-shredding: encrypt sensitive fields with a key per customer, then delete the key. His article also quotes a caution that encrypted personal data is still personal data, so check with your legal team.
What is the outbox pattern in simple words?
You save the message you want to send in your own database, in the same transaction as the change. A separate relay later sends it to the broker. Because both rows commit together, a committed change always has its message and a rolled-back change never does. The relay may still send a message twice, so receivers must skip repeats.
Does CDC slow down the main database?
It adds some work, because the server decodes its log for the reader, but it reads only the changes rather than whole tables. The bigger risk is a stopped reader. PostgreSQL keeps WAL for that slot until the disk fills or, if you set max_slot_wal_keep_size, until the slot is invalidated.
How do you change the format of old events?
You never edit stored events. Instead, give each event type a version number and teach the loading code to read every version. A common trick is to convert old events to the newest shape as they are loaded, so the rest of the code sees only one format.

Sources and further reading

Practise this with Lala

Practise