Transactional messaging and idempotent consumers
How to update the database and publish a message as one step, and how consumers survive duplicates and reordering.
key idea / coordinatorservice / good pathin progress / watch outproblem / failureneutral
Sending a message with a database change
If you update the DB and then crash before sending, the system is inconsistent. The outbox pattern makes both writes one local transaction.
Delivery guarantees and the outbox
A broker also balances load. If an instance dies, Kafka reassigns its partitions to the others. You need no external load balancer, and no rate limiter either: a burst becomes consumer lag, not rejected requests. That lag is the signal to scale out (see lesson 22 on scaling). From Practical Event-Driven Microservices Architecture, ch. 7.
| Semantics | Behaviour | Fit |
|---|---|---|
| At-most-once | No retry. Never duplicated, may be lost (for example on deploy). | Telemetry where only fresh data matters. |
| At-least-once | Retry until acknowledged. May duplicate. Small performance cost. | The practical default, with idempotent consumers. |
| Exactly-once | Kafka uses sequence numbers and transactions across topics. Reported cost: about 3 percent against at-least-once. | Only inside the broker flow. A database or external call still needs its own coordination. |
Saving state and publishing an event are two separate operations. If you save the order and fail to publish, the stream no longer matches your database and every consumer inherits the problem. The outbox pattern fixes it.
Two alternatives. First, publish the event and build state by consuming it, so the stream is the only source of truth. This fits stateless processors, but a service that validates against its own state would read stale data. Second, without cross-table transactions, make writes idempotent upserts and acknowledge the command only after both writes succeed; a retry then fills the missing one. Wrapping both in a transaction or using compensating actions only narrows the gap.
Change data capture when you cannot edit the monolith
The book tells of an inventory migration where calling the new service from every flow looked perfect in test, then drifted in production. Stored procedures with hundreds of lines, data fixes by support teams and "mushroom applications" such as spreadsheets all changed the data. CDC captures changes at the database, so none of them can be missed. It also works for vendor monoliths. From Practical Event-Driven Microservices Architecture, ch. 2.
| Approach | Good | Watch out |
|---|---|---|
| Query with a watermark (timestamp, id or version) | Works on any database. Flexible filters and bulk load of existing data. | Heavy queries load the DB. Several updates collapse into one. Deletes need soft delete. |
| Read the database log | Every change including deletes, with before and after. Lighter on the DB. | Depends on the vendor. Exposes the table model, so adapt it to domain events. High volume needs retention rules. |
| Framework (Kafka Connect, NiFi) | Configuration instead of code. | Hard to debug locally, limited customization, leaks the DB model. |
| Custom ETL component | Events with domain meaning (a stock update may mean a reservation). | More code to write and run. One per bounded context for many teams. |
The ETL must also bootstrap the current state into the stream so a new service starts complete. Eventually the new service becomes the source of data and the ETL is retired. SQL Server also offers Change Tracking, which compares versions and is lighter when you need only the latest state.
Duplicates, retries and dead letters
Failures happen between a service and the broker. A lost acknowledgment makes the sender retry, and the broker then holds a duplicate.
| Delivery | Promise | Catch |
|---|---|---|
| At most once | No duplicates | Messages can be lost. Fine for telemetry |
| At least once | No loss | Duplicates. Most common choice |
| Exactly once | Neither | Very hard. Needs every component in the flow to take part |
So assume at-least-once and make handlers safe to repeat. Also acknowledge only after processing, or a crash loses the message.
Events can also arrive out of order, for example when two instances consume at different speeds. Give each event the entity version, and ignore an event older than the stored state when only the latest state matters.
From Practical Event-Driven Microservices Architecture (ch. 5 to 7).
Concurrency and out-of-order events
In a monolith you lock the product in memory. An event-driven service runs many instances, each with many threads, so a local lock protects nothing across instances. Unlikely races stop being unlikely at thousands of events per second. From Practical Event-Driven Microservices Architecture, ch. 6.
Three families of fixes exist. Pessimistic means ask permission first. Optimistic means apologize afterwards. By design means arranging events so the clash cannot happen (next section).
| Pessimistic | Optimistic | |
|---|---|---|
| How | Take a distributed lock (Consul, ZooKeeper, Redis) or a DB transaction, then work alone. | No lock. Check at write time that nothing changed. |
| Best when | Conflicts are likely, for example a hot product. | Conflicts are rare, for example a house for sale. |
| Risks | Static lock key makes the service single-threaded; use the product id. A crashed or partitioned instance can lose its lock yet still write. Long transactions hurt. | Under contention ten buyers mean one success and nine retries, costlier than a lock. Retries must be idempotent. |
Give events an aggregate version or a timestamp. Timestamps are easy but not sequential; truly sequential ids are hard to generate in a distributed system. Partial events such as StockIn and StockOut cannot skip versions and are not idempotent, which makes them fragile. Even with versions, two threads can still process 21 and 22 together, so combine with a concurrency strategy.
Partitioning: concurrency solved by design
The better answer is to make the clash impossible. A Kafka topic is a distributed append log split into partitions. Order is guaranteed only inside one partition, and each partition has exactly one consumer per consumer group. Consumers are cheap: a consumer is just an offset pointer. From Practical Event-Driven Microservices Architecture, ch. 6.
Use the product id as the routing key. Every order for one product then lands in the same partition, is read by the same instance and in order. The distributed race becomes a local one. Inside the instance, route messages to threads with the same key so the same product is never handled by two threads. The service is parallel across keys and sequential within one.
| Limit | What to do |
|---|---|
| Hotspots: a key with most events (category instead of product) overloads one partition | Choose a key with enough granularity and diversity, several times the number of partitions. |
| Momentary hotspots: a hot product launch | Accept rare spikes. Avoid recovery jobs that dump many events with one key. |
| Number of partitions caps scaling; adding partitions does not re-route old data | Create topics with more partitions than needed today. |
| A service that mixes events and a synchronous API | Turn the request into a command on the queue, or fall back to locking. |
Recap
- Use shard keys for ordering, idempotent handlers for duplicates, and an outbox for reliable publishing.
- CDC captures changes you cannot publish from code. Log-based CDC sees every change; query-based is simpler but misses intermediate updates and deletes.
- Solve concurrency by design with a routing key that maps a product to one partition, one instance and one thread. Otherwise use optimistic or pessimistic control.
- Version events to detect out-of-order delivery. Design consumers for at-least-once delivery with idempotent handling.
- Assume duplicates: ack after processing, make handlers idempotent, retry with backoff, then dead-letter.
Quick check
Show answer
The transactional outbox. Save the entity and an outbox row in one transaction, then publish the row later through a relay or CDC.
Show answer
The messages in memory are lost (message leak). Acknowledge after processing so unfinished work is redelivered, and make handling idempotent for the duplicates.
Show answer
Make the handler idempotent: store the processed event id or version and skip repeats, or publish a StockChanged event that carries the current quantity.
Show answer
The distributed race is gone because one instance owns that partition. Threads inside the instance can still race, so route by key to threads or use a local lock.