<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ramesh Yara</title>
    <description>The latest articles on DEV Community by Ramesh Yara (@ramesh-yara).</description>
    <link>https://dev.to/ramesh-yara</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4070124%2F1e527bdf-d0ce-43f6-a7b4-2d49f8e297d2.jpg</url>
      <title>DEV Community: Ramesh Yara</title>
      <link>https://dev.to/ramesh-yara</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ramesh-yara"/>
    <language>en</language>
    <item>
      <title>The Outbox Pattern Is Not Enough</title>
      <dc:creator>Ramesh Yara</dc:creator>
      <pubDate>Mon, 17 Aug 2026 21:04:50 +0000</pubDate>
      <link>https://dev.to/ramesh-yara/the-outbox-pattern-is-not-enough-28g1</link>
      <guid>https://dev.to/ramesh-yara/the-outbox-pattern-is-not-enough-28g1</guid>
      <description>&lt;p&gt;The textbook version of the transactional outbox is tight. You save the domain entity and an outbox row in one local transaction. A background scheduler picks up &lt;code&gt;PENDING&lt;/code&gt; rows and publishes them to Kafka. You never publish inside the request thread — no dual-write, no atomicity breach. The pattern closes the consistency gap.&lt;/p&gt;

&lt;p&gt;Then you load-test it.&lt;/p&gt;

&lt;p&gt;I ran 1,000 authenticated requests through my event-driven platform in 70 seconds. The gateway returned &lt;code&gt;201&lt;/code&gt; for every one of them. The outbox absorbed every row. The consumer drained everything. By every visible metric the system looked healthy. Underneath that health, I found three production-grade problems the textbook never mentioned.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a correct implementation looks like
&lt;/h2&gt;

&lt;p&gt;Before the problems, the shape of the solution. The outbox publisher runs on a &lt;code&gt;@Scheduled&lt;/code&gt; virtual-thread worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Scheduled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixedDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;publishPendingEvents&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OutboxEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;outboxRepository&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findTop20ByStatusOrderByCreatedAtAsc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OutboxStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OutboxEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OutboxStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PROCESSING&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;outboxRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;kafkaTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTopic&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPayload&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OutboxStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PUBLISHED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;incrementRetryCount&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRetryCount&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="no"&gt;MAX_RETRIES&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OutboxStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OutboxStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;outboxRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is correct. The &lt;code&gt;PROCESSING&lt;/code&gt; state prevents another scheduler instance from claiming the same row. The retry cap prevents infinite cycling. The &lt;code&gt;PENDING&lt;/code&gt; fallback on transient errors gives the event another chance. The dual-write problem is genuinely closed.&lt;/p&gt;

&lt;p&gt;Here is what that correctness does not cover.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gap 1: Your throughput ceiling is a config line
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;fixedDelay = 5000&lt;/code&gt; means the scheduler runs every 5 seconds. &lt;code&gt;findTop20&lt;/code&gt; means it picks up 20 rows per cycle.&lt;/p&gt;

&lt;p&gt;Maximum publish throughput: &lt;strong&gt;20 events ÷ 5 seconds = 4 events per second.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That number does not appear in your unit tests. It does not appear in your monitoring unless you specifically look for it. It is a ceiling determined by two config values chosen without measurement.&lt;/p&gt;

&lt;p&gt;During the 1,000-event baseline run, the gateway processed ~14.3 requests per second. The publisher was draining at 4 per second. The backlog grew to 720 rows before the burst ended and the scheduler caught up. &lt;code&gt;outbox_oldest_pending_age_seconds&lt;/code&gt; — the gauge that measures the age of the oldest &lt;code&gt;PENDING&lt;/code&gt; row — peaked at &lt;strong&gt;191 seconds&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[outbox burst profile]
t=0s    → 1,000 requests fire at 14.3 req/s
t=70s   → all 1,000 return HTTP 201; 720 rows queued
t=70s+  → publisher drains at 4 ev/s (~3 minutes to clear)
         outbox_oldest_pending_age_seconds peak: 191s
t=250s  → backlog reaches 0; gauge returns to 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;&lt;code&gt;outbox_oldest_pending_age_seconds&lt;/code&gt; — a 200-request parallel burst. Age climbs to 110s as inbound rate outpaces the publisher, then drops to zero. The 07-17 baseline (1,000 requests at 14.3 req/s) peaked at 191s and took 3 minutes to drain.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The system worked correctly. No events were lost. No data was corrupted. But the freshness SLO — "events delivered within 30 seconds of creation" — was structurally impossible to meet at any input rate above 4 req/s. The ceiling isn't a bug. It's a design constant hiding in plain sight.&lt;/p&gt;

