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 OrderPlaced and returns cannot query Order and 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 returns 202 Accepted rather than 201 Created with 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 the 202 can 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 @KafkaListener like 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 same orderId must still be applied in the order they were published, and a redelivered OrderPlaced must 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, OrderPlaced has to carry everything needed to materialize the Order row — 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: once OrderPlaced is published, it cannot be rejected — exactly the "events cannot be rejected" property Domain Events already states — so every invariant Order.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 outbox table plus a relay (a polling job or a CDC connector).

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 (201 Created with a body) before the event ever reaches the broker.

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.

sequenceDiagram participant Client participant Ctrl as OrderController (outbox) participant DB as Order database participant Relay as OutboxPollingRelay participant Broker as Kafka Client->>Ctrl: POST slash orders Ctrl->>DB: insert Order row and outbox row, one transaction DB-->>Ctrl: committed Ctrl-->>Client: 201 Created, with the order Relay->>DB: read unpublished outbox row Relay->>Broker: publish OrderPlaced
sequenceDiagram participant Client participant Ctrl as PlaceOrderController (listen to yourself) participant Broker as Kafka participant Self as OrderPlacedSelfConsumer participant DB as Order database Client->>Ctrl: POST slash orders Ctrl->>Ctrl: validate command Ctrl->>Broker: publish OrderPlaced Ctrl-->>Client: 202 Accepted, no order body yet Broker->>Self: deliver OrderPlaced Self->>DB: insert Order row if not already present

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.

Figure: outbox vs. listen-to-yourself vs. a plain synchronous call

Decision table comparing three ways to make a service’s write and its published event consistent: a plain synchronous call with an inline broker publish inside the request