← Back to list

Stop Losing Events: The Transactional Outbox Pattern in .NET Microservices

Every event-driven microservice starts with the same 4 lines:

Aishwarya K R · 2026-06-28 09:09 · 2 claps · 4.7 min read
#outbox-pattern #transactional #saga-pattern #kafka #microservices
Open on Medium ↗

Stop Losing Events: The Transactional Outbox Pattern in .NET Microservices

Every event-driven microservice starts with the same 4 lines:

It feels safe. It passes code review.

It’s also wrong.

Not in a subtle way. “Your billing service will silently miss patients on the day Kafka has a hiccup, and you’ll only find out three weeks later from an angry CFO” wrong.

Why This Matters

One user action triggers a chain reaction:

  • Patient created in PatientService DB
  • PatientCreated event published to Kafka
  • Billing service → creates account
  • AI service → updates RAG embeddings

Now imagine that Kafka publish silently fails.

  • The patient exists. The billing account doesn’t. The RAG index is stale. The audit log is missing an entry.
  • Your database and your event stream now disagree about reality. Every downstream consumer is computing on lies.
  • No alert. No automated recovery. Only divergence — discovered weeks later when someone notices the totals don’t match.

This is the dual-write problem, and it’s the most expensive bug in microservices.

The Bug in 4 Lines

Look at those 4 lines again. There are three failure modes hiding in them:

The database transaction does nothing to protect Kafka. The two systems have no shared notion of “commit.” You cannot atomically write to both.

This is not solvable by adding try/catch. It is structurally unfixable from inside the same transaction.

The Pattern: Outbox Table + Publisher

Stop trying to write to two systems at once.

The guarantee becomes:

  • The event will be published if and only if the entity was committed.

If Kafka is down, the row stays in the outbox. The publisher retries. When Kafka returns — automatically — the event flows.

The database transaction is the source of truth. The publisher is the bridge.

The Outbox Row

The schema is intentionally boring:

Plus one composite index that makes the publisher’s “find unpublished, oldest first” query cheap forever:

No frameworks. No magic. A table and a flag.

Outbox holding events while Kafka is down After stopping Kafka and creating 5 patients via the API: 5 rows in OutboxMessages with IsPublished=false. The DB transactions committed. The events are safe.

Outbox holding events while Kafka is down After stopping Kafka and creating 5 patients via the API: 5 rows in OutboxMessages with IsPublished=false. The DB transactions committed. The events are safe.

The Atomic Write

The entire reliability guarantee lives in one SaveChangesAsync call:

EF Core wraps multiple Add calls in a single database transaction by default. Patient row and outbox row commit together, or roll back together. That’s it. That’s the magic.

The Publisher

Equally unremarkable:

A BackgroundService runs this loop every 10 seconds. That’s the entire reliability primitive.

Outbox drained after Kafka returns After restarting Kafka and waiting 10 seconds: same 5 rows, now IsPublished=true with PublishedAt timestamps. No manual intervention. No lost events. This is the recovery the pattern promises.

Outbox drained after Kafka returns After restarting Kafka and waiting 10 seconds: same 5 rows, now IsPublished=true with PublishedAt timestamps. No manual intervention. No lost events. This is the recovery the pattern promises.

The Saga Twist Most Tutorials Skip

There’s a subtle bug I shipped in my first iteration — and almost every outbox tutorial gets it wrong.

  • The naive flow looks fine: build the event payload, save entity + outbox row together in one SaveChanges.
  • The problem: the entity’s auto-generated ID is zero at the moment you build the payload. EF hasn’t called the database yet. Every event you publish carries PatientId = 0.
  • Downstream consumers receive PatientId = 0 for every create. The RAG index breaks silently. Billing accounts collide. The events publish successfully, consumers process happily, the data is just wrong.

This is saga-inspired rollback behavior within the Patient service boundary: if the gRPC call to Billing fails, everything rolls back — including the patient. No half-created state. No orphan events.

One unit of work, three side effects, atomic.

What Actually Matters

1. The Outbox Is Not Optional

  • There is no “lightweight” version of event-driven microservices that skips the outbox. Either you have it, or you have silent corruption waiting for the day Kafka has a partition.
  • Treat it like a foreign key — invisible 99% of the time, but the system collapses without it.

2. The DB Transaction Is Your Only Atomic Primitive

Anything that crosses a process boundary is not atomic with your DB write. The outbox cheats — it turns “publish to Kafka” into “insert a row” so it inherits the database’s atomicity.

3. Polling Sounds Dumb. It Isn’t.

  • Polling every 10 seconds feels primitive. It isn’t.
  • It’s simple (one query, one update), resilient (publisher crash = next poll picks up where it left off), observable (every event has a row, a retry count, an error message), and idempotent.
  • For 10s freshness, polling beats LISTEN/NOTIFY, CDC, and Debezium. Save those for when you actually need sub-second event latency.

Loki view of the patient-service: the OutboxPublisher background worker logs every poll cycle and every successful publish. The spike at 21:36 is the visual signature of recovery — five trapped events drained across two poll cycles, fully automatic, no human in the loop.

Loki view of the patient-service: the OutboxPublisher background worker logs every poll cycle and every successful publish. The spike at 21:36 is the visual signature of recovery — five trapped events drained across two poll cycles, fully automatic, no human in the loop.

4. The Event Envelope Is Forever

Every row in the outbox is a contract with every consumer that will ever exist. At minimum:

The EventId is the most important field — it’s what lets consumers deduplicate when the same event is delivered twice (which will happen with at-least-once delivery).

5. Don’t Forget The Cleanup Worker

Published rows pile up forever unless you delete them. A second background service that runs hourly:

Not glamorous. Absolutely required. Outbox tables that grow unbounded are how your “reliable event system” becomes “the table that ate the database.”

Key Takeaways

  • Dual writes are structurally unfixable. Any code that writes to a DB and publishes to Kafka in the same operation has a silent corruption bug.
  • The outbox makes “publish to Kafka” atomic with your DB write by turning it into a regular INSERT.
  • A background polling worker is enough. 10-second freshness is fine for 99% of systems.
  • Auto-generated IDs are a trap. Save the entity first, then build the outbox payload with the populated ID.
  • The event envelope is a forever-contract. Get the fields right on day one.

메타데이터
post_id
9d7e3e809e4a
slug
stop-losing-events-the-transactional-outbox-pattern-in-net-microservices-9d7e3e809e4a
url
https://medium.com/@akr28921/stop-losing-events-the-transactional-outbox-pattern-in-net-microservices-9d7e3e809e4a
canonical_url
https://medium.com/@akr28921/stop-losing-events-the-transactional-outbox-pattern-in-net-microservices-9d7e3e809e4a
author_url
https://medium.com/@akr28921
status
ok
fetched_at
2026-07-09 15:12:33