&lt;p&gt;The honest version of a freshness SLO for this system is: &lt;em&gt;"Events are delivered within 30 seconds, given input rates below 4 req/s."&lt;/em&gt; That constraint belongs in your SLO catalog, not buried in two config lines.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# SLO catalog entry — what honest capacity accounting looks like&lt;/span&gt;
&lt;span class="na"&gt;freshness_slo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;99.9% of events published within 30s of creation&lt;/span&gt;
  &lt;span class="na"&gt;constraint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;input rate ≤ 4 ev/s (fixedDelay=5000ms × batchSize=20)&lt;/span&gt;
  &lt;span class="na"&gt;at_higher_rates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;freshness degrades proportionally; availability unaffected&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Gap 2: The alert you write will fire for the wrong reason
&lt;/h2&gt;

&lt;p&gt;The natural monitoring instinct for the outbox is an age threshold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OutboxBacklogAgeHigh&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;outbox_oldest_pending_age_seconds &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;60&lt;/span&gt;
  &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;page&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During the 720-row burst, &lt;code&gt;outbox_oldest_pending_age_seconds&lt;/code&gt; crossed 191 seconds. The threshold is 60 seconds. Two rules went &lt;code&gt;pending&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Neither fired.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;for: 5m&lt;/code&gt; clause — which distinguishes a transient burst from a sustained incident — held. The burst resolved in under 5 minutes. Both rules sat pending through the whole event and silently cleared when the backlog drained.&lt;/p&gt;

&lt;p&gt;That is the correct behavior. But it is only correct if you understand why the window exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The naive response to a pending alert is to shorten the window.&lt;/strong&gt; Drop &lt;code&gt;for:&lt;/code&gt; from 5 minutes to 30 seconds to "catch problems faster." What you actually get is pages for every deployment spike, every cold-start burst, every Schema Registry restart. The alert stops being a signal and becomes noise that engineers learn to dismiss — which is worse than no alert at all.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;for:&lt;/code&gt; duration is where you encode your operational definition of incident. The 720-row burst at 191 seconds is a &lt;em&gt;load event&lt;/em&gt;: the system is processing work faster than it can publish, and it will self-resolve when the input rate drops. A broker offline for 7 minutes is an &lt;em&gt;incident&lt;/em&gt;: the backlog grows indefinitely and the oldest-age gauge only increases.&lt;/p&gt;

&lt;p&gt;You cannot calibrate that boundary by intuition. You need to run a burst that is definitively not an incident — a known-finite load, system otherwise healthy — and measure the oldest-age peak. Then set &lt;code&gt;for:&lt;/code&gt; so that peak does not fire. In this system: 191s peak, 5-minute &lt;code&gt;for:&lt;/code&gt; window, zero false positives across every deployment and cold-start since.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gap 3: The terminal state is invisible by design
&lt;/h2&gt;

&lt;p&gt;This is the one that costs you.&lt;/p&gt;

&lt;p&gt;When the publisher exhausts its retry budget, the row becomes &lt;code&gt;FAILED&lt;/code&gt; and the scheduler never touches it again. Your &lt;code&gt;outbox_oldest_pending_age_seconds&lt;/code&gt; goes back to zero — there are no more &lt;code&gt;PENDING&lt;/code&gt; rows to report age for. Your backlog count goes to zero. Your age alert stays silent.&lt;/p&gt;

&lt;p&gt;The event is gone. No notification was sent. No audit trail was written downstream. Nothing alerted.&lt;/p&gt;

&lt;p&gt;This happened on my platform. During the cluster's first night, Schema Registry took approximately four minutes to become ready after a pod restart. The outbox publisher started immediately and encountered &lt;code&gt;Error registering Avro schema&lt;/code&gt; for every publish attempt. After five retries, five rows were marked &lt;code&gt;FAILED&lt;/code&gt; permanently.&lt;/p&gt;

&lt;p&gt;The Grafana dashboard showed a healthy system. The Prometheus alerts list showed no active rules. The &lt;code&gt;outbox_failed&lt;/code&gt; gauge was plotted on the business dashboard and showing 5. No one had wired a rule to it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Left: MySQL terminal FAILED rows. Right: Grafana outbox panel — &lt;code&gt;failed=5&lt;/code&gt;, no active alert. The "looks healthy" beat.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Those rows sat there for 24 hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gauge without an alert is a decoration.&lt;/strong&gt; The &lt;code&gt;outbox_failed&lt;/code&gt; metric existed. It had a panel. It had a y-axis label. It had no operational consequence. The fix wasn't adding a new gauge — it was adding the rule that should have been there from the start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OutboxPublishTerminalFailure&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;outbox_failed &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
  &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2m&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;page&lt;/span&gt;
    &lt;span class="na"&gt;slo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;outbox_integrity&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runbook&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://docs.internal/runbooks/outbox-failed&lt;/span&gt;
    &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Terminal&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;FAILED&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;outbox&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;rows&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;detected&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;—&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;events&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;permanently&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;blocked"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;for: 2m&lt;/code&gt; gives the scheduler one extra cycle to confirm before paging. The severity is &lt;code&gt;page&lt;/code&gt; because &lt;code&gt;FAILED&lt;/code&gt; is a permanent state — unlike &lt;code&gt;PENDING&lt;/code&gt;, it never self-heals. The runbook names the re-drive query: update rows to &lt;code&gt;PENDING&lt;/code&gt;, identify the root cause (Schema Registry, broker auth, schema incompatibility), resolve it, let the publisher retry.&lt;/p&gt;

