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
psqlprompt. - 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
OrderPlacedevent is a contract you can version.orderstable 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:
- 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.
SetReplicationStatusis 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
- PostgreSQL: Logical Decoding
- PostgreSQL: Logical Replication Quick Setup
- Npgsql: Logical and Physical Replication
- .NET Aspire documentation
- Aspire PostgreSQL hosting integration
Related in this series
- The Outbox Pattern in .NET — publishing an event you designed, in the same transaction as the data
- Idempotency and message deduplication — why every CDC consumer needs it
- NATS Message Broker in .NET — where the events go next
- Events in a .NET monolith — before you reach for a broker at all
