Change Data Capture in PostgreSQL with .NET and Aspire

The database says the product is sold out. Search still says it's available.

Saving a change is not the same as telling every other system about it. That gap is where inventory goes stale, caches lie, and read models drift. Change Data Capture (CDC) closes it by turning the database's own write log into a stream of events.

📺 Prefer to watch? The same walkthrough is on YouTube:

What is Change Data Capture?

Change Data Capture is a technique for reading committed row changes directly from the database's transaction log and publishing them as a stream of insert, update and delete events.

The important word is committed. CDC is not a trigger, not a polling loop, and not a second write your application has to remember to make. The database has already durably written the change to its log in order to be crash-safe; CDC reads that same log. If the transaction committed, the event exists. If it rolled back, there is nothing to read.

Three properties follow from that:

  • No dual write. The application writes once, to the database. There is no second "and also publish an event" step that can fail independently.
  • Nothing is missed. Every committed change is in the log, including changes made by batch jobs, other services, or someone at a psql prompt.
  • It is asynchronous by nature. The consumer is always slightly behind. CDC gives you eventual consistency, not a distributed transaction.

How PostgreSQL does it: WAL, logical decoding, slots

PostgreSQL records every write in its write-ahead log (WAL) before touching the data files. That log is a physical record — page-level changes — so it isn't directly useful to an application.

Logical decoding is the translation layer. It takes the physical WAL and turns it into logical row events (INSERT, UPDATE, DELETE) using an output plugin. Since PostgreSQL 10 the built-in plugin is pgoutput, and it's the one you want.

Four concepts do all the work:

Concept What it does
WAL The durable, ordered record of every write. The source of truth for CDC
Logical decoding Converts physical WAL records into row-level logical events
Publication Declares which tables are streamed — CREATE PUBLICATION ... FOR TABLE ...
Replication slot Bookmarks how far a consumer has read, and retains WAL until it catches up

The replication slot is the part that bites people, so it's worth being blunt about it:

A replication slot guarantees that PostgreSQL will not delete WAL a consumer hasn't read yet. If your consumer is down for two days, PostgreSQL keeps two days of WAL. An abandoned slot will fill the disk on your primary database and take the whole server down with it.

Drop slots you no longer use, and alert on pg_replication_slots lag. This is the single most common way a CDC setup causes an outage.

CDC or the Outbox pattern?

Both solve the dual-write problem. They solve it at different layers, and the trade-off is about ownership, not performance.

Outbox pattern Change Data Capture
What is published An event you designed and wrote A row change, shaped like your schema
Coupling Consumers depend on your event contract Consumers depend on your table layout
Schema changes You control the event version A column rename breaks every consumer
Application changes Yes — write to an outbox table in the same transaction None — the app doesn't know CDC exists
Captures changes made outside the app No Yes — migrations, batch jobs, manual SQL
Operational cost A table and a dispatcher Replication slots, WAL retention, a connector

The rule of thumb:

  • You own the service and the consumers care about business events → Outbox. A OrderPlaced event is a contract you can version. orders table row 4471 changed is not.
  • You don't control the writer, or changes arrive from outside your code → CDC. Legacy systems, third-party applications, and bulk loads never write to your outbox table.

They also combine well: use CDC to read the outbox table itself. The application writes a business event in the same transaction as the business data, and CDC ships it with no polling dispatcher. You get a designed contract and log-based delivery.

If you're new to either, start with the Outbox pattern in .NET and idempotent message handling — a CDC consumer still has to handle redelivery.

The exercise: PostgreSQL CDC on Aspire

Aspire is a good fit here because CDC needs a database configured a specific way before the application starts. Rather than a README saying "set wal_level to logical", the AppHost declares it.

1. Model the database in the AppHost

AddPostgres runs the official postgres container. Extra arguments are passed through to the postgres server binary, which is how you turn on logical decoding:

// AppHost.cs
var builder = DistributedApplication.CreateBuilder(args);

var postgres = builder.AddPostgres("postgres")
    // Logical decoding is off by default: the server ships with wal_level=replica.
    .WithArgs("-c", "wal_level=logical", "-c", "max_replication_slots=10")
    .WithInitBindMount("./postgres-init")
    .WithPgAdmin();

var inventoryDb = postgres.AddDatabase("inventorydb");

builder.AddProject<Projects.Inventory_Api>("api")
    .WithReference(inventoryDb)
    .WaitFor(inventoryDb);