&lt;p&gt;Live-fire verified against a real condition: the FAILED rows described above triggered &lt;code&gt;OutboxPublishTerminalFailure&lt;/code&gt; after 2 minutes. Alertmanager delivered &lt;code&gt;[active] OutboxPublishTerminalFailure | severity=page | slo=outbox_integrity&lt;/code&gt;. Rows deleted. Alert auto-resolved in 30 seconds.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Alertmanager: &lt;code&gt;OutboxPublishTerminalFailure&lt;/code&gt; ACTIVE — severity=page, slo=outbox_integrity, namespace=microservices. The pager delivery moment.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The operational layer, assembled
&lt;/h2&gt;

&lt;p&gt;Three concrete additions on top of a correct outbox implementation:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Name the throughput ceiling in your SLO catalog.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;fixedDelay&lt;/code&gt; and &lt;code&gt;batchSize&lt;/code&gt; are not internal implementation details. They are your freshness SLO's capacity constraint. Make them visible, version them, and review them when input load changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Calibrate the age alert against measured burst data.&lt;/strong&gt;&lt;br&gt;
Run a known-safe burst. Record the oldest-age peak. Set &lt;code&gt;for:&lt;/code&gt; so the burst doesn't page. Re-run the calibration whenever you change the batch size or schedule interval.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Alert unconditionally on FAILED, with severity page.&lt;/strong&gt;&lt;br&gt;
The terminal state has exactly the property that makes it most dangerous: it looks like a healthy system. Wire the rule, write the runbook, and treat every FAILED row as a lost event until the runbook says otherwise.&lt;/p&gt;




