Listen to Yourself
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
The Transactional Outbox Pattern closes the dual-write
gap by writing the event next to the aggregate in the same local transaction and relaying it afterward. This page
covers a structurally different answer to the same dual-write problem, one that removes the outbox table and the
relay entirely by inverting the order of operations: publish first, apply second. It is not covered by any of
this section’s four books — it comes from its own original write-up (Oded Shopen, 2017) and Confluent’s course
material on event-driven microservices, both linked in References below — and is shown here with the same Java
21 / Spring Boot 4.1.x / Spring Data JPA / Spring Modulith 2.x / Spring for Apache Kafka 4.1.x baseline as the
rest of this section, reusing the exact Order, OrderPlaced, DomainEventEnvelope, and PlaceOrderCommand
shapes Structuring the Code and
Domain Events already established.
The pattern, stated precisely
Every version of PlaceOrderService shown earlier in this section does the same two things in the same order:
save Order to its own database first, then publish OrderPlaced for everyone else. Listen to yourself
reverses that order for the publishing service’s own database write. Instead of PlaceOrderService calling
orderRepository.save(order) directly, it publishes OrderPlaced to the same topic every other service reads — and the Order context’s own database is updated only when that same event comes back around through a
@KafkaListener running inside the Order service itself. There is exactly one code path that reacts to "an
order was placed": the listener. Whether the reader is Inventory, Payment, or the Order service’s own database,
every one of them learns about the placement the same way, through the same message, with no special case for
"the context that happens to own this aggregate."
This is a genuinely different shape from every other pattern in this section, not a variation on the outbox: the outbox writes the aggregate and the event together, then relays the event afterward; listen-to-yourself sends the event first and derives the aggregate’s persisted state from it afterward, deliberately treating its own database as just one more subscriber to its own fact.
What it costs
Publishing before applying buys the simplicity of one code path, and it is not free — four costs come with it, each one a direct consequence of the database write no longer happening synchronously inside the request:
-
No read-your-writes. An HTTP handler that publishes
OrderPlacedand returns cannot queryOrderand get back the row it just "placed," because that row does not exist yet — it is created only once the self-consumer processes the event, asynchronously, at some later moment. The handler’s response can say only "accepted," never "here is the order as it now stands," which is why the code below returns202 Acceptedrather than201 Createdwith the resource in the body. -
A lag window. Between the event being published and the self-consumer applying it, the Order service’s own database has no row for an order a client was just told was accepted. A client polling
GET /orders/{id}immediately after the202can see a 404 for a real, if momentarily unmaterialized, order — a caller has to either tolerate that window or poll until the row appears, exactly the "eventual" half of eventual consistency made visible at the API boundary instead of hidden inside cross-service propagation. -
Ordering and idempotency on the self-consumer. The self-consumer is a
@KafkaListenerlike any other, so every ordering and at-least-once-delivery requirement Domain Events already states for consumption in general applies here with no exception for being "the same service that published it" — two events about the sameorderIdmust still be applied in the order they were published, and a redeliveredOrderPlacedmust still be a safe no-op, not a duplicate row. -
The event must be complete enough to reconstruct the write, and validation must happen before publishing. Because the self-consumer’s only input is the event itself,
OrderPlacedhas to carry everything needed to materialize theOrderrow — there is no synchronous fallback to "just read the command that caused this," since the command object is long gone by the time the listener runs. This has a sharper consequence than it first looks: onceOrderPlacedis published, it cannot be rejected — exactly the "events cannot be rejected" property Domain Events already states — so every invariantOrder.place(command)would normally enforce (the minimum-order-amount check, valid line items) must be checked before the publish call, in the command handler, never inside the self-consumer. A self-consumer that discovers invalid data has no way to say "actually, don’t place this order" anymore; the fact is already out.
Side by side with the outbox, and with event sourcing
The table below places listen-to-yourself against the two patterns it is easiest to confuse it with.
Transactional outbox |
Listen to yourself |
|
Local write vs. publish order |
Aggregate and outbox row written together, in one local transaction; publish happens afterward, relayed independently. |
Event published first; the local write happens afterward, driven by consuming that same event. |
Extra infrastructure |
An |
None — no outbox table, no relay process; the broker’s own topic is the only extra moving part, and it is already there for every other consumer. |
Synchronous confirmation |
The request thread still writes the aggregate synchronously and can return the saved state ( |
None — the request thread only publishes and returns; the local write is asynchronous by construction, so the response can say only "accepted." |
Listen-to-yourself is also easy to mistake for event sourcing
(Event Sourcing), since both make an event the thing a
service’s own state is derived from — but the resemblance stops at that one sentence. Event sourcing makes the
event log the aggregate’s durable source of truth: Order’s current state is always `fold(events) over its
entire history, replayed from the event store on every load, and the event store is never pruned because it is
the data. Listen-to-yourself’s event is transient: once the self-consumer has applied OrderPlaced to the
Order row, the event has done its job and the row — not the topic — is what every later read, update, and
query against Order uses. Losing the topic’s retained history after that point loses an audit trail, not the
aggregate’s actual state; losing an event-sourced aggregate’s event store loses the aggregate.
When it fits, and when it does not
Listen-to-yourself trades a synchronous, consistent write for throughput and a simpler consumption model, which makes it a good fit specifically where that trade is favorable:
-
High write throughput. Publishing to a topic and returning is cheaper per request than a synchronous database write plus a separate publish step, which matters once request volume is high enough that the database write itself is the bottleneck.
-
Naturally asynchronous commands. A command whose caller was never going to get a synchronous answer anyway — a webhook ingestion endpoint, a batch import, a "submit and I’ll email you" workflow — loses nothing by making the whole path asynchronous, since the caller was not relying on read-your-writes to begin with.
-
Replay/rebuild scenarios. Because the local database is always derived from the topic, rebuilding it from scratch (a schema migration, recovering from data corruption, standing up a new instance) is a matter of replaying the topic from the retained history and letting the same self-consumer logic rebuild the table — the same "derive state from a log" convenience CQRS's read models already get from being rebuilt off events.
It does not fit where the costs above are not acceptable trade-offs:
-
Strict invariants that need the current state. `Order.removeLine’s minimum-order-amount check (Aggregates) needs the current, authoritative state of the order to decide correctly — and under listen-to-yourself, "current state" is only ever as fresh as the self-consumer’s own lag, which is exactly the wrong foundation for an invariant that must never be violated, even briefly.
-
Synchronous confirmation required. Any use case whose caller genuinely needs "did this succeed, and here is the result" in the same response — a checkout flow that must show the confirmed order number immediately, not "check back later" — is a poor fit, because listen-to-yourself structurally cannot answer that without the caller polling afterward.
The DDD grounding: Vernon’s rule 4
Aggregates already states the general principle this pattern is one deliberate instance of: consistency between aggregates (and, at the microservices scale, between bounded contexts) can never be transactional, only eventual, and domain events are the mechanism that carries it. Vernon’s DDD Destilado ch. 5’s fourth rule for aggregate design makes explicit what most services treat as implicit: a bounded context may subscribe to the domain events it publishes itself, using the exact same subscription mechanism any other bounded context would use, rather than reserving a privileged, synchronous "internal" path for its own reaction. Every other pattern in this section still lets the publishing context update its own database directly, in-process, and publish only as a courtesy to everyone else; listen-to-yourself is what it looks like to take Vernon’s rule 4 all the way — the publishing context is, deliberately, nothing special to itself. `Domain Events’s closing section already gestures at this same idea in the ordinary case ("a publishing bounded context is also, quite often, one of its own event’s subscribers"); this page is that idea made the only path, not an additional one alongside a synchronous save.
Code: publish first, self-consume second
PlaceOrderController validates and publishes, then returns immediately — no OrderRepository in sight, and no
synchronous save anywhere on this path:
package com.example.order.api;
import com.example.order.application.PlaceOrderCommand;
import com.example.order.domain.Order;
import com.example.order.domain.OrderId;
import com.example.order.domain.OrderLine;
import com.example.order.domain.event.OrderPlaced;
import com.example.shared.Money;
import org.springframework.http.ResponseEntity;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
@RestController
class PlaceOrderController {
private final KafkaTemplate<String, OrderPlaced> kafkaTemplate;
PlaceOrderController(KafkaTemplate<String, OrderPlaced> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
@PostMapping("/orders")
ResponseEntity<Void> placeOrder(@RequestBody PlaceOrderRequest request) {
PlaceOrderCommand command = request.toCommand();
OrderId orderId = OrderId.generate();
// Every check Order.place(command) would normally perform has to happen here, before
// publishing: a published event can no longer be rejected downstream. Order exposes its
// creation-time invariants as a static method for exactly this reason -- one check, no copy.
Order.requireValidLines(command.lines());
Money totalAmount = command.lines().stream()
.map(OrderLine::lineTotal)
.reduce(Money::add)
.orElseThrow();
OrderPlaced event = new OrderPlaced(orderId, command.customerId(), command.lines(),
totalAmount, Instant.now());
// The published event is the only durable record of this order, so wait for the broker's
// acknowledgement before telling the client anything: join() throws if the send fails, and
// the resulting 5xx is honest -- nothing was accepted.
kafkaTemplate.send("order-events", orderId.value().toString(), event).join();
// No OrderRepository.save(...) here -- the local row is created only once
// OrderPlacedSelfConsumer below applies this same event.
return ResponseEntity.accepted().build();
}
}
Derived from Spring for Apache Kafka’s KafkaTemplate API, whose send(…) returns the CompletableFuture joined above — see Spring for Apache Kafka reference — Sending messages (KafkaTemplate).
OrderPlacedSelfConsumer is the only code path that writes an Order row — running inside the same Order
service, subscribed to the same topic every other context reads, and idempotent under redelivery exactly like any
other outbox-relayed consumer in this section:
package com.example.order.api;
import com.example.order.domain.Order;
import com.example.order.domain.OrderRepository;
import com.example.order.domain.OrderStatus;
import com.example.order.domain.event.OrderPlaced;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
class OrderPlacedSelfConsumer {
private final ProcessedEventRepository processedEvents; // same dedup table as Domain Events
private final OrderRepository orderRepository;
OrderPlacedSelfConsumer(ProcessedEventRepository processedEvents, OrderRepository orderRepository) {
this.processedEvents = processedEvents;
this.orderRepository = orderRepository;
}
@KafkaListener(topics = "order-events")
@Transactional
public void on(OrderPlaced event) {
String eventId = event.orderId().value().toString(); // OrderPlaced is published once per order here
if (processedEvents.existsById(eventId)) {
return; // redelivered -- already materialized, safe no-op
}
orderRepository.save(Order.reconstitute(event.orderId(), event.customerId(), event.lines(),
OrderStatus.APPROVAL_PENDING, 0L));
processedEvents.save(new ProcessedEvent(eventId, event.orderId().value().toString()));
}
}
Derived from Spring for Apache Kafka’s listener model — see Spring for Apache Kafka reference — @KafkaListener annotation; the @Transactional boundary follows Spring Framework reference — Declarative transaction management.
The same flow re-described in Spring Modulith terms: @ApplicationModuleListener replaces the plain
@KafkaListener for the in-process case, while still being the same "publish first, react second" shape — Modulith’s own event publication registry
(Transactional Outbox's framework-provided outbox)
tracks whether this listener has completed, and republishes to it if it has not, without any change to the method
body itself:
package com.example.order.api;
import com.example.order.domain.Order;
import com.example.order.domain.OrderRepository;
import com.example.order.domain.OrderStatus;
import com.example.order.domain.event.OrderPlaced;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
@Component
class OrderPlacedSelfListener {
private final ProcessedEventRepository processedEvents;
private final OrderRepository orderRepository;
OrderPlacedSelfListener(ProcessedEventRepository processedEvents, OrderRepository orderRepository) {
this.processedEvents = processedEvents;
this.orderRepository = orderRepository;
}
@ApplicationModuleListener
void on(OrderPlaced event) {
String eventId = event.orderId().value().toString();
if (processedEvents.existsById(eventId)) {
return; // already materialized -- Modulith's registry may redeliver on resume
}
orderRepository.save(Order.reconstitute(event.orderId(), event.customerId(), event.lines(),
OrderStatus.APPROVAL_PENDING, 0L));
processedEvents.save(new ProcessedEvent(eventId, event.orderId().value().toString()));
}
}
ProcessedEvent/ProcessedEventRepository are unchanged from
Domain Events — reused here, not redefined, since the dedup
requirement is identical whether the redelivered message came from an outbox relay or from a self-published
topic. Both listeners are, on purpose, the same four lines of actual logic — the only thing that changes between
a plain Kafka self-consumer and a Spring Modulith one is which annotation, and which delivery/retry machinery,
sits underneath it.
Mermaid: the outbox flow and listen-to-yourself, side by side
The two sequences below reuse Transactional Outbox's own flow on the left, and place listen-to-yourself’s inverted order on the right, so the structural difference — where the local database write happens relative to the publish call — is visible in one glance.
Reading the two side by side makes the trade-off concrete: the outbox flow’s client gets a 201 with the actual
order back, at the cost of running a relay; the listen-to-yourself flow’s client gets only a 202 and must poll
or wait, but there is no outbox table and no relay process anywhere in the diagram.
References
-
Oded Shopen — Listen to Yourself: A Design Pattern for Event-Driven Microservices
-
Confluent Developer — Designing Event-Driven Microservices: The Listen to Yourself Pattern
-
Spring for Apache Kafka reference — Sending messages (KafkaTemplate)
-
Spring for Apache Kafka reference — @KafkaListener annotation
-
Spring Framework reference — Declarative transaction management