builder.AddProject<Projects.Inventory_CdcWorker>("cdc-worker")
    .WithReference(inventoryDb)
    .WaitFor(inventoryDb);

builder.Build().Run();

Install the hosting integration with aspire add postgres (Aspire.Hosting.PostgreSQL).

wal_level has to be a server argument, not an init script. Init files run after the server is already up, so an ALTER SYSTEM SET wal_level = 'logical' in there needs a restart to take effect — and the worker will have already failed to connect by then.

2. Declare the table and the publication

Files in the init bind mount run once, after the data directory is created. This is where the publication belongs — it's schema, not application code:

-- ./postgres-init/01-inventory.sql
CREATE TABLE products (
    id          text PRIMARY KEY,
    name        text NOT NULL,
    stock       int  NOT NULL
);

-- Which tables are streamed.
CREATE PUBLICATION inventory_pub FOR TABLE products;

-- Without this, UPDATE and DELETE events carry only the primary key
-- and you cannot see the previous values.
ALTER TABLE products REPLICA IDENTITY FULL;

REPLICA IDENTITY is the second thing people get wrong. The default (DEFAULT) puts only the primary key in the old-row image, so an update event tells you which row changed but not what it changed from. FULL logs the entire previous row — at the cost of more WAL. Choose deliberately; don't discover it in production.

3. Consume the stream in .NET

Npgsql speaks the replication protocol directly, so a CDC consumer is a BackgroundService and one await foreach — no Debezium, no Kafka Connect, no JVM:

public sealed class InventoryCdcWorker(
    IConfiguration configuration,
    ILogger<InventoryCdcWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await using var connection = new LogicalReplicationConnection(
            configuration.GetConnectionString("inventorydb"));

        await connection.Open(stoppingToken);

        // The slot is created once (see below) and remembers our position across restarts.
        var slot = new PgOutputReplicationSlot("inventory_slot");
        var options = new PgOutputReplicationOptions("inventory_pub", 1);

        await foreach (var message in
            connection.StartReplication(slot, options, stoppingToken))
        {
            switch (message)
            {
                case InsertMessage insert:
                    logger.LogInformation("Row inserted at {Lsn}", insert.WalEnd);
                    break;

                case UpdateMessage update:
                    logger.LogInformation("Row updated at {Lsn}", update.WalEnd);
                    break;

                case DeleteMessage delete:
                    logger.LogInformation("Row deleted at {Lsn}", delete.WalEnd);
                    break;
            }

            // Acknowledge. Until you do, PostgreSQL keeps this WAL on disk.
            connection.SetReplicationStatus(message.WalEnd);
        }
    }
}

Two things that will cost you an evening if you skip them:

  1. Npgsql recycles message instances. The object you are holding is reused as soon as the next message is read. Project what you need out of the message inside the loop body; never queue the message itself.
  2. SetReplicationStatus is not optional. It's how the server learns which WAL it can recycle. Forget it and the slot's retained WAL grows forever — see the disk-fill warning above.

Create the slot once, before the worker first runs:

SELECT pg_create_logical_replication_slot('inventory_slot', 'pgoutput');

The replication user must be a superuser or carry the REPLICATION attribute. The Aspire-generated postgres user already is one, which is exactly why this is a local development exercise and not a production deployment template.

4. Watch it run

aspire run, then insert a row through the API and watch the worker log the event. Nothing in Inventory.Api publishes anything — it only writes to a table. The event stream is a property of the database, not of the application code. That is the whole point of CDC.

What CDC is not

Being precise here saves architecture arguments later:

  • Not a webhook delivery service. You get row events at the schema level, not business events at the domain level. Somebody still has to translate.
  • Not DDL replication. Logical decoding streams data changes. Schema migrations and sequence state are not replicated for you.
  • Not synchronous. Consumers lag. If a workflow needs a change to be visible in another system before the request returns, CDC is the wrong tool.
  • Not exactly-once. After a restart the consumer resumes from the last acknowledged LSN and may see a change twice. Handlers must be idempotent.

The same log-based idea exists in other engines with different switches — SQL Server has native CDC change tables read via LSNs, and Oracle exposes redo through LogMiner. The concepts transfer; the configuration, permissions and licensing do not.

Takeaway

CDC is not a messaging feature you add to your application. It is a property of your database that you choose to read. Use the Outbox pattern when you want to publish a contract you own; use CDC when changes arrive from writers you don't control — and then watch your replication slots like you watch disk space, because that's what they consume.

Official sources