&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Everything above came from a 70-second load run on a JWT-authenticated, fully containerized platform:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Requests&lt;/td&gt;
&lt;td&gt;1,000 POST &lt;code&gt;/api/v1/users&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP 201&lt;/td&gt;
&lt;td&gt;1,000 (100%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Availability SLI&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency SLI&lt;/td&gt;
&lt;td&gt;0.999&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99 gateway / user-service&lt;/td&gt;
&lt;td&gt;186ms / 177ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peak backlog&lt;/td&gt;
&lt;td&gt;720 rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oldest-age peak&lt;/td&gt;
&lt;td&gt;191 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publisher rate&lt;/td&gt;
&lt;td&gt;~4 ev/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consumer drain rate&lt;/td&gt;
&lt;td&gt;~150 ev/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;False-positive alerts during burst&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Terminal failure was live-fired separately: synthetic &lt;code&gt;FAILED&lt;/code&gt; row inserted directly into the outbox table → &lt;code&gt;OutboxPublishTerminalFailure&lt;/code&gt; pending at &lt;code&gt;t=0&lt;/code&gt;, FIRING at &lt;code&gt;t=2m&lt;/code&gt; → Alertmanager severity=page delivered → row deleted → alert auto-resolved in 30 seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The outbox closes the dual-write problem; it opens an operational one.&lt;/strong&gt; "Works correctly" and "is observable and bounded" are different properties.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your throughput ceiling is determined by two config values that most implementations never name.&lt;/strong&gt; Instrument it, measure it, put it in your SLO catalog.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;for:&lt;/code&gt; duration is where you define "incident."&lt;/strong&gt; Shortening it to catch faster is how alerts become noise. Calibrate it against real burst data, not intuition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A gauge without an alert is a decoration.&lt;/strong&gt; The terminal &lt;code&gt;FAILED&lt;/code&gt; state has exactly the property that makes it most dangerous: it looks like a healthy system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FAILED rows never self-heal.&lt;/strong&gt; Alert on them unconditionally, with severity page, and with a runbook that names the re-drive query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest SLOs require measurement.&lt;/strong&gt; A freshness target that doesn't name its throughput constraint is either vacuous or untested.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The full implementation — outbox publisher, status lifecycle, &lt;code&gt;PENDING&lt;/code&gt;/&lt;code&gt;FAILED&lt;/code&gt; gauges, alert rules, and runbooks — is in the open-source repository:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/Rummy43/ai-microservices-platform" rel="noopener noreferrer"&gt;https://github.com/Rummy43/ai-microservices-platform&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What's the throughput ceiling of your outbox implementation, and have you measured it?&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>distributedsystems</category>
      <category>microservices</category>
      <category>springboot</category>
    </item>
    <item>
      <title>Who Did This? Identity Across Async Boundaries</title>
      <dc:creator>Ramesh Yara</dc:creator>
      <pubDate>Sun, 09 Aug 2026 18:20:05 +0000</pubDate>
      <link>https://dev.to/ramesh-yara/who-did-this-identity-across-async-boundaries-1n4n</link>
      <guid>https://dev.to/ramesh-yara/who-did-this-identity-across-async-boundaries-1n4n</guid>
      <description>&lt;p&gt;You put a lot of work into authentication. A gateway validates the Keycloak JWT, maps realm roles to authorities, checks that the caller is allowed. By the time a request reaches your service, you know exactly who is calling.&lt;/p&gt;

&lt;p&gt;Then the request crosses into async land, and all of that evaporates.&lt;/p&gt;

&lt;p&gt;This is the story of the point where identity quietly disappears in an event-driven system, why the dead-letter queue is the worst possible place for it to disappear, and how I made the acting user as durable and replay-safe as the event itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  The flow everyone believes is fine
&lt;/h3&gt;

&lt;p&gt;The platform is a set of Spring Boot services: an API gateway in front, a &lt;code&gt;user-service&lt;/code&gt; on MySQL, a &lt;code&gt;notification-service&lt;/code&gt; on PostgreSQL, and Kafka carrying events between them. A user is created, an event is published, a notification is sent.&lt;/p&gt;

&lt;p&gt;Authentication is handled at the edge. The gateway is an OAuth2 Resource Server; it validates the token once and propagates the caller's identity downstream as headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// api-gateway — IdentityPropagationFilter (@Order(2), after security)&lt;/span&gt;
&lt;span class="nc"&gt;IdentityContext&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;identityContextExtractor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;extract&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Always set all three headers (empty when absent) to mask any spoofed values.&lt;/span&gt;
&lt;span class="n"&gt;enrichedRequest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;putHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;IdentityHeaders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;USER_NAME&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;nullToEmpty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;span class="n"&gt;enrichedRequest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;putHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;IdentityHeaders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;USER_EMAIL&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullToEmpty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;span class="n"&gt;enrichedRequest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;putHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;IdentityHeaders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;USER_ROLES&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;rolesAsString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;DELIM&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail here matters more than it looks. The headers are &lt;strong&gt;always overwritten&lt;/strong&gt;, even when a claim is absent. If a client tries to inject &lt;code&gt;X-User-Name: admin&lt;/code&gt; on the inbound request, the gateway stomps it with the validated value (or empty). Downstream trust in those headers is only safe because the perimeter guarantees they cannot be forged. Miss that, and you've built an impersonation API.&lt;/p&gt;

&lt;p&gt;So far, so good. The synchronous hop carries identity. The problem starts one line later.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hidden failure: the thread boundary
&lt;/h3&gt;

&lt;p&gt;I don't publish to Kafka inside the request. I use the transactional outbox pattern: the request persists the user &lt;strong&gt;and&lt;/strong&gt; an outbox row in one local transaction, and a separate scheduled poller publishes to Kafka afterward. (Why: a direct publish inside the request can commit the DB row and then lose the event if the broker call fails — the dual-write problem. The outbox closes that gap.)&lt;/p&gt;

&lt;p&gt;That decoupling is correct for delivery. It is also exactly where identity dies.&lt;/p&gt;

&lt;p&gt;The outbox publisher runs on a &lt;strong&gt;scheduled thread&lt;/strong&gt;, not the request thread. &lt;code&gt;SecurityContextHolder&lt;/code&gt;, &lt;code&gt;ThreadLocal&lt;/code&gt;, &lt;code&gt;MDC&lt;/code&gt; — every ambient place you might have stashed "who is calling" — are all empty by the time the poller runs. There is no request. There is no token. There is nothing to read.&lt;/p&gt;

&lt;p&gt;So the event goes out anonymous. The consumer logs anonymous. The notification is written anonymous. And when an event exhausts its retries and lands in the dead-letter queue — the one moment you will &lt;em&gt;urgently&lt;/em&gt; want to know who triggered it — the dead-letter row is anonymous too. Provenance vanishes at precisely the point forensics begins.&lt;/p&gt;

&lt;h3&gt;
  
  
  The naive fixes (and why each one fails)
&lt;/h3&gt;

&lt;p&gt;The tempting answers all fail at the same boundary:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Just log the username in the controller."&lt;/strong&gt; You can — but the log line you care about is the &lt;em&gt;publish&lt;/em&gt;, which happens later, on another thread, after the HTTP response has already returned. The controller log tells you a request arrived; it can't attribute the event that failed twenty seconds later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Stash it in MDC / a ThreadLocal and read it in the publisher."&lt;/strong&gt; The publisher isn't on your thread. MDC is thread-scoped; the scheduled poller starts with a clean, empty context. You'll read &lt;code&gt;null&lt;/code&gt; every time. Worse, under Virtual Threads with pooled carriers, a &lt;em&gt;stale&lt;/em&gt; ThreadLocal is a correctness hazard — you can leak one request's identity onto another's event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Re-read the JWT in the publisher."&lt;/strong&gt; There is no request in scope and no token to re-validate. The authentication event is long over.&lt;/p&gt;

&lt;p&gt;Every naive fix assumes identity lives in ambient thread state. Across an async boundary, ambient state is exactly what you don't have.&lt;/p&gt;

&lt;h3&gt;
  
  
  The production implementation: persist identity with the event
&lt;/h3&gt;

&lt;p&gt;If the publish is decoupled from the request in &lt;em&gt;time&lt;/em&gt; and &lt;em&gt;thread&lt;/em&gt;, then identity has to travel the same way the event does — &lt;strong&gt;as data, not as ambient context&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So I capture the actor on the request thread and persist it onto the outbox row, inside the same transaction as the entity write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// user-service — OutboxEventService (runs on the request thread)&lt;/span&gt;
&lt;span class="nc"&gt;IdentityContext&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;IdentityContextHolder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;orElse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;traceId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;MDC&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CorrelationConstants&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TRACE_ID&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;OutboxEvent&lt;/span&gt; &lt;span class="n"&gt;outboxEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OutboxEvent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="o"&gt;(...).&lt;/span&gt;&lt;span class="na"&gt;aggregateType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"USER"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;eventType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"USER_CREATED"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;writeValueAsString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OutboxEventStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;actorUsername&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;      &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;actorEmail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;   &lt;span class="n"&gt;actor&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;         &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;actorRoles&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;   &lt;span class="n"&gt;actor&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;rolesAsString&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;traceId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;traceId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;outboxEventRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outboxEvent&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// same TX as the user write&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now identity is as durable as the event. It survives a crash, a restart, a redeploy — because it's a committed row, not a value on a dying thread.&lt;/p&gt;

&lt;p&gt;The scheduled publisher later rehydrates that context from the row and rides it onto the message as Kafka headers — supplied &lt;strong&gt;explicitly&lt;/strong&gt;, never read from MDC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// user-service — EventPublisher (runs on the scheduled outbox thread)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;publishUserCreatedEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserCreatedEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;traceId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;IdentityContext&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ProducerRecord&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ProducerRecord&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;(&lt;/span&gt;&lt;span class="no"&gt;TOPIC&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;addHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;KAFKA_TRACE_ID_HEADER&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;traceId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;addHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;KAFKA_USER_NAME&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;addHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;KAFKA_USER_EMAIL&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;addHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;KAFKA_USER_ROLES&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;rolesAsString&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;kafkaTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The consumer does the mirror operation: read the headers, rebind identity to the consumer thread and MDC so every log line is attributed — and, critically, &lt;strong&gt;clear it in a &lt;code&gt;finally&lt;/code&gt; block&lt;/strong&gt; so nothing bleeds into the next message on a reused thread:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// notification-service — KafkaConsumerService&lt;/span&gt;
&lt;span class="nc"&gt;IdentityContext&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;restoreIdentityContext&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userNameHeader&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userEmailHeader&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userRolesHeader&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;notificationService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sendWelcomeNotification&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;partition&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;clearIdentityContext&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// MDC.remove(...) + IdentityContextHolder.clear()&lt;/span&gt;
    &lt;span class="no"&gt;MDC&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;remove&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CorrelationConstants&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TRACE_ID&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A single immutable &lt;code&gt;IdentityContext&lt;/code&gt; record is the canonical "who is acting" shape at every hop — serialized to HTTP headers at the gateway, to a DB row in the outbox, to Kafka headers on publish, back to MDC on consume. It's deliberately transport-agnostic, so a sync→async hop never has to re-derive trust from the token.&lt;/p&gt;

&lt;p&gt;Here's the whole path, including the failure branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[HTTP request thread]                    [scheduled thread]           [consumer thread]
Gateway (validate JWT)                    Outbox Publisher             @KafkaListener
   │ X-User-* headers (overwritten)          │ read actor from row        │ read actor from headers
   ▼                                          ▼                            ▼ bind to MDC
user-service ──save user + outbox row──▶ outbox_events ──Kafka headers──▶ process + log (attributed)
             (one transaction: entity                                     │
              + actor_username/email/roles)                               │ retries exhausted
                                                                          ▼
                                                             @DltHandler → dead_letter_events
                                                             (actor_* columns persisted)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The payoff: the dead-letter queue keeps its memory
&lt;/h3&gt;

&lt;p&gt;The reason all of this is worth it lives in the &lt;code&gt;@DltHandler&lt;/code&gt;. When every retry is exhausted, the failed event is persisted for triage &lt;strong&gt;with the originating actor attached&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;deadLetterEventRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DeadLetterEvent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEventId&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lastError&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errorMessage&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;actorUsername&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;actorEmail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;actorRoles&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;rolesAsString&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A dead letter without an actor is a mystery ticket. A dead letter &lt;em&gt;with&lt;/em&gt; one is an incident you can route, reproduce, and explain. The schema changes that back this are explicit migrations — Liquibase &lt;code&gt;actor_*&lt;/code&gt; + &lt;code&gt;trace_id&lt;/code&gt; on &lt;code&gt;outbox_events&lt;/code&gt; (MySQL), Flyway &lt;code&gt;actor_*&lt;/code&gt; on &lt;code&gt;dead_letter_events&lt;/code&gt; and &lt;code&gt;notification_log&lt;/code&gt; (PostgreSQL) — not &lt;code&gt;ddl-auto&lt;/code&gt; guesses.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verification: prove it survives the failure path
&lt;/h3&gt;

&lt;p&gt;Assertion isn't evidence. I verified the branch that matters — the failure one — by fault injection: force the notification step to fail so an event exhausts its four attempts and dead-letters, then read the audit table.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actor_username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actor_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_error&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;dead_letter_events&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;failed_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- actor_username / actor_email populated on the failed row ✅&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The identity is there, on a &lt;em&gt;failed&lt;/em&gt; event, written by a background thread that never saw the original request. That's the whole thesis in one row.&lt;/p&gt;

&lt;p&gt;A subtle bonus: because identity rides Kafka headers as data, it survives even where distributed &lt;em&gt;tracing&lt;/em&gt; currently doesn't. On this platform the OTel agent doesn't yet emit Kafka spans (a gap I wrote about separately), so the async trace edge is blind — yet the actor still crosses it, because header-borne data doesn't depend on span instrumentation. Two independent propagation mechanisms; the durable one keeps working when the automatic one has a hole.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key takeaways
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authentication is not propagation.&lt;/strong&gt; Validating a token proves a request is allowed &lt;em&gt;now&lt;/em&gt;; it says nothing about who's behind the event that fails an hour later on another thread.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Across an async boundary, identity must be data, not ambient context.&lt;/strong&gt; ThreadLocal/MDC/SecurityContext are all empty on the publisher and consumer threads. Persist the actor with the event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attach identity in the same transaction as the write.&lt;/strong&gt; That's what makes it as durable and replay-safe as the event — surviving crashes, retries, and redeploys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The dead-letter queue is where provenance matters most and is usually lost.&lt;/strong&gt; Persisting &lt;code&gt;actor_*&lt;/code&gt; on the DLT row turns an anonymous mystery into a routable incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust in propagated headers requires an unspoofable perimeter.&lt;/strong&gt; Unconditionally overwrite &lt;code&gt;X-User-*&lt;/code&gt; at the gateway, or you've built impersonation-as-a-service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clear context in &lt;code&gt;finally&lt;/code&gt;.&lt;/strong&gt; Under Virtual Threads and pooled consumers, a stale ThreadLocal is a cross-request correctness bug, not just untidy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The platform is open source — gateway, both services, the outbox, and this identity path end to end: &lt;a href="https://github.com/Rummy43/ai-microservices-platform" rel="noopener noreferrer"&gt;https://github.com/Rummy43/ai-microservices-platform&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When one of your events lands in the dead-letter queue tonight, can you tell who triggered it?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://medium.com/@yara.ramesh/who-did-this-identity-across-async-boundaries-823c712b073f" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>distributedsystems</category>
      <category>springboot</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Your Service Map Is Lying</title>
      <dc:creator>Ramesh Yara</dc:creator>
      <pubDate>Sun, 09 Aug 2026 18:10:22 +0000</pubDate>
      <link>https://dev.to/ramesh-yara/your-service-map-is-lying-4g47</link>
      <guid>https://dev.to/ramesh-yara/your-service-map-is-lying-4g47</guid>
      <description>&lt;p&gt;You attach the OpenTelemetry Java agent, point it at a collector, and within minutes Grafana is drawing a service map you never drew. A box for each service, arrows between them, latency on every edge. It feels like magic, and — more dangerously — it feels &lt;em&gt;complete&lt;/em&gt;. "The agent traces everything" is the sentence repeated in every onboarding doc.&lt;/p&gt;

&lt;p&gt;This is the story of the moment that sentence stopped being true on my platform, why I'm glad it did, and the difference between a system that is &lt;em&gt;working&lt;/em&gt; and a system you can actually &lt;em&gt;see&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The flow everyone trusts
&lt;/h3&gt;

&lt;p&gt;The platform is an event-driven set of Spring Boot services: an API gateway in front, a &lt;code&gt;user-service&lt;/code&gt; backed by MySQL, a &lt;code&gt;notification-service&lt;/code&gt; backed by PostgreSQL, and Kafka carrying events between them. A user is created, an event is published, a notification is sent.&lt;/p&gt;

&lt;p&gt;I didn't want to &lt;em&gt;draw&lt;/em&gt; that topology. A hand-drawn architecture diagram is documentation that drifts — true the day you commit it, slightly wrong a month later, actively misleading after a quarter. I wanted the dependency graph &lt;strong&gt;generated from live traffic&lt;/strong&gt;, so it would always reflect what the system actually does.&lt;/p&gt;

&lt;p&gt;Grafana Tempo does exactly this. Its &lt;code&gt;service-graphs&lt;/code&gt; processor reads matched client/server span pairs out of trace data and emits a metric — &lt;code&gt;traces_service_graph_request_total&lt;/code&gt; — that Grafana renders as a node graph. No edge is ever wired by hand. The topology is derived, continuously, from real spans.&lt;/p&gt;

&lt;h3&gt;
  
  
  The edge that wasn't there
&lt;/h3&gt;

&lt;p&gt;I generated the graph and the synchronous edges lit up immediately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;api-gateway → user-service&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;user-service → MySQL&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;notification-service → PostgreSQL&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I looked for the one edge I actually cared about — &lt;code&gt;user-service → notification-service&lt;/code&gt;, the asynchronous hop over Kafka.&lt;/p&gt;

&lt;p&gt;It wasn't there.&lt;/p&gt;

&lt;h3&gt;
  
  
  The naive conclusion (and why it's wrong)
&lt;/h3&gt;

&lt;p&gt;The tempting read is immediate and obvious: &lt;em&gt;the async hop is broken. The event isn't getting across. Go debug the consumer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So I checked. And the consumer was completely fine. &lt;code&gt;notification-service&lt;/code&gt; had consumed every event and written every corresponding row to PostgreSQL. Its database edge was lit. Liveness was perfect; the feature worked end to end.&lt;/p&gt;

&lt;p&gt;That is the trap. &lt;strong&gt;A missing edge looks exactly like a broken feature&lt;/strong&gt;, and the instinct is to go "fix" something that was never broken. The defect wasn't in the message path at all — it was in the &lt;em&gt;observability&lt;/em&gt; of the message path. Those are two different failure domains that happen to render identically on a dashboard.&lt;/p&gt;

&lt;p&gt;So I stopped trusting the picture and went to the source of truth: the trace store.&lt;/p&gt;

&lt;h3&gt;
  
  
  Evidence over assertion
&lt;/h3&gt;

&lt;p&gt;Two TraceQL queries settled it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{ span.messaging.system = "kafka" }
→ 0 results
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{ resource.service.name = "user-service" } &amp;amp;&amp;amp; { resource.service.name = "notification-service" }
→ 0 traces
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero Kafka messaging spans anywhere in the system. Zero traces spanning both services. The agent — this build, under this Spring Boot version — simply was not instrumenting the Kafka client. Nothing errored. No warning was logged. The map wasn't wrong about the data; &lt;strong&gt;the data was never produced&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is the part worth sitting with: a green dashboard would have let me believe the chain was fully traced. The absence of red is not the presence of coverage.&lt;/p&gt;

&lt;h3&gt;
  
  
  How the graph is actually built
&lt;/h3&gt;

&lt;p&gt;The pipeline that produces the map is worth seeing, because one of its links is &lt;em&gt;also&lt;/em&gt; a silent path you have to consciously turn on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OTel Java Agent (spans)
        │
        ▼
Tempo  (service-graphs processor)
        │  remote_write
        ▼
Prometheus  (--web.enable-remote-write-receiver)
        │  query
        ▼
Grafana  (Tempo data source → serviceMap → nodeGraph)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three deliberate decisions shaped it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generated edges, not declared ones.&lt;/strong&gt; Tempo reads client/server span pairs and emits the edge metric. The graph is a test, not a drawing — it &lt;em&gt;fails&lt;/em&gt; when reality diverges from expectation, which is exactly what makes it valuable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One processor, scoped on purpose.&lt;/strong&gt; I enabled &lt;code&gt;service-graphs&lt;/code&gt; only — deliberately not &lt;code&gt;span-metrics&lt;/code&gt;. The latter generates RED/latency series and would have inflated cardinality for a deliverable that was strictly about topology. Minimal blast radius, single responsibility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A push boundary you must open explicitly.&lt;/strong&gt; Tempo &lt;em&gt;remote-writes&lt;/em&gt; its generated metrics into Prometheus — the inverse of every other component, which Prometheus scrapes. That requires flipping Prometheus into a receiver with &lt;code&gt;--web.enable-remote-write-receiver&lt;/code&gt;. Forget it, and the metrics silently never land. Same lesson as the Kafka gap, one layer down: the data paths that fail quietly are the ones nobody turned on.&lt;/p&gt;

&lt;h3&gt;
  
  
  One missing edge, two real causes
&lt;/h3&gt;

&lt;p&gt;There's a subtlety the graph forced me to articulate. Even once the agent emits Kafka spans, the &lt;code&gt;user-service → notification-service&lt;/code&gt; edge would &lt;em&gt;still&lt;/em&gt; not look like a normal synchronous call — because my publish doesn't happen on the request thread.&lt;/p&gt;

&lt;p&gt;I use the transactional outbox pattern: the request persists the user and an outbox row in one local transaction, and a scheduled poller publishes to Kafka afterward. So the Kafka branch roots its &lt;em&gt;own&lt;/em&gt; trace, off the poll cycle, structurally separate from the originating HTTP request. That's the outbox decoupling working as designed — not a propagation bug.&lt;/p&gt;

&lt;p&gt;So a single missing edge had two distinct, both-legitimate explanations: a real &lt;strong&gt;instrumentation gap&lt;/strong&gt; (no Kafka spans) and a real &lt;strong&gt;design boundary&lt;/strong&gt; (async decoupling). Conflating them would have been the naive read. The generated graph didn't just find a bug — it made an architecture decision legible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making the map legible, not just generated
&lt;/h3&gt;

&lt;p&gt;There was one more way the graph was technically correct but practically dishonest: both databases first rendered as a single node named &lt;code&gt;localhost&lt;/code&gt;. The agent labels an uninstrumented peer by its host, and both databases live on the loopback interface — so MySQL and PostgreSQL collapsed into one vertex. A heterogeneous-persistence design rendered as if it shared one database.&lt;/p&gt;

&lt;p&gt;The fix is a peer-service mapping on the agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;-Dotel.instrumentation.common.peer-service-mapping&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="s"&gt;localhost:3306=MySQL,localhost:5432=PostgreSQL,&lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="s"&gt;localhost:8085=SchemaRegistry,localhost:8180=Keycloak&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now each dependency resolves to a real, named vertex, and the Schema Registry shows up as its own node. I confirmed it the same way I confirmed everything else — in the backend, not the UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="n"&gt;traces_service_graph_request_total&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"MySQL"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;        &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;present&lt;/span&gt;
&lt;span class="n"&gt;traces_service_graph_request_total&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"PostgreSQL"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;present&lt;/span&gt;
&lt;span class="n"&gt;traces_service_graph_request_total&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"localhost"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;    &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A graph that says &lt;code&gt;localhost&lt;/code&gt; is &lt;em&gt;generated&lt;/em&gt;; a graph that says &lt;code&gt;MySQL&lt;/code&gt; and &lt;code&gt;PostgreSQL&lt;/code&gt; is &lt;em&gt;legible&lt;/em&gt;. The labels are what turn telemetry into topology.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key takeaways
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auto-instrumentation is a claim, not a guarantee.&lt;/strong&gt; "The agent traces everything" holds until a version boundary quietly says otherwise. Verify spans exist in the backend; never infer coverage from the absence of errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A missing edge is not a missing feature.&lt;/strong&gt; Liveness and observability are independent failure domains. A system can be correct and unobservable at the same time — the most dangerous state, because it looks fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A generated graph is a test, not a decoration.&lt;/strong&gt; Its entire value is that it can fail when reality diverges. A hand-drawn diagram cannot fail; that's precisely why it's worthless as a safety net.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent data paths must be consciously enabled.&lt;/strong&gt; Remote-write receivers, peer mappings, opt-in instrumentation — the telemetry that "just never lands" is the telemetry nobody turned on. Make the gaps loud.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name your peers.&lt;/strong&gt; Topology you can't read is only half-built; labels are the difference between a generated graph and a useful one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify with queries, not screenshots.&lt;/strong&gt; Green is the absence of evidence, not evidence of absence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The platform is open source — gateway, two services, Kafka, the full observability stack, and this service-graph setup: &lt;a href="https://github.com/Rummy43/ai-microservices-platform" rel="noopener noreferrer"&gt;https://github.com/Rummy43/ai-microservices-platform&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What's the most expensive silent gap you've found by verifying instead of trusting the dashboard?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://medium.com/@yara.ramesh/your-service-map-is-lying-cfc84fb38990" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>observability</category>
      <category>kafka</category>
      <category>distributedsystems</category>
      <category>springboot</category>
    </item>
  </channel>
</rss>
