<?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: alginte software</title>
    <description>The latest articles on DEV Community by alginte software (@alginte_sw).</description>
    <link>https://dev.to/alginte_sw</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%2F4109378%2F151d1de6-a5b0-465b-a8a8-434b8cdd0a72.png</url>
      <title>DEV Community: alginte software</title>
      <link>https://dev.to/alginte_sw</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alginte_sw"/>
    <language>en</language>
    <item>
      <title>Debezium change events in Kafka Streams: flatten, route, and survive the delete</title>
      <dc:creator>alginte software</dc:creator>
      <pubDate>Wed, 23 Sep 2026 06:07:29 +0000</pubDate>
      <link>https://dev.to/alginte/debezium-change-events-in-kafka-streams-flatten-route-and-survive-the-delete-54ab</link>
      <guid>https://dev.to/alginte/debezium-change-events-in-kafka-streams-flatten-route-and-survive-the-delete-54ab</guid>
      <description>&lt;p&gt;You have &lt;a href="https://debezium.io/" rel="noopener noreferrer"&gt;Debezium&lt;/a&gt; streaming a database table into Kafka. Call it &lt;code&gt;shipments&lt;/code&gt;: a row per parcel, with an order, a carrier, a weight and a destination, and every insert, update and delete against it landing on a topic as it happens. Now you want to do something with that topic — keep the shipments over ten kilograms, join them to orders, count them per carrier.&lt;/p&gt;

&lt;p&gt;If you have not met it, that first sentence is doing a lot of work. Debezium reads a database's own change log and turns every committed row change into a Kafka record, in commit order. No polling, no triggers, no timestamp column that somebody has to remember to touch, and no change to the application doing the writing: the database is already writing that log for its own recovery, and Debezium reads it. Before the live changes it takes a consistent &lt;a href="https://debezium.io/documentation/reference/stable/connectors/postgresql.html#postgresql-snapshots" rel="noopener noreferrer"&gt;snapshot&lt;/a&gt; of what is already there, so the stream starts complete rather than starting from now.&lt;/p&gt;

&lt;p&gt;Which log that is depends on the database, and there are &lt;a href="https://debezium.io/documentation/reference/stable/connectors/index.html" rel="noopener noreferrer"&gt;thirteen connectors&lt;/a&gt;  of them: the write-ahead log on Postgres, the binlog on MySQL and MariaDB, the oplog on MongoDB, the redo log on Oracle, and so on through SQL Server, Db2, Cassandra, Spanner and the rest. The &lt;a href="https://debezium.io/documentation/reference/stable/index.html" rel="noopener noreferrer"&gt;documentation&lt;/a&gt;is the place to start.&lt;/p&gt;

&lt;p&gt;This post uses Postgres. The change events below look almost the same from every connector, so a topology written for one mostly transfers to the next. If you want a real one running locally, &lt;a href="https://github.com/debezium/debezium-examples/tree/main/tutorial" rel="noopener noreferrer"&gt;&lt;code&gt;docker-compose-postgres.yaml&lt;/code&gt;&lt;/a&gt;in the tutorial examples takes a few minutes.&lt;/p&gt;

&lt;p&gt;Kafka Streams is the natural place for that logic. It is a library that ships with Apache Kafka, not a cluster to run: your application links it, reads the topic, writes another, and the broker you already have does the rest. So you reach for it, and the first record you look at is not a shipment. It is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"before"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"shipment_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"order_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ord-1001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"carrier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DHL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"weight_kg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Zagreb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"leave with neighbour"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-19T07:55:40.534185Z"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3.6.3.Final"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"connector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"postgresql"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"db"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inventory"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shipments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"txId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;826&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"lsn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;28126360&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"transaction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ts_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1789804541024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ts_us"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1789804541024594&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ts_ns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1789804541024594256&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the &lt;a href="https://debezium.io/documentation/reference/stable/connectors/postgresql.html#postgresql-events" rel="noopener noreferrer"&gt;change event envelope&lt;/a&gt;: a change is not a row, it is a before, an after and a verb. The new version of the row is in &lt;code&gt;after&lt;/code&gt;, the version it replaced is in &lt;code&gt;before&lt;/code&gt;, and &lt;code&gt;op&lt;/code&gt; says what happened — an insert, an update, a delete. On an insert there is nothing to put in &lt;code&gt;before&lt;/code&gt;, so it is null; on a delete there is nothing to put in&lt;code&gt;after&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Three things follow from that, and everybody does all three. Flatten &lt;code&gt;after&lt;/code&gt;. Route by &lt;code&gt;op&lt;/code&gt;. Survive the delete. Here is each as one expression, and how to know it is right before it runs against anything that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the transform that may end the post here
&lt;/h2&gt;

&lt;p&gt;Debezium ships &lt;a href="https://debezium.io/documentation/reference/stable/transformations/event-flattening.html" rel="noopener noreferrer"&gt;&lt;code&gt;ExtractNewRecordState&lt;/code&gt;&lt;/a&gt;, a Kafka Connect single message transform (an SMT, in Connect's words) that unwraps the envelope. Put it on the connector and the envelope is gone: the topic carries the &lt;code&gt;after&lt;/code&gt; row, flat, the way you wanted in the first place.&lt;/p&gt;

&lt;p&gt;If that is all you need, use it. It runs in the connector, it costs you no application, and this post is over. What it cannot do is anything that depends on more than one record or on more than one topic: routing to different topics by a condition on the data, joining a change stream to another stream, keeping a running count per key, comparing &lt;code&gt;before&lt;/code&gt; to &lt;code&gt;after&lt;/code&gt; to find what actually changed. The moment the logic outgrows one record at a time, it moves into a stream, and the envelope comes with it.&lt;/p&gt;

&lt;p&gt;So: keep the envelope, and learn its three moves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flatten &lt;code&gt;after&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The expressions in this post are SpEL, written into a node on &lt;a href="https://www.alginte.com" rel="noopener noreferrer"&gt;Alginte&lt;/a&gt;'s canvas: a &lt;code&gt;mapValues&lt;/code&gt; node carries one that shapes the value, a &lt;code&gt;filter&lt;/code&gt; node carries one that answers true or false, and &lt;code&gt;value&lt;/code&gt; inside them is the record's value, which the JSON converter hands over as a map. &lt;a href="https://www.alginte.com/blog/visual-kafka-streams-builder/" rel="noopener noreferrer"&gt;Kafka Streams topologies you can draw and run&lt;/a&gt;walks the canvas end to end; the &lt;a href="https://docs.alginte.com/streams/spel#roots-key-and-value" rel="noopener noreferrer"&gt;expression reference&lt;/a&gt; has the rules. If you write Kafka Streams in Java, read each expression as the one-line body of the lambda in that node.&lt;/p&gt;

&lt;p&gt;The column you want is one hop in:&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;value&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="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="err"&gt;'&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="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;carrier&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the record above that is &lt;code&gt;"DHL"&lt;/code&gt;. Build the whole flattened row the same way, in a &lt;code&gt;mapValues&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;after&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;shipment_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
 &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;carrier&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;after&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;carrier&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
 &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;weight&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;after&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;weight_kg&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the JSON converter the strings are real strings: &lt;code&gt;value.get('op') == 'd'&lt;/code&gt;works, no conversion, no &lt;code&gt;toString()&lt;/code&gt;. If your connector's &lt;code&gt;value.converter&lt;/code&gt; is Avro instead, so the same envelope lands as an Avro record with its schema in a registry, the field arrives as a &lt;code&gt;Utf8&lt;/code&gt;: &lt;code&gt;== 'd'&lt;/code&gt; still works, &lt;code&gt;.contains('d')&lt;/code&gt;does not until you call &lt;code&gt;.toString()&lt;/code&gt; on it first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route by &lt;code&gt;op&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;op&lt;/code&gt; is one character: &lt;code&gt;c&lt;/code&gt; for create, &lt;code&gt;u&lt;/code&gt; for update, &lt;code&gt;d&lt;/code&gt; for delete, &lt;code&gt;r&lt;/code&gt; for a row read during the initial snapshot. Route on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;op&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;c&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;op&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;u&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// upserts&lt;/span&gt;
&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;op&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// deletes&lt;/span&gt;
&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;op&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;r&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// the snapshot backfill&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one worth a second look is &lt;code&gt;r&lt;/code&gt;. When the connector first starts it reads the table and emits every existing row as a change event with &lt;code&gt;op: r&lt;/code&gt;, before any live change arrives — with &lt;code&gt;source.snapshot&lt;/code&gt; reading &lt;code&gt;first&lt;/code&gt; on the first of them and &lt;code&gt;last&lt;/code&gt; on the last, if you want to find the boundary. If your logic treats every event as news — send an email, call an API, increment a counter that means "changes today" — the first deployment sends one per existing row. That is the snapshot doing what it says, and it is why the stream starts with history instead of starting empty. Decide what &lt;code&gt;r&lt;/code&gt; means to you, and say so in an expression rather than finding out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Survive the delete
&lt;/h2&gt;

&lt;p&gt;Here is the record your first version dies on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"before"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"shipment_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"order_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ord-1001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"carrier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DHL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"weight_kg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Zagreb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"leave with neighbour"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-19T07:55:40.534185Z"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ts_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1789804541026&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;after&lt;/code&gt; is null, because after a delete there is no row. Every expression above reads &lt;code&gt;value.get('after').get(...)&lt;/code&gt;, and on this record that is a method call on null. It fails. Not quietly, not as a &lt;code&gt;false&lt;/code&gt; that drops the record: it fails, and depending on where you put it, your topology stops.&lt;/p&gt;

&lt;p&gt;The thing to notice is &lt;em&gt;when&lt;/em&gt; it fails. It works on every insert and every update and every snapshot row, which is to say it works for as long as nobody deletes anything. Then somebody does.&lt;/p&gt;

&lt;p&gt;There are two honest fixes. Route first, and only flatten where you know there is an &lt;code&gt;after&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;op&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// a filter, upstream of the flatten&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or make the access itself null-safe, with SpEL's &lt;a href="https://docs.spring.io/spring-framework/reference/core/expressions/language-ref/operator-safe-navigation.html" rel="noopener noreferrer"&gt;safe navigation&lt;/a&gt;operator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;after&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;shipment_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On an insert that is &lt;code&gt;"s-1"&lt;/code&gt;. On the delete it is &lt;code&gt;null&lt;/code&gt;, and nothing throws. It reads as a small thing and it is the difference between a topology that survives its first delete and one that does not.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvfgr9rm9zieo7z3dtdpg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvfgr9rm9zieo7z3dtdpg.png" alt="The map values editor holding the two-line flatten {'id': value.get('after')?.get('shipment_id'), 'carrier': value.get('after')?.get('carrier')} with no issues. Under it the sample preview evaluated against a real record from the shipments-cdc topic, partition 0, offset 3: the delete, with before holding the row and after null. The out value is {" width="800" height="736"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And where the delete is what you actually care about — a downstream store to evict, an index entry to remove — &lt;code&gt;before&lt;/code&gt; is where the row is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;before&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;shipment_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is why &lt;code&gt;before&lt;/code&gt; is worth configuring, and this is the part that surprised me when I measured it. On Postgres, &lt;code&gt;REPLICA IDENTITY&lt;/code&gt; decides how much of the old row reaches the connector. With the default, the old tuple is the primary key and nothing else — so an &lt;strong&gt;update&lt;/strong&gt; has no &lt;code&gt;before&lt;/code&gt; at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"before"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"shipment_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s-2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"carrier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GLS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"u"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and a &lt;strong&gt;delete&lt;/strong&gt; has a &lt;code&gt;before&lt;/code&gt; that looks complete and is not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"before"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"shipment_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"order_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"carrier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"weight_kg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1970-01-01T00:00:00.000000Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is real. Every other column has been filled in with its type's zero: an empty string for the text columns, &lt;code&gt;0.0&lt;/code&gt; for the weight, and for the timestamp the epoch, which is the one that catches the eye. Not null, which you would notice — a value, which you would not; only &lt;code&gt;notes&lt;/code&gt; is null, and only because that column is nullable. A rule that reads&lt;code&gt;value.get('before').get('carrier')&lt;/code&gt; gets &lt;code&gt;""&lt;/code&gt; and carries on; a filter on&lt;code&gt;weight_kg &amp;lt; 5&lt;/code&gt; says yes to a parcel that weighed nothing; a rule that reads&lt;code&gt;updated_at&lt;/code&gt; gets 1970.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ALTER TABLE ... REPLICA IDENTITY FULL&lt;/code&gt; gives you the whole old row on both updates and deletes, at the cost of more WAL. If anything downstream reads&lt;code&gt;before&lt;/code&gt; for more than the key, that is the setting, and it belongs in the migration that creates the table rather than in the incident that finds it.&lt;/p&gt;

&lt;h3&gt;
  
  
  And then the tombstone
&lt;/h3&gt;

&lt;p&gt;There is one more record, and it is not an envelope at all:&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="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shipment_id"&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s-1"&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the delete event, Debezium emits a &lt;a href="https://debezium.io/documentation/reference/stable/connectors/postgresql.html#postgresql-delete-events" rel="noopener noreferrer"&gt;tombstone&lt;/a&gt;: same key, null value, no envelope, nothing to reach into. Its job is log compaction — it is the marker that lets Kafka eventually drop every earlier record with that key. It is on by default, and it is the second record your delete produces.&lt;/p&gt;

&lt;p&gt;For your topology that means &lt;code&gt;value&lt;/code&gt; itself is null. Not &lt;code&gt;after&lt;/code&gt;, not a field: the whole value. An expression cannot save you there, because there is nothing to evaluate. Decide what a null value means in your stream and handle it as a null, or turn the tombstones off with &lt;code&gt;tombstones.on.delete=false&lt;/code&gt; if the downstream does not need them and you would rather not think about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that bites silently
&lt;/h2&gt;

&lt;p&gt;Everything above fails loudly, which is the good kind. This one does not.&lt;/p&gt;

&lt;p&gt;Give the table a &lt;code&gt;NUMERIC&lt;/code&gt; column — a weight, a price, an amount, which in a database is exactly what &lt;code&gt;NUMERIC&lt;/code&gt; is for — and under Debezium's default settings it arrives like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"weight_kg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"APA="&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is 2.40. Debezium's &lt;code&gt;decimal.handling.mode&lt;/code&gt; &lt;a href="https://debezium.io/documentation/reference/stable/connectors/postgresql.html#postgresql-decimal-types" rel="noopener noreferrer"&gt;defaults to &lt;code&gt;precise&lt;/code&gt;&lt;/a&gt;: a &lt;code&gt;NUMERIC&lt;/code&gt; is an exact decimal, a JSON number is a float, and rather than lose precision the connector sends the unscaled bytes, base64-encoded, with the scale in the schema.&lt;/p&gt;

&lt;p&gt;What it means for your expression is that &lt;code&gt;weight_kg&lt;/code&gt; is a String. So this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;after&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;weight_kg&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is a String compared to a number. If you want the ordinary thing, say so on the connector:&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="err"&gt;"decimal.handling.mode":&lt;/span&gt; &lt;span class="err"&gt;"double"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and &lt;code&gt;weight_kg&lt;/code&gt; is &lt;code&gt;2.4&lt;/code&gt;. There is a third setting, &lt;code&gt;string&lt;/code&gt;, which gives you&lt;code&gt;"2.40"&lt;/code&gt; — exact, readable, and still not a number until you convert it. Choose deliberately: &lt;code&gt;precise&lt;/code&gt; when the value is money and the arithmetic is someone else's problem downstream, &lt;code&gt;double&lt;/code&gt; when you are comparing weights in a filter and a float is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove it before it runs
&lt;/h2&gt;

&lt;p&gt;Every expression in this post is one line, and every one of them has a record that breaks it. The chained &lt;code&gt;get&lt;/code&gt; breaks on the delete. The numeric comparison breaks on the default decimal mode. The tombstone breaks anything that assumes a value.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.alginte.com/blog/kafka-streams-topology-starts-in-a-sandbox/" rel="noopener noreferrer"&gt;You do not have to find those on a shared cluster&lt;/a&gt;. Take a sample of the topic — the ordinary record, an update, a delete, and the tombstone — and evaluate against it while you write. In Alginte that is the &lt;a href="https://docs.alginte.com/installation/playground" rel="noopener noreferrer"&gt;playground&lt;/a&gt;: one command, its own broker, and a folder where the change events you copied in are produced at every start. There is a Debezium sample in it already, four real events captured from a Postgres, so you can see the envelope without having a database or a connector anywhere near it; the tombstone you produce from the console, with a null value, since a file has no way to hold one.&lt;/p&gt;

&lt;p&gt;The part that matters is not the tool, it is the order: write the expression against a record that has actually happened, see the result, and only then deploy. A chained &lt;code&gt;get&lt;/code&gt; through a null &lt;code&gt;after&lt;/code&gt; is a two-second discovery when the delete is in front of you, and an incident when it is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Your row is in &lt;code&gt;after&lt;/code&gt;; &lt;code&gt;before&lt;/code&gt; is the old one; &lt;code&gt;op&lt;/code&gt; says which to believe.&lt;/li&gt;
&lt;li&gt;If a flat topic is all you need, &lt;code&gt;ExtractNewRecordState&lt;/code&gt; gives you that in the connector and you can stop.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;op: r&lt;/code&gt; is the initial snapshot, and it arrives once per existing row.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;after&lt;/code&gt; is null on a delete. Route on &lt;code&gt;op&lt;/code&gt; first, or use &lt;code&gt;?.&lt;/code&gt;, or read&lt;code&gt;before&lt;/code&gt; — and set &lt;code&gt;REPLICA IDENTITY FULL&lt;/code&gt; if you want the whole old row.&lt;/li&gt;
&lt;li&gt;The delete is followed by a tombstone: same key, null value, no envelope.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;NUMERIC&lt;/code&gt; is base64 unless you set &lt;code&gt;decimal.handling.mode&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is hard. All of it is easier to learn from a record in front of you, on a &lt;a href="https://docs.alginte.com/installation/playground" rel="noopener noreferrer"&gt;playground&lt;/a&gt; you can experiment with freely, before the topology runs anywhere that matters.&lt;/p&gt;

</description>
      <category>debezium</category>
      <category>cdc</category>
      <category>apachekafka</category>
      <category>kafkastreams</category>
    </item>
    <item>
      <title>A Kafka Streams topology starts in a sandbox, not on your cluster</title>
      <dc:creator>alginte software</dc:creator>
      <pubDate>Sat, 19 Sep 2026 17:05:18 +0000</pubDate>
      <link>https://dev.to/alginte/a-kafka-streams-topology-starts-in-a-sandbox-not-on-your-cluster-i3p</link>
      <guid>https://dev.to/alginte/a-kafka-streams-topology-starts-in-a-sandbox-not-on-your-cluster-i3p</guid>
      <description>&lt;p&gt;Somebody owns the Kafka cluster you need, and it is not you. You have a topology in mind: read &lt;code&gt;orders&lt;/code&gt;, keep the big ones, write them somewhere. You have a predicate in mind too, and you are fairly sure it is right. "Fairly sure" is the problem. To find out, you need to run it, and the only place to run it is the shared cluster: a consumer group in everyone's list, internal topics with your name on them, and an output topic that real consumers may already be reading. Kafka does not take records back. A wrong predicate there means wrong records that somebody downstream has already acted on.&lt;/p&gt;

&lt;p&gt;So you do not run it. The idea is shelved, and you never learn that it was the right one.&lt;/p&gt;

&lt;p&gt;This post is about the other order: find out first, in a place where being wrong costs nothing, and only then touch the cluster. It needs two things. A stack of your own that you are free to break, and a way to carry what you learned across the border without carrying the stack with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The expensive part is not the topology
&lt;/h2&gt;

&lt;p&gt;Writing a Kafka Streams topology is not where the time goes. The time goes into the question the code cannot answer for itself: does the predicate do what you meant, on the records that actually arrive? We made that argument at length in &lt;a href="https://www.alginte.com/blog/kafka-streams-without-codegen/" rel="noopener noreferrer"&gt;The compiler was never what you wanted&lt;/a&gt;: the cost is in the loop between having an idea and finding out, and on a shared cluster that loop is long, so you run it rarely.&lt;/p&gt;

&lt;p&gt;The loop is short when three things are true. The records you evaluate against are real. The evaluation you see before deploying is the one that will run after. And being wrong leaves nothing behind. The earlier post was about the first two. This one is about the third, and about what happens after you are right.&lt;/p&gt;

&lt;h2&gt;
  
  
  A stack of your own, free to break
&lt;/h2&gt;

&lt;p&gt;One &lt;a href="https://github.com/alginte/community/tree/main/playground" rel="noopener noreferrer"&gt;&lt;code&gt;docker compose up -d&lt;/code&gt;&lt;/a&gt; gives you a single-node Kafka, a Schema Registry, and a console that runs Kafka Streams topologies, bound to your machine, with no volumes. It comes seeded with topics, real Avro records against a registered subject, and a topology already running, so the first thing you see is a canvas with numbers on it. &lt;code&gt;docker compose down&lt;/code&gt; returns the machine to the state it was in. That is the whole safety story. Nothing here can hurt anyone, because nothing here is shared.&lt;/p&gt;

&lt;p&gt;Seeded orders are fine for the first ten minutes. After that you want your own topic. Say it is &lt;code&gt;shipments&lt;/code&gt;, with JSON Schema values. Two folders beside the compose file take it in, and because they are read at every &lt;code&gt;up&lt;/code&gt;, what you put there survives the &lt;code&gt;down&lt;/code&gt; that erases everything else:&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="s"&gt;playground/&lt;/span&gt;
  &lt;span class="s"&gt;docker-compose.yml&lt;/span&gt;
  &lt;span class="s"&gt;schemas/&lt;/span&gt;
    &lt;span class="s"&gt;shipments-value.json the subject, saved out of your registry&lt;/span&gt;
  &lt;span class="s"&gt;records/&lt;/span&gt;
    &lt;span class="s"&gt;shipments.json-schema.jsonl a sample of the topic, one record per line&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file name is the contract. &lt;code&gt;shipments-value.json&lt;/code&gt; is registered as the subject &lt;code&gt;shipments-value&lt;/code&gt;; &lt;code&gt;.avsc&lt;/code&gt; and &lt;code&gt;.proto&lt;/code&gt; do the same for Avro and Protobuf. &lt;code&gt;shipments.json-schema.jsonl&lt;/code&gt; is produced into the topic&lt;code&gt;shipments&lt;/code&gt;, each value framed against that subject, and a line is either a keyed record or a bare one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;s-1|{"shipmentId":"s-1","orderId":"ord-1001","carrier":"DHL","weightKg":2.4,"destination":"Zagreb"}
s-2|{"shipmentId":"s-2","orderId":"ord-1003","carrier":"UPS","weightKg":11.0,"destination":"Milano"}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the topic and subject names identical to the real ones, because the design you are about to make carries them. A small sample is enough, if it is the right one: the ordinary record, and then the ones that break things. A null where a field is optional, an empty list, a value sitting on the boundary of your predicate, a key you did not expect. What you are proving is logic against the shape of your data, and shape is a matter of variety, not volume.&lt;/p&gt;

&lt;p&gt;Production may not be able to give you that sample. The ordinary record is there a million times over; the one that breaks your predicate may not have happened yet, and a shared topic is no place to produce it to find out, with everyone else's consumers downstream. Here it is one more line in the file: written by hand, framed against the same subject as the sampled ones, seen by nobody's consumer but yours.&lt;/p&gt;

&lt;p&gt;Anything personal in the sampled records is yours to redact before it lands in the folder. The playground's repository has these two files as templates to copy.&lt;/p&gt;

&lt;p&gt;Now the loop. Drop a source node on &lt;code&gt;shipments&lt;/code&gt;, bound to &lt;code&gt;shipments-value&lt;/code&gt;. Add a filter, and start typing the predicate. As you type, it is &lt;a href="https://www.alginte.com/blog/building-a-spel-editor/" rel="noopener noreferrer"&gt;evaluated against the newest record on the topic&lt;/a&gt;, and the result sits next to the editor: the input record, the output, the verdict. Put a &lt;code&gt;mapValues&lt;/code&gt; in front of it and the filter sees the shape the&lt;code&gt;mapValues&lt;/code&gt; produced, not a guess. Misspell a field and the editor tells you on the keystroke. On an Avro or Protobuf record the access fails, with the same message the deployed operator would throw, because it is the same evaluation. On a JSON Schema record, where a missing key is a quiet&lt;code&gt;null&lt;/code&gt; at runtime, the editor warns on the name and offers the closest one the subject declares. This is the short loop: the few seconds between the typo and finding it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F442p2tlwx4ww6157f774.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F442p2tlwx4ww6157f774.png" alt="The filter's drawer on a JSON Schema source, the expression value.get('carier') == 'DHL' underlined as a warning. The status panel reads: the subject declares no property 'carier', lists the five declared properties, says an absent key answers null so this never fails here or deployed, and asks: did you mean 'carrier'? Under it the sample preview shows a real shipment and the verdict: predicate would DROP the sample." width="800" height="1194"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Fix the name, finish the predicate, and the same panel judges it against a record you brought:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ubwtqon4aqlg5s43klk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ubwtqon4aqlg5s43klk.png" alt="The same drawer with the two-line predicate value.get('carrier') == 'DHL' &amp;amp;&amp;amp; value.get('weightKg') &lt; 5 and no issues. The sample preview shows a real record from the shipments topic, key s-3, a DHL parcel of 1.1 kg for Berlin, in and out, with the verdict: predicate passes the sample." width="800" height="1061"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Submit, and a gate runs before anything deploys: a source topic that does not exist, a source whose subject the registry does not have, join inputs that are not co-partitioned, a windowed key on its way to a sink that cannot write it. Each refusal names the node and the fix. Pass the gate, and the stream runs here, on this broker. Produce a few records, written by hand or generated at random from the subject's schema, one click each. Watch the sink fill, open the State tab and see &lt;a href="https://www.alginte.com/blog/monitoring-kafka-streams-beyond-the-cluster/" rel="noopener noreferrer"&gt;the threads, the stores and the lag&lt;/a&gt;. Delete it, keep the state or remove it. Deploy it again under the same id, and the console tells you what the earlier run left on the cluster, a changelog and a committed offset, and asks whether to continue on it or start clean.&lt;/p&gt;

&lt;p&gt;None of this is a simulation. It is the Kafka Streams client, running your topology, on a broker you can throw away. The console in the loop is &lt;a href="https://www.alginte.com" rel="noopener noreferrer"&gt;Alginte&lt;/a&gt;, in the mode it calls&lt;a href="https://docs.alginte.com/installation/playground" rel="noopener noreferrer"&gt;the playground&lt;/a&gt;, and the one claim that makes the loop worth trusting is the one we test hardest: an expression that evaluates one way in the editor evaluates the same way deployed, on Avro, JSON Schema and Protobuf alike. That promise is held by a corpus of rows that run each expression on both surfaces and refuse to ship a disagreement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Carrying it across the border
&lt;/h2&gt;

&lt;p&gt;A design that works here has to reach the cluster that matters, and it must not bring the sandbox with it.&lt;/p&gt;

&lt;p&gt;The design travels as a file. &lt;strong&gt;Download&lt;/strong&gt; writes the whole thing: the configuration, every node with its expressions and serdes, the edges, the layout. The file is named after the &lt;code&gt;application.id&lt;/code&gt;, and it belongs in git, beside the code it feeds. It is plain, indented JSON, so a changed expression is a one-line diff, a new node is a block a reviewer can read in a pull request, and the history of the stream is the history of the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;   "data": {
     "name": "light DHL parcels",
     "expression": {
&lt;span class="gd"&gt;- "expression": "value.get('carrier') == 'DHL' &amp;amp;&amp;amp; value.get('weightKg') &amp;lt; 5",
&lt;/span&gt;&lt;span class="gi"&gt;+ "expression": "value.get('carrier') == 'DHL' &amp;amp;&amp;amp; value.get('weightKg') &amp;lt; 10",
&lt;/span&gt;       "language": "SpEL"
     }
&lt;span class="err"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is what makes it the durable artefact: the sandbox is thrown away, the file is not.&lt;/p&gt;

&lt;p&gt;What the file does not carry is as important: not the records, not the topic, not the schema, not the stream's state. Those are the cluster's, on both sides.&lt;/p&gt;

&lt;p&gt;On the console that runs against the real cluster, &lt;strong&gt;Upload&lt;/strong&gt; the file. The same wizard opens with the same validation. Three things are different at the border, and each is handled by a rule rather than by remembering.&lt;/p&gt;

&lt;p&gt;A design carries its logic, not its cluster. If the file has&lt;code&gt;bootstrap.servers&lt;/code&gt;, a registry URL or security settings in it, the upload leaves them out and says which, so the connection that applies is this console's, never the sandbox's.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3082gc2085b8mb1j1mak.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3082gc2085b8mb1j1mak.png" alt="The builder's canvas just after an upload: the three nodes of the design, source, filter with its predicate, sink, and a notice beside them: Cluster-bound properties left out of the upload. bootstrap.servers, schema.registry.url: a design carries its logic, not the cluster it was written on. This console's own values apply; set them on Stream Properties if this stream needs others." width="800" height="1013"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Names are checked against this cluster. The gate refuses a source topic that does not exist here, and a source whose subject this registry lacks, naming the node. Register the subject first, with the same text you put in&lt;code&gt;schemas/&lt;/code&gt;, and the refusal goes away.&lt;/p&gt;

&lt;p&gt;The id is checked against this cluster's memory. If a stream under this&lt;code&gt;application.id&lt;/code&gt; has run here before, the console says so before deploying: these internal topics, this committed offset. Continue on them, or start fresh. It never guesses.&lt;/p&gt;

&lt;p&gt;Then Submit. The deploy is the same gate and the same client. The only thing that changed is the cluster it joined.&lt;/p&gt;

&lt;p&gt;That is one of two routes out of the sandbox, and neither is the lesser one. The other: your team ships its streams as Java services, with a compiler and a test suite, and what you carry across is the shape, the expressions and the evidence that they are right. Either way you arrive having already answered the question the compiler cannot.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the sandbox does not prove
&lt;/h2&gt;

&lt;p&gt;Scale. A design proven on a small, well-chosen sample is proven for what it does to a record, not for its throughput, its partition layout under load, or how long its stores take to restore after a restart. Those are questions for the real cluster, and there they have answers. In the sandbox, the &lt;a href="https://www.alginte.com/blog/monitoring-kafka-streams-beyond-the-cluster/" rel="noopener noreferrer"&gt;&lt;strong&gt;State tab&lt;/strong&gt;&lt;/a&gt; showed a handful of records moving. On the real cluster, the same tab shows what matters at scale: a restore as it replays, the threads and the tasks they hold, the lag of every store, and on the canvas the throughput of every node. It is the same console on both sides of the border: the workbench here, the instrument there. The sandbox tells you the logic is right. The first hour on the real cluster tells you the rest, and it is an hour you can watch rather than infer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order of operations
&lt;/h2&gt;

&lt;p&gt;Start where being wrong is free. Bring the shape of your data, and a sample that covers its corners. Write the expression against a real record and let the editor tell you on the keystroke. Deploy on a broker you can throw away, and throw it away. Then, if you choose to run it as it is, carry the design across to the production cluster as a file, and let the gate on the other side check the names, the id and the connection. Deploy there once, on purpose.&lt;/p&gt;

&lt;p&gt;The production cluster is still somebody else's. The difference is that your stream now arrives there with its logic already proven, and "fairly sure" left behind in the sandbox.&lt;/p&gt;

</description>
      <category>apachekafka</category>
      <category>kafkastreams</category>
      <category>spring</category>
      <category>java</category>
    </item>
    <item>
      <title>Monitoring Kafka Streams applications: the state a cluster view cannot show</title>
      <dc:creator>alginte software</dc:creator>
      <pubDate>Tue, 15 Sep 2026 05:38:25 +0000</pubDate>
      <link>https://dev.to/alginte/monitoring-kafka-streams-applications-the-state-a-cluster-view-cannot-show-1cde</link>
      <guid>https://dev.to/alginte/monitoring-kafka-streams-applications-the-state-a-cluster-view-cannot-show-1cde</guid>
      <description>&lt;p&gt;A typical Kafka console reads the cluster: topics, partitions, consumer groups, committed offsets, lag. That is the right picture for most of what a console is for.&lt;/p&gt;

&lt;p&gt;A Kafka Streams application is a client of that cluster, and from the cluster's side it looks like what it is: a consumer group with members, some committed offsets, a few internal topics with a naming pattern.&lt;/p&gt;

&lt;p&gt;What the application is &lt;em&gt;doing&lt;/em&gt; is not on the cluster. Whether it is restoring a store from its changelog, and how far along it is. Whether a standby is keeping up with its active. Whether the threads Kafka reports as alive are serving the tasks assigned to them. How many times it has rebalanced since it started and how long the last one took. All of that is inside the process, in listeners and gauges the Kafka Streams API exposes to the application itself.&lt;/p&gt;

&lt;p&gt;The restore case is the sharpest. The consumer that replays a changelog is not a member of any group, commits no offset, and keeps its position in a local checkpoint file. So while an instance that started with an empty state directory replays millions of records before it can process a single new one, the cluster shows a stable group and, if input keeps arriving, a lag that grows for no reason it can show. The application is doing the one thing that decides when it will be available again, and the cluster has no way to say so.&lt;/p&gt;

&lt;p&gt;Anyone who has explained that silence to a colleague, or to a customer on an upgrade call, knows the shape of the problem: the application was fine, or was not, and the picture in front of everyone was of something else.&lt;/p&gt;

&lt;p&gt;We built Alginte around the stream because of this gap, on both sides of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Visibility into the stream's shape
&lt;/h2&gt;

&lt;p&gt;The first side was design time. A Kafka Streams topology is a graph, and the DSL, the describe output and every diagram in every article draw it as one, but the drawing never contains what the operators do. So we build the stream as the graph it is: in the &lt;a href="https://www.alginte.com/blog/visual-kafka-streams-builder/" rel="noopener noreferrer"&gt;modeler&lt;/a&gt; you draw the topology, node by node and edge by edge, and the boxes get their bodies.&lt;/p&gt;

&lt;p&gt;Each operator carries the expression it applies, and the editor shows a record that is really in the source topic and what that expression makes of it. The derived shape at every node is marked as inferred rather than asserted. You see the stream's shape, and what it does to your data, before it exists on the cluster. That is visibility into the stream as a thing you are building.&lt;/p&gt;

&lt;h2&gt;
  
  
  Visibility into the stream's operation
&lt;/h2&gt;

&lt;p&gt;The second side is run time, available since 0.11.0. Because the console runs the stream, it holds the Kafka Streams client and can read it directly: no agent, no scraping, nothing written to the cluster.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;State&lt;/strong&gt; tab on every deployed stream shows what the process knows. A restore banner appears only while a store replays its changelog, showing how many of the records it has replayed.&lt;/p&gt;

&lt;p&gt;The lifecycle is the state the console derives, not only the one Kafka reports, because the raw client state misleads in two known ways: the console reads &lt;code&gt;REBALANCING&lt;/code&gt; with every thread dead as &lt;code&gt;ERROR&lt;/code&gt;, and &lt;code&gt;RUNNING&lt;/code&gt; with assigned tasks served by nobody as &lt;code&gt;DEGRADED&lt;/code&gt;, with the shortfall in red.&lt;/p&gt;

&lt;p&gt;Every store partition is listed with the one figure that matters most, read by its role: records left to replay while restoring, distance behind the active on a standby, and the plain changelog lag at steady state. Threads, rebalances with their durations and an event timeline complete it. Beside all of it sit the cluster's own facts about the same stream, group state, committed offsets, changelog topics, so the two views can be read against each other.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F48ho3wpjm4q4ch6cswjy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F48ho3wpjm4q4ch6cswjy.png" alt="The State tab while a stream restores a 7.5 million record store after its state directory was wiped: a banner with the records replayed of the total and a progress bar, the Lifecycle card reading REBALANCING with 0 of 0 tasks served, the Rebalances, Cluster and Observer cards, and the store row with its lag counting down." width="800" height="1127"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A stream replaying 7.5 million changelog records after its state directory was wiped. The cluster's view of the same moment is a stable group and no sign of why.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The same page opens for a Kafka Streams application the console does not run, on any cluster it reads with the usual read-only rights. There it shows what the cluster can say, the group, the offsets and lag, the changelog topics and how far each reaches, and marks the rest as unseen: no lifecycle, because a group reads stable while threads die and we would rather show absence than invent one; no restore progress, no store sizes. Every figure the observer cannot see is a dash, never a zero. The docs describe &lt;a href="https://docs.alginte.com/streams/monitoring#which-view-you-get" rel="noopener noreferrer"&gt;which view you get&lt;/a&gt; in each case.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs6xue0nf0jn2m5tmaq75.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs6xue0nf0jn2m5tmaq75.png" alt="The observed page for a streams-protocol application this instance does not run: a banner saying the view is inferred from the cluster, an absent lifecycle, the Cluster card with group state and members, stores with end offsets only, source partitions with lag, and group state and epoch events." width="800" height="818"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;An application this console does not run: the cluster's facts, and everything it cannot see marked as absent rather than zero.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That is the depth the cluster allows, and it is where the idea points next. An application you run outside Alginte will be able to report its own state to the console, thread state, restore progress and store sizes included, through a small reporter library added to the application, one call before it starts. The console side of that, an endpoint that accepts what a runtime pushes, exists today; the library follows. And the streams the console runs need not run in the console's own process forever: a headless runtime that hosts them elsewhere and reports back to the same page is the shape we are building toward. Either way the page stays the same. What changes is how much of the stream it is allowed to see.&lt;/p&gt;

&lt;p&gt;Together the two sides are one idea. The modeler lets you see the stream you are building; the State tab lets you see the stream you are running. Same object, both ends of its life, with the console built around it. Martin Kleppmann &lt;a href="https://martin.kleppmann.com/2015/03/04/turning-the-database-inside-out.html" rel="noopener noreferrer"&gt;turned the database inside out&lt;/a&gt;; this is the console turned inside out, the stream in the middle and the console around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What seeing it does not change
&lt;/h2&gt;

&lt;p&gt;Seeing a restore does not make it faster. A large store still takes the minutes it takes to replay; what changes is that those minutes are a number on a page with a bar beside it instead of an unexplained silence. Seeing a dead thread does not bring it back; what changes is that the person who has to decide what to do can tell it from an ordinary rebalance.&lt;/p&gt;

&lt;h2&gt;
  
  
  See what it is doing
&lt;/h2&gt;

&lt;p&gt;A stateful stream application is a commitment: it holds state, it has to restore it, and it can fail in ways the cluster does not show. Run it where you can see it, so that its state is a page anyone on the team can read, not a question for whoever wrote it. That is what the console is for: a stream you can see while you design it, and a stream you can see while it runs.&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>kafkastreams</category>
      <category>java</category>
    </item>
    <item>
      <title>The compiler was never what you wanted</title>
      <dc:creator>alginte software</dc:creator>
      <pubDate>Fri, 11 Sep 2026 06:55:08 +0000</pubDate>
      <link>https://dev.to/alginte/the-compiler-was-never-what-you-wanted-1icd</link>
      <guid>https://dev.to/alginte/the-compiler-was-never-what-you-wanted-1icd</guid>
      <description>&lt;p&gt;You have an &lt;code&gt;orders&lt;/code&gt; topic on a Kafka cluster, its values encoded with &lt;a href="https://avro.apache.org/" rel="noopener noreferrer"&gt;Avro&lt;/a&gt; against a schema in the &lt;a href="https://docs.confluent.io/platform/current/schema-registry/index.html" rel="noopener noreferrer"&gt;Schema Registry&lt;/a&gt;. You want the orders worth more than fifty euros on a topic of their own, and you have decided to do it with &lt;a href="https://kafka.apache.org/documentation/streams/" rel="noopener noreferrer"&gt;Kafka Streams&lt;/a&gt; — a JVM library, your code, your deployment.&lt;/p&gt;

&lt;p&gt;The schema has five fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"record"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Order"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"namespace"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"com.alginte.demo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orderId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"customerId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"item"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"int"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"priceEur"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"double"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want one line of logic over them: &lt;code&gt;quantity * priceEur &amp;gt; 50&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is everything standing between that line and a topic of big orders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven steps
&lt;/h2&gt;

&lt;p&gt;The route &lt;a href="https://github.com/confluentinc/kafka-streams-examples/blob/v8.4.0-2/pom.xml#L426-L441" rel="noopener noreferrer"&gt;Confluent's own examples&lt;/a&gt; take, and many projects with them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Get the schema out of the registry&lt;/strong&gt; and into your repository as an &lt;code&gt;.avsc&lt;/code&gt; — or, if your team owns the schema in the repository and publishes it to the registry, the other way round. Whichever copy you call the source, there are now two that can disagree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the code generator&lt;/strong&gt; to your build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configure it&lt;/strong&gt; — source and output directories, and the string type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build&lt;/strong&gt;, producing &lt;code&gt;Order.java&lt;/code&gt; under &lt;code&gt;target/generated-sources&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the topology&lt;/strong&gt; against the generated class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Package&lt;/strong&gt; the application, with the schema, the class and the serde.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy&lt;/strong&gt; it somewhere that runs a JVM.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 2 and 3 are this, once — in Maven, though Gradle's equivalent has the same shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;plugin&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.apache.avro&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;avro-maven-plugin&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;1.12.1&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;executions&amp;gt;&amp;lt;execution&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;phase&amp;gt;&lt;/span&gt;generate-sources&lt;span class="nt"&gt;&amp;lt;/phase&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;goals&amp;gt;&amp;lt;goal&amp;gt;&lt;/span&gt;schema&lt;span class="nt"&gt;&amp;lt;/goal&amp;gt;&amp;lt;/goals&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;configuration&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;sourceDirectory&amp;gt;&lt;/span&gt;${project.basedir}/src/main/avro&lt;span class="nt"&gt;&amp;lt;/sourceDirectory&amp;gt;&lt;/span&gt;
      &lt;span class="c"&gt;&amp;lt;!-- without this, string fields generate as CharSequence, not String;
           Confluent's own examples set it for the same reason --&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;stringType&amp;gt;&lt;/span&gt;String&lt;span class="nt"&gt;&amp;lt;/stringType&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/configuration&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/execution&amp;gt;&amp;lt;/executions&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/plugin&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And step 5 is the part you actually wanted to 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="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Consumed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Serdes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;orderSerde&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
       &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getQuantity&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPriceEur&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
       &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"big-orders"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Produced&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Serdes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;orderSerde&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven steps, one of them the predicate. Two of them — adding the plugin and configuring it — you do that once.&lt;/p&gt;

&lt;p&gt;The filter is deliberately trivial — a real topology joins, aggregates and branches — but the seven steps are identical for twenty operators, because they are charged per project rather than per line of logic. And &lt;code&gt;application.id&lt;/code&gt;, &lt;code&gt;bootstrap.servers&lt;/code&gt;, the registry URL and your cluster's authentication are missing from the list because they are the price of running against a real cluster and registry, not the price of generating classes.&lt;/p&gt;

&lt;p&gt;Those two are not the cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then you want to use a new field
&lt;/h2&gt;

&lt;p&gt;Somebody adds &lt;code&gt;region&lt;/code&gt; to the schema. While it sits there unused you are fine — Avro resolves the writer's schema against yours, your generated class does not know the field exists, and nothing needs rebuilding. Schema evolution is doing its job.&lt;/p&gt;

&lt;p&gt;Then somebody asks for EU orders only.&lt;/p&gt;

&lt;p&gt;The change to your logic is one term: &lt;code&gt;&amp;amp;&amp;amp; order.getRegion().equals("EU")&lt;/code&gt;. The change to your project is steps 1, 4, 5, 6 and 7 — pull the new schema, regenerate, rewrite, repackage, redeploy.&lt;/p&gt;

&lt;p&gt;That is the actual price, and it is charged not per schema change but per schema change &lt;em&gt;you need&lt;/em&gt;. Which, over the life of a pipeline, is many of them: fields get added because somebody intends to use them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the seven steps buy
&lt;/h2&gt;

&lt;p&gt;They buy the compiler. &lt;code&gt;order.getQuantiy()&lt;/code&gt; does not compile. Rename a field, regenerate, and every stale use site turns red before anything runs. The IDE completes field names. Refactoring works.&lt;/p&gt;

&lt;p&gt;That is worth having. It is also answering a narrower question than the one you actually have.&lt;/p&gt;

&lt;p&gt;The compiler can tell you that &lt;code&gt;getQuantiy()&lt;/code&gt; is not a method. It cannot tell you whether &lt;code&gt;quantity * priceEur &amp;gt; 50&lt;/code&gt; is the predicate you meant, whether it matches any record on the topic, or whether the field you are multiplying holds what you think it holds. For that, the seven steps have one answer: deploy it and look.&lt;/p&gt;

&lt;p&gt;So the loop you are really in is not &lt;em&gt;edit, compile&lt;/em&gt;. It is &lt;strong&gt;edit, compile, package, deploy, produce a record, read the output&lt;/strong&gt; — and it costs the same whether the expression was right or wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tighter loop
&lt;/h2&gt;

&lt;p&gt;There is another shape for this, and it is the one we build: &lt;a href="https://www.alginte.com" rel="noopener noreferrer"&gt;Alginte&lt;/a&gt;, a browser-based topology builder that assembles it at runtime from topics you pick, instead of compiling it into an application you ship.&lt;/p&gt;

&lt;p&gt;Point it at &lt;code&gt;orders&lt;/code&gt;. The schema comes from the registry at runtime, the five field names arrive as completions, and the predicate is a string — written in &lt;a href="https://docs.spring.io/spring-framework/reference/core/expressions.html" rel="noopener noreferrer"&gt;SpEL&lt;/a&gt;, Spring's expression language, evaluated once per record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;value.get('quantity') * value.get('priceEur') &amp;gt; 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That expression is evaluated against a real record from the topic while it is being typed — a record off the partition rather than a mock or a fixture, with the answer beside it.&lt;/p&gt;

&lt;p&gt;When it is right, it deploys as a Kafka Streams topology: the same library, the same &lt;code&gt;KafkaStreams&lt;/code&gt; client, the same rebalances, state stores and changelog topics you would have got from the seven steps. No separate engine is involved. The only thing that changed is how the topology was written.&lt;/p&gt;

&lt;p&gt;No &lt;code&gt;.avsc&lt;/code&gt; in a repository, no plugin, no &lt;code&gt;target/generated-sources&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.alginte.com/blog/kafka-streams-without-codegen/" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3j50vees19rtv79nvjzt.webp" alt="The sample preview panel on a map node, showing a real record drawn from the orders topic on the in line. Typing value.get('item') makes the out line read Kettle; replacing the expression with value.get('quantity') * value.get('priceEur') makes the out line read 51. The editor reports no issues throughout and nothing is deployed." width="800" height="593"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The record on the &lt;code&gt;in&lt;/code&gt; line is a real one off &lt;code&gt;orders&lt;/code&gt; — &lt;code&gt;quantity&lt;/code&gt; 3, &lt;code&gt;priceEur&lt;/code&gt; 17. The &lt;code&gt;out&lt;/code&gt; line is whatever the expression currently returns, and it follows the expression as it is edited: &lt;code&gt;51&lt;/code&gt;, which is the number the &lt;code&gt;&amp;gt; 50&lt;/code&gt; filter is about to judge. Nothing has been deployed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Mistakes surface in the same place. A method that does not exist on the type the record actually carries is reported while it is being typed, naming the type — &lt;a href="https://www.alginte.com/blog/building-a-spel-editor/" rel="noopener noreferrer"&gt;the post about building that editor&lt;/a&gt; has that one on camera.&lt;/p&gt;

&lt;p&gt;That is not a compiler. It is the question the compiler could not answer, asked against real data, answered in seconds. And when somebody adds a sixth field and you want to use it, you type its name.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you give up
&lt;/h2&gt;

&lt;p&gt;The compiler.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;value.get('quantiy')&lt;/code&gt; is a valid expression — javac never sees it, so nothing rejects it before it runs. Rename across the project goes too, and the unit tests that constructed &lt;code&gt;Order&lt;/code&gt; objects.&lt;/p&gt;

&lt;p&gt;What you do not lose is the typo itself. On Avro a misspelt field throws at access — &lt;code&gt;Not a valid schema field: quantiy&lt;/code&gt; — so it fails in the editor and fails deployed, identically. That is not the compiler's guarantee, which is made before anything runs. It is the same failure arriving in both places at the same moment: a weaker promise, and a real one.&lt;/p&gt;

&lt;p&gt;Some of it returns in a different form — field names completed from the registry, so the typo is never offered; the expression checked against a real record as you type; the same failure surfacing in the editor that would surface in the deployed topology. A different guarantee, weaker in some places, stronger in one.&lt;/p&gt;

&lt;p&gt;What does not go is the review. A topology drawn here exports as JSON and imports back, so the thing a reviewer reads and the thing git keeps is a file, not a browser session — the build step is gone, the artifact is not.&lt;/p&gt;

&lt;p&gt;You can have the generic half of this without any of the rest. Nothing stops you writing a Java topology that reads &lt;a href="https://avro.apache.org/docs/1.12.0/api/java/org/apache/avro/generic/GenericRecord.html" rel="noopener noreferrer"&gt;&lt;code&gt;GenericRecord&lt;/code&gt;&lt;/a&gt; and never generates a class: steps 1 to 4 disappear, and so does the compiler, since &lt;code&gt;record.get("quantiy")&lt;/code&gt; is a string lookup that javac will not check either. What you are left with is the worst of both — no type safety, and steps 6 and 7 still in front of you.&lt;/p&gt;

&lt;p&gt;The generic types are not the point. The loop around them is: the completions, the record on screen, and the deploy that is a click rather than a pipeline.&lt;/p&gt;

&lt;p&gt;What that buys is a faster answer to the question you actually had.&lt;/p&gt;

&lt;p&gt;And if you would rather write the Java regardless — the stream is one part of a larger application, or your team works that way, or any of the other good reasons — the loop is still worth having first. Draw it here, get the expressions right against real records, then spend the seven steps on logic you already know works. That is a better use of an afternoon than finding out after the deploy.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This came out of building &lt;a href="https://www.alginte.com" rel="noopener noreferrer"&gt;Alginte&lt;/a&gt;, a visual Kafka Streams builder — self-hosted, free to run.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>java</category>
      <category>avro</category>
      <category>spring</category>
    </item>
    <item>
      <title>SpEL in Kafka Streams: from evaluator to editor</title>
      <dc:creator>alginte software</dc:creator>
      <pubDate>Wed, 09 Sep 2026 09:42:51 +0000</pubDate>
      <link>https://dev.to/alginte/spel-in-kafka-streams-from-evaluator-to-editor-6pn</link>
      <guid>https://dev.to/alginte/spel-in-kafka-streams-from-evaluator-to-editor-6pn</guid>
      <description>&lt;p&gt;A Kafka Streams application is two things: a graph, and the logic inside it.&lt;br&gt;
The DSL writes both in one chain — operators like &lt;code&gt;mapValues&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;join&lt;/code&gt; become the nodes, the chaining becomes the edges, and the lambda inside&lt;br&gt;
each operator is where the work happens:&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;record&lt;/span&gt; &lt;span class="nf"&gt;Enriched&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;customer&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;product&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;total&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;builder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Consumed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Serdes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;orderSerde&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
       &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mapValues&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Enriched&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
               &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCustomerId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
               &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getItem&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
               &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getQuantity&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPriceEur&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt;
       &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
       &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-enriched"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Produced&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Serdes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;enrichedSerde&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A graph is a picture, so we draw it: operators dragged onto a canvas and wired&lt;br&gt;
together.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fefk70ju38z3ximkrk7xr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fefk70ju38z3ximkrk7xr.png" alt="A Kafka Streams topology on a canvas: four boxes labelled Source (Stream), Map Values, Filter and Sink, wired top to bottom with arrows; the Map Values and Filter boxes each carry a red badge reading 1" width="800" height="593"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same four operators as the Java above, drawn rather than chained — one box&lt;br&gt;
each for &lt;code&gt;stream&lt;/code&gt;, &lt;code&gt;mapValues&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt; and &lt;code&gt;to&lt;/code&gt;. The two that need an&lt;br&gt;
expression each carry a red badge, and the step above counts them; the source and&lt;br&gt;
sink already have their topics. The graph is finished and the contents are not.&lt;br&gt;
Watching it built, and what happens after, is the subject of &lt;a href="https://www.alginte.com/blog/visual-kafka-streams-builder/" rel="noopener noreferrer"&gt;an earlier post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The body of an operator is not a picture. It is a small computation over one&lt;br&gt;
record — and it is the only part of the whole thing doing anything specific to&lt;br&gt;
your data. That part, and only that part, is what an expression language is&lt;br&gt;
for.&lt;/p&gt;

&lt;p&gt;Written as an expression, that body needs no class and no build around it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{'customer': value.get('customerId'),
 'product': value.get('item'),
 'total': value.get('quantity') * value.get('priceEur')}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filter's body is one line: &lt;code&gt;value.get('total') &amp;gt; 50&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Writing those bodies in Java is not the hard part. Finding out whether they are&lt;br&gt;
&lt;em&gt;right&lt;/em&gt; is the hard part: compile, package, point the app at a broker, produce&lt;br&gt;
a few records, read what came out. Every step is routine, the whole loop is&lt;br&gt;
slow, and it takes exactly as long whether the expression was correct or not.&lt;/p&gt;

&lt;p&gt;Evaluated as an expression against a real record while you type, that loop&lt;br&gt;
collapses. It widens the set of people who can experiment with stream&lt;br&gt;
processing, and it makes learning it cheap — the barrier was never the DSL, it&lt;br&gt;
was everything you had to stand up before the DSL would run.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why SpEL
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It is built for this.&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://docs.spring.io/spring-framework/reference/core/expressions.html" rel="noopener noreferrer"&gt;SpEL&lt;/a&gt; has driven routers and transformers in Spring Integration for years: a language built to take expressions at runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is already there.&lt;/strong&gt; In a Spring application it arrives on the classpath with the framework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It can be contained.&lt;/strong&gt; A Kafka Streams lambda is compiled by whoever owns the deployment — your classpath, your risk. An expression typed into a browser arrives at runtime and runs in a process the author may not own. So containment comes first: a restricted evaluation context, with no type references, no constructors and no bean resolution. Spring provides that deliberately, which is what makes the whole approach possible — though the honest word is &lt;em&gt;containment&lt;/em&gt; rather than safety.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is a tokenizer to reuse.&lt;/strong&gt; That expression needs a real editor around&lt;br&gt;
it, and what that editor shows has to agree with what will actually run. The&lt;br&gt;
surest way is to tokenize with the same code the evaluator uses. SpEL has one.&lt;br&gt;
Getting hold of it is where this gets interesting.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where the knowledge comes from
&lt;/h2&gt;

&lt;p&gt;In the Java above, the knowledge came from types — a class on the classpath, there at build time, whether generated from a schema or written by hand.&lt;/p&gt;

&lt;p&gt;An expression editor in a browser has none of it. No compile step, no class to consult — the only things that know anything about the data are the &lt;a href="https://docs.confluent.io/platform/current/schema-registry/index.html" rel="noopener noreferrer"&gt;Schema Registry&lt;/a&gt; and the records sitting on the topic. Everything the editor offers has to come from those two, while someone is typing.&lt;/p&gt;

&lt;p&gt;Drawing the graph is the easy half, and on its own it buys very little: a&lt;br&gt;
canvas with a bare text box for the logic trades away the help the IDE was giving and offers nothing in its place. Separating the two only pays off if the expression half gets something of its own.&lt;/p&gt;

&lt;p&gt;It will not be an IDE — but it has what an IDE cannot: the data. An IDE knows &lt;code&gt;priceEur&lt;/code&gt; is a number; it does not know this record's is 17. One expression, evaluated against a real record as you type, is closer to a REPL than to autocompletion.&lt;/p&gt;

&lt;p&gt;The editor is &lt;a href="https://microsoft.github.io/monaco-editor/" rel="noopener noreferrer"&gt;Monaco&lt;/a&gt;, the&lt;br&gt;
component behind VS Code, so it already knows how to highlight tokens, show a completion list and put a marker on a line. What it knows nothing about is SpEL, or your data.&lt;/p&gt;
&lt;h2&gt;
  
  
  Completions from types, and from content
&lt;/h2&gt;

&lt;p&gt;Completions come from two different places, and the difference is worth keeping visible.&lt;/p&gt;

&lt;p&gt;At a source node — reading a topic whose values have a registered schema — the completion list is the schema's own fields with their declared types. That is a contract: something else asserted it, and it holds for every record on the topic.&lt;/p&gt;

&lt;p&gt;One operator downstream, after a &lt;code&gt;mapValues&lt;/code&gt;, there is no schema any more. The value is whatever the expression returned. The fields offered there are derived by evaluating that expression against a real record and looking at what came out — and the UI marks them &lt;code&gt;(inferred)&lt;/code&gt;, because that is what they are: an observation of the sample in hand, not a promise. Draw another record and they may differ; if the data is heterogeneous, no single sample will say so.&lt;/p&gt;

&lt;p&gt;Inference is not the last word. A node can declare its output in the same&lt;br&gt;
vocabulary a source uses — a serde per side, optionally a registered subject — and a node that declares one becomes a fresh start: its completions are a contract again. What you cannot do is type a list of field names, and that is deliberate. Fields belong to schemas, and a shape worth describing is usually a shape worth registering.&lt;/p&gt;

&lt;p&gt;The same dropdown at two different nodes, two kinds of knowledge, labelled differently. It would be easy to present both as "fields", and it would be wrong.&lt;/p&gt;

&lt;p&gt;An empty topic is not a dead end. With no record to draw, the sample is&lt;br&gt;
randomly generated from the registered schema instead, and the preview says which one it is holding. That keeps the editor working before any data exists — while being the weaker evidence of the two, since it demonstrates the shape rather than your data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcm6e10o6vc1a4zbs6xjh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcm6e10o6vc1a4zbs6xjh.png" alt="A SpEL completion dropdown in the filter node, offering get('customer'), get('product') and get('total'), with the type hint reading " width="800" height="593"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One operator after the &lt;code&gt;mapValues&lt;/code&gt;, the filter offers &lt;code&gt;customer&lt;/code&gt;, &lt;code&gt;product&lt;/code&gt; and &lt;code&gt;total&lt;/code&gt; — none of which exist in any schema. They are there because the expression on the node behind was evaluated against a real record, and the type hint says so. In the source node the same list would have come from the registry instead. &lt;a href="https://www.alginte.com/blog/visual-kafka-streams-builder/" rel="noopener noreferrer"&gt;The earlier post has this as a&lt;br&gt;
clip&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What an expression receives, and returns
&lt;/h2&gt;

&lt;p&gt;All of that depends on what the deserializer actually hands over — and that is rarely the Java type you would guess from the schema. &lt;strong&gt;Avro&lt;/strong&gt; gives you a &lt;code&gt;GenericRecord&lt;/code&gt;, and a field the schema calls a string does not arrive as a &lt;code&gt;String&lt;/code&gt;: it arrives as &lt;code&gt;org.apache.avro.util.Utf8&lt;/code&gt;, a &lt;code&gt;CharSequence&lt;/code&gt; wrapping the raw bytes. Equality still works — &lt;code&gt;value.get('item') == 'Kettle'&lt;/code&gt; matches on the orders stream — because SpEL compares &lt;code&gt;CharSequence&lt;/code&gt; content. &lt;code&gt;.contains(...)&lt;/code&gt; does not, because it is a &lt;code&gt;String&lt;/code&gt; method that &lt;code&gt;CharSequence&lt;/code&gt; never declared, so it needs a &lt;code&gt;.toString()&lt;/code&gt; first. That is the kind of detail that lives in serde Javadoc rather than anywhere you would think to look, and&lt;br&gt;
it costs an afternoon the first time.&lt;/p&gt;



&lt;p&gt;&lt;em&gt;Typing value.get('item').contains('Kettle') into a map expression; the editor reports one issue; opening the status shows EL1004E, method contains(java.lang.String) cannot be found on type org.apache.avro.util.Utf8; adding .toString() clears it to no issues.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same afternoon, spent in under thirty seconds. &lt;code&gt;.contains(...)&lt;/code&gt; on an Avro string, the message naming &lt;code&gt;org.apache.avro.util.Utf8&lt;/code&gt; rather than "invalid expression", and &lt;code&gt;.toString()&lt;/code&gt; clearing it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Types are one half of what an editor can offer; the record is the other. Each node's sample preview puts that record in and the evaluated output out, side by side, re-running as the expression changes — so what an expression actually returns is on screen while it is still being written, against the data it will run on.&lt;/p&gt;



&lt;p&gt;&lt;em&gt;The Sample preview panel on a map node, showing a real record drawn from the orders topic on the in line. Typing value.get('item') makes the out line read Kettle; replacing the expression with value.get('quantity') * value.get('priceEur') makes the out line read 51. The editor reports no issues throughout and nothing is deployed.&lt;/em&gt; &lt;em&gt;&lt;code&gt;in&lt;/code&gt; is a real record off &lt;code&gt;orders&lt;/code&gt;; &lt;code&gt;out&lt;/code&gt; is whatever the expression returns, and it follows the expression as that changes — &lt;code&gt;value.get('item')&lt;/code&gt; gives &lt;code&gt;"Kettle"&lt;/code&gt;, then the intro's own &lt;code&gt;quantity * priceEur&lt;/code&gt; gives &lt;code&gt;51&lt;/code&gt;, the value the&lt;br&gt;
&lt;code&gt;&amp;gt; 50&lt;/code&gt; filter downstream exists to judge. Nothing has been deployed, and the editor never claims either answer is correct. It only makes them impossible to miss.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Avro's type names leak into error messages too, which the next section has to deal with.&lt;/p&gt;
&lt;h2&gt;
  
  
  Errors a user can act on
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SpelCompilerMode.IMMEDIATE&lt;/code&gt; exists to make expressions faster by compiling them to bytecode. We use it in the &lt;strong&gt;validator&lt;/strong&gt;, as a type checker. Compiling forces type resolution to happen while someone is still typing, which is exactly when you want to hear that a method does not exist on the type you actually have.&lt;/p&gt;

&lt;p&gt;Catching it early, at the right line and column, is half of a diagnostic. The other half is what it says.&lt;/p&gt;

&lt;p&gt;SpEL's messages assume a developer who knows the JVM types involved. Ours are read by someone who has never heard of &lt;code&gt;GenericData$Record&lt;/code&gt; and should not have to. So the validator rewrites them.&lt;/p&gt;

&lt;p&gt;An indexing failure — someone reasonably tries &lt;code&gt;value['item']&lt;/code&gt; — becomes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;indexing with &lt;code&gt;['...']&lt;/code&gt; is not supported on schema-backed records — use&lt;br&gt;
&lt;code&gt;get('fieldName')&lt;/code&gt; instead (the completions suggest it). A deployed topology&lt;br&gt;
fails the same way on Avro / Protobuf values.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fix, a pointer at where it is offered, and a promise about the&lt;br&gt;
runtime. That last clause matters most: it tells the reader this is not an editor quirk to be worked around, because the deployed topology fails&lt;br&gt;
identically.&lt;/p&gt;

&lt;p&gt;Everything else gets a blunter rule: any message mentioning an internal sample type has that type replaced with "the sample record". Someone who writes a bad expression should learn what they did, not what class the deserializer happened to return. A few cases are worth naming precisely and get hand-written messages, like &lt;em&gt;"Invalid map access syntax. Use &lt;code&gt;value['key']&lt;/code&gt; without a dot."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The rewriting covers Avro's generic types, Jackson's nodes and — since 0.10.0&lt;br&gt;
— Protobuf's message types, which became reachable the moment &lt;code&gt;get('field')&lt;/code&gt;&lt;br&gt;
started evaluating on them.&lt;/p&gt;
&lt;h2&gt;
  
  
  The tokenizer you cannot reach
&lt;/h2&gt;

&lt;p&gt;An editor needs three things an evaluator does not provide: syntax&lt;br&gt;
highlighting, completions, and errors at the right line and column. All three start from tokens.&lt;/p&gt;

&lt;p&gt;Spring has a tokenizer — the one its own parser uses. It is package-private.&lt;br&gt;
Against Spring Framework 7.0.8:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class org.springframework.expression.spel.standard.Tokenizer {
  public org.springframework.expression.spel.standard.Tokenizer(java.lang.String);
  public java.util.List&amp;lt;org.springframework.expression.spel.standard.Token&amp;gt; process();
  static {};
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The constructor and &lt;code&gt;process()&lt;/code&gt; are &lt;strong&gt;already public&lt;/strong&gt;. The &lt;em&gt;class&lt;/em&gt; is not, so&lt;br&gt;
from outside the package there is no way to call them.&lt;/p&gt;

&lt;p&gt;The public surface of that package is &lt;code&gt;SpelExpressionParser&lt;/code&gt;, &lt;code&gt;SpelExpression&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;SpelCompiler&lt;/code&gt;. Parsing, evaluation, compilation — no tokenization.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SpelExpression&lt;/code&gt; does expose a parse tree — &lt;code&gt;getAST()&lt;/code&gt; returns &lt;code&gt;SpelNode&lt;/code&gt;s with&lt;br&gt;
start and end positions, which looks like exactly the supported route we missed.&lt;br&gt;
But a parser needs a valid expression, and an editor spends most of its life&lt;br&gt;
holding an invalid one. &lt;code&gt;value.&lt;/code&gt; is precisely when completions have to fire, and&lt;br&gt;
it does not parse; nor does this post's own map literal before its closing&lt;br&gt;
brace. Both tokenize.&lt;/p&gt;

&lt;p&gt;So we copied &lt;code&gt;Tokenizer&lt;/code&gt;, &lt;code&gt;Token&lt;/code&gt; and &lt;code&gt;TokenKind&lt;/code&gt; from&lt;br&gt;
&lt;a href="https://github.com/spring-projects/spring-framework/tree/v7.0.8/spring-expression/src/main/java/org/springframework/expression/spel/standard" rel="noopener noreferrer"&gt;Spring's &lt;code&gt;spel.standard&lt;/code&gt; package&lt;/a&gt;,&lt;br&gt;
under Apache-2.0, into a package of the same name in our own tree — which is&lt;br&gt;
what puts those package-private types within reach.&lt;/p&gt;

&lt;p&gt;All of it exists to enable one line:&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="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Token&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tokens&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;Tokenizer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputData&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;process&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What we add on top is thin, and most of it is a coordinate transform. Spring&lt;br&gt;
reports absolute character offsets; editors want line and column. So each&lt;br&gt;
&lt;code&gt;Token&lt;/code&gt; becomes a &lt;code&gt;SpELToken(start, end, line, charPositionInLine, type,&lt;br&gt;
modifiers)&lt;/code&gt; — the shape Monaco and LSP want — and goes over a WebSocket to the&lt;br&gt;
browser. There they are served through a &lt;code&gt;DocumentSemanticTokensProvider&lt;/code&gt;,&lt;br&gt;
which is the highlighting: every colour in the expression comes from the same&lt;br&gt;
tokenizer the evaluator parses with, so what the editor shows and what runs&lt;br&gt;
cannot drift apart.&lt;/p&gt;

&lt;p&gt;That is the whole of it. Spring's authors had already written the hard part;&lt;br&gt;
nothing was missing but a way to call it. What we carry is not&lt;br&gt;
complexity — it is their file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SpEL is not Java.&lt;/strong&gt; Broadening who can write a transformation costs the type&lt;br&gt;
system, the IDE, refactoring, unit tests, and the entire library ecosystem, in&lt;br&gt;
exchange for a string in a text box. That trade is defensible for &lt;em&gt;"what does&lt;br&gt;
this do to my records"&lt;/em&gt; and indefensible for a thousand-line pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The editor cannot catch a wrong answer.&lt;/strong&gt; It catches expressions that are&lt;br&gt;
&lt;em&gt;invalid&lt;/em&gt; — a method that does not exist, a syntax it cannot parse. One that is&lt;br&gt;
type-correct and simply always false passes every check. The preview above will&lt;br&gt;
faithfully show you the wrong answer; nothing will tell you that is what it&lt;br&gt;
is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Package-private internals carry no compatibility promise.&lt;/strong&gt; Our copy of the&lt;br&gt;
tokenizer can break between Spring versions with no deprecation cycle, and that&lt;br&gt;
is our problem rather than Spring's. We took the risk knowingly; it is still a&lt;br&gt;
risk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Protobuf was opaque when this was first published&lt;/strong&gt; — a &lt;code&gt;DynamicMessage&lt;/code&gt;&lt;br&gt;
offers no field access by name — and unlike the rest of this, it was ours to&lt;br&gt;
fix. It shipped in &lt;a href="https://www.alginte.com/releases/0.10.0/" rel="noopener noreferrer"&gt;0.10.0&lt;/a&gt;: one SpEL &lt;code&gt;MethodResolver&lt;/code&gt; gives&lt;br&gt;
&lt;code&gt;get('field')&lt;/code&gt; the same meaning on Protobuf as on Avro and JSON Schema,&lt;br&gt;
registered at all three places an evaluation context is built, so the editor&lt;br&gt;
and the runtime cannot disagree about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is there a better way to do this?
&lt;/h2&gt;

&lt;p&gt;That is our workaround, and we are not claiming it is the right answer.&lt;br&gt;
Package-private is a deliberate choice — internals stay private precisely so&lt;br&gt;
they can change — and we went around it.&lt;/p&gt;

&lt;p&gt;So the question is genuine. If there is a supported path to a SpEL token stream&lt;br&gt;
that we missed, we would much rather be told than go on carrying a copy of&lt;br&gt;
somebody else's tokenizer. And if there is not, we are probably not the last&lt;br&gt;
people who will want one.&lt;/p&gt;

&lt;p&gt;Either way the pieces are all there: the tokenizer works, the errors carry&lt;br&gt;
positions, the compiler doubles as a type checker. We would just like a&lt;br&gt;
supported way to hold one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updates
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2026-09-07&lt;/strong&gt; — when first published (2026-08-31), this post said Protobuf
values were opaque to the editor and that the fix was on the list. It shipped
in &lt;a href="https://www.alginte.com/releases/0.10.0/" rel="noopener noreferrer"&gt;0.10.0&lt;/a&gt; on 2026-09-03: &lt;code&gt;get('field')&lt;/code&gt; evaluates on
Protobuf in the editor and deployed, and Protobuf's type names joined the
message rewriting. The two passages above were corrected to say so.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This came out of building &lt;a href="https://www.alginte.com" rel="noopener noreferrer"&gt;Alginte&lt;/a&gt;, a visual Kafka&lt;br&gt;
Streams builder — self-hosted, free to run.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>kafkastreams</category>
      <category>spring</category>
      <category>java</category>
    </item>
    <item>
      <title>Kafka Streams topologies you can draw and run</title>
      <dc:creator>alginte software</dc:creator>
      <pubDate>Tue, 08 Sep 2026 13:33:25 +0000</pubDate>
      <link>https://dev.to/alginte/kafka-streams-topologies-you-can-draw-and-run-49j7</link>
      <guid>https://dev.to/alginte/kafka-streams-topologies-you-can-draw-and-run-49j7</guid>
      <description>&lt;p&gt;You have an &lt;code&gt;orders&lt;/code&gt; topic. Each record carries a customer, an item, a&lt;br&gt;
quantity, and a price. You want the big ones — total over 50 — in a topic of&lt;br&gt;
their own, with that total already calculated.&lt;/p&gt;

&lt;p&gt;In Kafka Streams, that is a &lt;code&gt;mapValues&lt;/code&gt; and a &lt;code&gt;filter&lt;/code&gt;: a dozen lines of real&lt;br&gt;
logic.&lt;/p&gt;

&lt;p&gt;Getting them to run is a different size of job: a build file, a serde&lt;br&gt;
configuration, a jar, somewhere to put it, and a redeploy every time you want&lt;br&gt;
to check if your expression was right about the data. The work is small. The&lt;br&gt;
apparatus around it is not — and none of it tells you anything until the whole&lt;br&gt;
thing is up.&lt;/p&gt;

&lt;p&gt;That distance is the subject here. Not that Kafka Streams is hard, because it&lt;br&gt;
is not — but that the trip from &lt;em&gt;knowing what you want&lt;/em&gt; to &lt;em&gt;watching it&lt;br&gt;
happen&lt;/em&gt; is longer than a dozen lines of logic deserves.&lt;/p&gt;

&lt;p&gt;Start with what Kafka Streams already gives you — most of the picture is&lt;br&gt;
there, and the missing part is smaller than it looks. Deploy a topology and&lt;br&gt;
ask it to describe itself; here is a real one: four operators, reading&lt;br&gt;
&lt;code&gt;orders&lt;/code&gt; and writing &lt;code&gt;orders-enriched&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Topologies:
   Sub-topology: 0
    Source: Orders-src (topics: [orders])
      --&amp;gt; order-total-enrich
    Processor: order-total-enrich (stores: [])
      --&amp;gt; big-orders-bigOnly
      &amp;lt;-- Orders-src
    Processor: big-orders-bigOnly (stores: [])
      --&amp;gt; Big-Orders-sink
      &amp;lt;-- order-total-enrich
    Sink: Big-Orders-sink (topic: orders-enriched)
      &amp;lt;-- big-orders-bigOnly
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a graph. Nodes, directed edges, a topological order. The DSL&lt;br&gt;
constructs one; &lt;code&gt;describe()&lt;/code&gt; prints one, and it is what anyone sketches on a&lt;br&gt;
whiteboard when explaining a pipeline to someone else.&lt;/p&gt;

&lt;p&gt;Look at what it describes. &lt;code&gt;order-total-enrich&lt;/code&gt; is a processor: its name, its&lt;br&gt;
position, what feeds it, and what it feeds. What it &lt;strong&gt;does&lt;/strong&gt; lives somewhere&lt;br&gt;
else — the lambda inside &lt;code&gt;mapValues&lt;/code&gt;, the entire reason that node exists, is in&lt;br&gt;
your source rather than in the description. A topology description captures&lt;br&gt;
shape, and captures it completely; behaviour was never its job.&lt;/p&gt;

&lt;p&gt;What falls between them is the interesting part. A visual representation of a&lt;br&gt;
Kafka Streams application — ours included, until recently — tends to be a&lt;br&gt;
picture of the shape. Fill the boxes with real bodies and the picture stops&lt;br&gt;
being documentation of the program and becomes the program — something you can&lt;br&gt;
draw and run.&lt;/p&gt;
&lt;h2&gt;
  
  
  The data is already there, and so is its schema
&lt;/h2&gt;

&lt;p&gt;When records are produced through a schema registry — as these were — each one&lt;br&gt;
carries the id of the schema it was written against. So the shape is described&lt;br&gt;
before any topology exists, and a record and its schema are connected before&lt;br&gt;
anyone draws anything — you can watch it work.&lt;/p&gt;



&lt;p&gt;&lt;em&gt;Reading the orders topic with a Void key deserializer and Avro value deserializer, expanding one record to its decoded JSON, then following the Schema tab through to the orders-value subject.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Note what the value deserializer is &lt;em&gt;not&lt;/em&gt; asked for: a subject. The decode&lt;br&gt;
succeeding &lt;em&gt;is&lt;/em&gt; the connection; the schema tab afterwards only names it.&lt;/p&gt;

&lt;p&gt;(&lt;code&gt;Void&lt;/code&gt; on the key side because these records genuinely have null keys —&lt;br&gt;
anything else renders noise where there is nothing.)&lt;/p&gt;
&lt;h2&gt;
  
  
  What goes in the boxes
&lt;/h2&gt;

&lt;p&gt;So what should &lt;code&gt;order-total-enrich&lt;/code&gt; produce? A customer, a product, and that&lt;br&gt;
total. In a Java project, you would write that as a lambda and compile it; here&lt;br&gt;
it is an expression, and this is the whole of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{'customer': value.get('customerId'),
 'product':  value.get('item'),
 'total':    value.get('quantity') * value.get('priceEur')}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Evaluated once per record, inside the same JVM that is running the topology.&lt;br&gt;
The parse happens up front, and the parsed expression is cached, so the&lt;br&gt;
per-record cost is evaluation, not parsing.&lt;/p&gt;

&lt;p&gt;Reaching for an expression language rather than something more powerful is not&lt;br&gt;
a simplification — it follows from &lt;em&gt;who writes it&lt;/em&gt;. A Kafka Streams lambda is&lt;br&gt;
compiled by whoever owns the deployment, so trust is implicit and never has to&lt;br&gt;
be examined. Move authoring into a console and the author is a user: the code&lt;br&gt;
arrives at runtime, from outside, and the process it runs in is yours. The&lt;br&gt;
obvious alternative — letting people upload compiled code — answers the same&lt;br&gt;
need by handing a stranger arbitrary execution inside that process.&lt;/p&gt;

&lt;p&gt;We use &lt;a href="https://docs.spring.io/spring-framework/reference/core/expressions.html" rel="noopener noreferrer"&gt;Spring's SpEL&lt;/a&gt;&lt;br&gt;
for this. It is embeddable, it has an evaluation context you can restrict&lt;br&gt;
deliberately rather than by accident, and in a Spring application it is already&lt;br&gt;
on the classpath.&lt;/p&gt;

&lt;p&gt;Which makes the sandbox a precondition rather than a feature. Expressions&lt;br&gt;
evaluate against a restricted context: no type references, no constructors, no&lt;br&gt;
bean resolution. The honest word for that is &lt;em&gt;containment&lt;/em&gt; — we can tell you&lt;br&gt;
exactly what is blocked, and we cannot prove that nobody will find a way&lt;br&gt;
past it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drawing it, and watching where the type survives
&lt;/h2&gt;

&lt;p&gt;Now the graph. Four operators dragged out, wired, named, and pointed at&lt;br&gt;
&lt;code&gt;orders&lt;/code&gt;:&lt;/p&gt;



&lt;p&gt;&lt;em&gt;Building the topology on a canvas: dragging source, mapValues, filter and sink nodes, wiring them, naming them, and binding the source to the orders topic.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The beat worth pausing on is the source's serde section, about a third of the&lt;br&gt;
way in. The &lt;strong&gt;value&lt;/strong&gt; side fills itself in — &lt;code&gt;Avro&lt;/code&gt;, subject &lt;code&gt;orders-value&lt;/code&gt; —&lt;br&gt;
because a registered subject makes the value type a contract. The &lt;strong&gt;key&lt;/strong&gt; side&lt;br&gt;
stays empty until you say so, because there is no &lt;code&gt;orders-key&lt;/code&gt; subject and nothing&lt;br&gt;
to derive it from.&lt;/p&gt;

&lt;p&gt;From the source down, each operator either preserves the value or replaces it.&lt;br&gt;
&lt;code&gt;filter&lt;/code&gt;, &lt;code&gt;peek&lt;/code&gt;, &lt;code&gt;repartition&lt;/code&gt;, &lt;code&gt;toStream&lt;/code&gt; pass it along unchanged; &lt;code&gt;mapValues&lt;/code&gt;&lt;br&gt;
ends the contract, because after it the value is whatever your expression&lt;br&gt;
returned. That is not a property of any tool — it is how the DSL works, and it&lt;br&gt;
is the rule anything deriving types has to follow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop
&lt;/h2&gt;

&lt;p&gt;With a contract at the source and a rule for how it propagates, the editor can&lt;br&gt;
say something useful while you type — and a real record underneath answers&lt;br&gt;
back. Type, look, adjust: that is the loop.&lt;/p&gt;



&lt;p&gt;&lt;em&gt;Writing a mapValues expression with completions drawn from the registry schema, watching a real record flow through the preview, then a filter whose completions offer the computed total field.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two different kinds of knowledge appear in that clip, and the difference&lt;br&gt;
matters.&lt;/p&gt;

&lt;p&gt;In the &lt;code&gt;mapValues&lt;/code&gt; node, the completions are the &lt;strong&gt;schema's own fields&lt;/strong&gt; —&lt;br&gt;
&lt;code&gt;customerId&lt;/code&gt;, &lt;code&gt;item&lt;/code&gt;, &lt;code&gt;quantity&lt;/code&gt;, &lt;code&gt;priceEur&lt;/code&gt;, with their declared types. That&lt;br&gt;
is a contract; it came from the registry.&lt;/p&gt;

&lt;p&gt;One node downstream, the filter offers &lt;code&gt;total&lt;/code&gt;. There is no &lt;code&gt;total&lt;/code&gt; in any&lt;br&gt;
schema, in any topic, anywhere. It exists because the expression above computed&lt;br&gt;
it, and the shape was observed by evaluating that expression against a real&lt;br&gt;
record. The UI marks fields like this &lt;code&gt;(inferred)&lt;/code&gt; for exactly that reason: it&lt;br&gt;
is one record's observation, not a promise. If your data is heterogeneous, one&lt;br&gt;
sample will not tell you so.&lt;/p&gt;

&lt;p&gt;The record underneath is pulled from the topic, not fabricated. You can draw a&lt;br&gt;
different one, roll a fresh one from the schema, or type the case you are&lt;br&gt;
actually worried about — the order with the null field, the quantity nobody&lt;br&gt;
expected — and watch what your expression does to it. Whatever you put in&lt;br&gt;
there, every downstream node reads the same record, so one story flows through&lt;br&gt;
the whole chain.&lt;/p&gt;

&lt;p&gt;Inference reads one execution path of one sample, so there are nodes where it&lt;br&gt;
will be wrong and nodes where it has nothing to offer. Any node can &lt;strong&gt;declare&lt;br&gt;
its output&lt;/strong&gt; instead — a serde per side, and a subject if that shape is&lt;br&gt;
registered. A node that declares one becomes a fresh start: the walk resumes&lt;br&gt;
from it, and everything below reads what you stated rather than what was&lt;br&gt;
observed. What you declare beats what was inferred, and both beat a guess.&lt;/p&gt;

&lt;p&gt;It is deliberately not a free-form field list. Fields belong to schemas, and a&lt;br&gt;
shape worth describing is usually a shape worth registering — so declaring a&lt;br&gt;
node's output asks the same question binding a source does, and takes a&lt;br&gt;
contract if one exists.&lt;/p&gt;

&lt;p&gt;One thing the completions cannot warn you about: the values are runtime&lt;br&gt;
objects, not the Java types their names suggest. A field the schema calls a&lt;br&gt;
string does not arrive as a &lt;code&gt;String&lt;/code&gt; — it arrives as &lt;code&gt;org.apache.avro.util.Utf8&lt;/code&gt;,&lt;br&gt;
a &lt;code&gt;CharSequence&lt;/code&gt; wrapping the raw bytes. Equality still behaves, because SpEL&lt;br&gt;
compares &lt;code&gt;CharSequence&lt;/code&gt; content, so &lt;code&gt;value.get('item') == 'Kettle'&lt;/code&gt; is true when&lt;br&gt;
you expect it to be. But &lt;code&gt;.contains(...)&lt;/code&gt; is a &lt;code&gt;String&lt;/code&gt; method that&lt;br&gt;
&lt;code&gt;CharSequence&lt;/code&gt; does not declare, so it needs a &lt;code&gt;.toString()&lt;/code&gt; first. It is the&lt;br&gt;
kind of detail that lives in serde Javadoc rather than anywhere you would think&lt;br&gt;
to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the loop can be trusted
&lt;/h2&gt;

&lt;p&gt;All of which is worth exactly nothing if the answers differ from what the&lt;br&gt;
topology does once it is deployed. A preview that disagrees with the runtime&lt;br&gt;
teaches you something false and lets you find out at deploy time — worse than&lt;br&gt;
showing you nothing at all.&lt;/p&gt;

&lt;p&gt;That is not something you can check from the outside, so here is how we check&lt;br&gt;
it. Every expression in the test suite runs through all three surfaces. Two you&lt;br&gt;
have already seen: the editor's validator, and the preview under the sample.&lt;br&gt;
The third is the topology itself — built by the same code that builds a&lt;br&gt;
deployed one, running the same serdes, fed a record, and asked what came out of the&lt;br&gt;
sink. That leg runs in-process rather than against a broker, which is the only&lt;br&gt;
way to do it per expression at test speed, but nothing about the expression's&lt;br&gt;
path is simulated.&lt;/p&gt;

&lt;p&gt;All three have to agree: same success or failure, same output once serialized,&lt;br&gt;
same verdict from a filter. The cases run across Avro, Protobuf, and JSON&lt;br&gt;
Schema, and inside those, nested records, unions, arrays, enums, and logical&lt;br&gt;
types — and the set only grows: every disagreement we find becomes a case&lt;br&gt;
before it becomes a fix. They are usually small and specific, like arithmetic&lt;br&gt;
on an Avro field that the editor flagged and the runtime handled perfectly&lt;br&gt;
well.&lt;/p&gt;

&lt;h2&gt;
  
  
  It runs
&lt;/h2&gt;

&lt;p&gt;Configuration, submit, and the deployed topology with live per-node numbers:&lt;/p&gt;



&lt;p&gt;&lt;em&gt;Setting a dead-letter topic in the stream configuration, submitting the topology, and watching per-node throughput badges appear on the deployed canvas.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Those badges exist because of a naming decision made much earlier. Runtime&lt;br&gt;
metrics are tagged with &lt;em&gt;operator names&lt;/em&gt;, so joining them back to the picture&lt;br&gt;
means knowing what each operator is called — and Kafka's own answer,&lt;br&gt;
&lt;code&gt;KSTREAM-MAPVALUES-0000000003&lt;/code&gt;, is generated from graph position and shifts the&lt;br&gt;
moment you insert an operator upstream. The names in this topology are derived&lt;br&gt;
from the nodes instead, which is why the &lt;code&gt;describe()&lt;/code&gt; output at the top of this&lt;br&gt;
post reads &lt;code&gt;order-total-enrich&lt;/code&gt;. That is worth more than legibility: those names&lt;br&gt;
also land in JMX, in log lines, and in the internal topic names on your&lt;br&gt;
cluster.&lt;/p&gt;

&lt;p&gt;Read them carefully, though. The source reports an exact count; everything&lt;br&gt;
downstream reports an &lt;em&gt;attributed&lt;/em&gt; one, taken from the surrounding subtopology.&lt;br&gt;
For a linear chain, the number is correct, but it cannot tell you which records&lt;br&gt;
survived a predicate — the filter's effect shows up on the sink topic's&lt;br&gt;
offsets, not on the node's badge.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is for
&lt;/h2&gt;

&lt;p&gt;There is a stretch of work at the start of any pipeline that is mostly&lt;br&gt;
questions: what is actually in these records, does this field mean what its&lt;br&gt;
name suggests, what does my transformation do to the awkward ones. Answering&lt;br&gt;
those in a project means writing an app to find out. Answering them here takes&lt;br&gt;
the time it takes to type an expression, and the answers come from records that&lt;br&gt;
are really on the topic.&lt;/p&gt;

&lt;p&gt;The loop also explains an architectural choice. Most Kafka consoles are&lt;br&gt;
readers: they ask the cluster questions — what topics exist, where are the&lt;br&gt;
consumer groups, what do the metrics say — and never execute anything&lt;br&gt;
themselves. Evaluating your expression against a real record and showing you&lt;br&gt;
what came out is not a question you can ask Kafka. It means building the&lt;br&gt;
topology and running it, so a Kafka Streams application deployed from this&lt;br&gt;
console runs inside the console's own process.&lt;/p&gt;

&lt;p&gt;Having everything in one place pays off after the deploy, too. The sink topic&lt;br&gt;
is a topic like any other: the browser that showed you &lt;code&gt;orders&lt;/code&gt; in the first&lt;br&gt;
clip will show you &lt;code&gt;orders-enriched&lt;/code&gt;, so you can read what your expression&lt;br&gt;
actually produced — not just how many records got there. That is the round trip&lt;br&gt;
closed: the schema you started from, the records you tested against, and the&lt;br&gt;
output you caused, all reachable without leaving. Consumer groups, connectors,&lt;br&gt;
and ksqlDB sit on the same sidebar for the same reason, and that is the&lt;br&gt;
direction the rest of it keeps moving.&lt;/p&gt;

&lt;p&gt;That is a genuine trade, not a free win: it puts your expressions in our JVM,&lt;br&gt;
which is why the sandbox above came first. If you already have a console you&lt;br&gt;
like for browsing a cluster, keep it — this one is shaped the way it is&lt;br&gt;
because authoring needed something that could actually run what you wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays out of reach
&lt;/h2&gt;

&lt;p&gt;The derivation stops, and where it stops is the honest part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At a join, we said nothing when this was published.&lt;/strong&gt; A join emits when a&lt;br&gt;
pair co-occurs — same key, both sides, inside the window — and inventing the&lt;br&gt;
side that has not arrived asserts a match that may never happen. Since&lt;br&gt;
&lt;a href="https://www.alginte.com/releases/0.10.0/" rel="noopener noreferrer"&gt;0.10.0&lt;/a&gt; the answer is two journeys instead of silence: each&lt;br&gt;
input is evaluated on its own lane, &lt;code&gt;#leftValue&lt;/code&gt; and &lt;code&gt;#rightValue&lt;/code&gt; are bound to&lt;br&gt;
their own branch's record, the joiner runs over both with the deployed&lt;br&gt;
operator, and the nodes after the join get a chain like any other. What did&lt;br&gt;
not change is the refusal to guess: a join whose second input cannot be&lt;br&gt;
evaluated waits, and says so, rather than showing output for a pair that may&lt;br&gt;
never exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One record at a time is not coverage.&lt;/strong&gt; You can cycle through real records,&lt;br&gt;
generate them from the schema, and write the awkward case by hand — but nothing&lt;br&gt;
enumerates the shapes your stream actually contains, and the loop will never&lt;br&gt;
tell you about the one you did not think to try. It is a probe, not a proof.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Protobuf values were opaque to all of this when this was published&lt;/strong&gt; — and&lt;br&gt;
that one was ours, not Kafka's. They arrive as &lt;code&gt;DynamicMessage&lt;/code&gt;, which has no&lt;br&gt;
&lt;code&gt;get(String)&lt;/code&gt;. It shipped in &lt;a href="https://www.alginte.com/releases/0.10.0/" rel="noopener noreferrer"&gt;0.10.0&lt;/a&gt;: a SpEL&lt;br&gt;
&lt;code&gt;MethodResolver&lt;/code&gt; gives &lt;code&gt;get('field')&lt;/code&gt; the same meaning on Protobuf as on Avro&lt;br&gt;
and JSON Schema, in the editor and deployed alike.&lt;/p&gt;

&lt;p&gt;Those three were not the same kind of limit, and two have since moved.&lt;br&gt;
Sampling one record at a time is not something a different tool fixes; it will&lt;br&gt;
still be true in whatever you author with instead. Protobuf access was a gap in&lt;br&gt;
what we had built, and it closed. The join was a decision — declining to guess&lt;br&gt;
— and the decision stood; what changed is that a second lane, evaluated on its&lt;br&gt;
own record, is not a guess.&lt;/p&gt;

&lt;p&gt;What a tool can do is derive what is derivable, say nothing where nothing is&lt;br&gt;
knowable, and let you run an expression against a record that actually exists&lt;br&gt;
before you commit to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it, and tell us where it fits
&lt;/h2&gt;

&lt;p&gt;This started with an &lt;code&gt;orders&lt;/code&gt; topic and a transformation you could describe in one&lt;br&gt;
sentence, with a build and a deploy standing between you and knowing whether&lt;br&gt;
it was right. The four clips are that distance getting shorter: the schema&lt;br&gt;
read out of the records, the expression checked against one of them before it&lt;br&gt;
ships, the topology running with its own numbers on it.&lt;/p&gt;

&lt;p&gt;What comes out is a Kafka Streams topology that runs as it stands. And if what&lt;br&gt;
you wanted was a shape you now understand well enough to go and write in Java,&lt;br&gt;
the value has to survive you doing that, and it does: the fields you confirmed&lt;br&gt;
and the transformation you settled on do not care where they end up.&lt;/p&gt;

&lt;p&gt;Where it stops being useful is the part we cannot answer alone: the pipelines&lt;br&gt;
you would want to explore this way, and the ones where none of this would&lt;br&gt;
help.&lt;/p&gt;

&lt;p&gt;All of this is in the &lt;a href="https://docs.alginte.com/installation/playground" rel="noopener noreferrer"&gt;playground&lt;/a&gt;:&lt;br&gt;
the &lt;code&gt;orders&lt;/code&gt; topic with its registered schema, and the topology from these&lt;br&gt;
clips deployed and running against it. One command, and you can take it apart&lt;br&gt;
instead of taking our word for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updates
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2026-09-07&lt;/strong&gt; — when first published (2026-08-19), this post said Protobuf
values were opaque to the expression editor and that we had not taught it to
read them. That shipped in &lt;a href="https://www.alginte.com/releases/0.10.0/" rel="noopener noreferrer"&gt;0.10.0&lt;/a&gt; on 2026-09-03:
&lt;code&gt;get('field')&lt;/code&gt; evaluates on Protobuf in the editor and deployed alike. The
passage above was corrected to say so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-09-08&lt;/strong&gt; — the same first version said the walk halts at a join and
everything after it is left unevaluated. Since 0.10.0 a join is evaluated on
two lanes and the nodes downstream get a chain; a join with an input that
cannot be evaluated still waits rather than guesses. The join passage and
the closing comparison were corrected to say so.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kafka</category>
      <category>kafkastreams</category>
      <category>spring</category>
      <category>java</category>
    </item>
    <item>
      <title>KafkaStreams.state() says REBALANCING. Every thread is dead.</title>
      <dc:creator>alginte software</dc:creator>
      <pubDate>Sun, 02 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/alginte/kafkastreamsstate-says-rebalancing-every-thread-is-dead-1nbh</link>
      <guid>https://dev.to/alginte/kafkastreamsstate-says-rebalancing-every-thread-is-dead-1nbh</guid>
      <description>&lt;p&gt;We found this one the embarrassing way: a seeded demo stream sat in&lt;code&gt;REBALANCING&lt;/code&gt; for a day. Not failing — &lt;em&gt;rebalancing&lt;/em&gt;. The UI said so. The Streams client said so. The only place the truth existed was a server log nobody was reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;Deploy a Kafka Streams topology whose &lt;strong&gt;source topic doesn't exist&lt;/strong&gt; , and here's the exact sequence (Kafka clients 4.x, but the behaviour is old):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The group leader's assignment fails with&lt;code&gt;INCOMPLETE_SOURCE_TOPIC_METADATA&lt;/code&gt;; the member receives the error in its assignment.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;StreamThread&lt;/code&gt; logs &lt;code&gt;MissingSourceTopicException&lt;/code&gt;, transitions&lt;code&gt;PENDING_SHUTDOWN → DEAD&lt;/code&gt;, and &lt;strong&gt;does not retry&lt;/strong&gt;. This is deliberate — a missing source topic is not a transient condition Kafka Streams can wait out.&lt;/li&gt;
&lt;li&gt;Crucially, the &lt;em&gt;client-level&lt;/em&gt; state machine never follows. The&lt;code&gt;StreamsUncaughtExceptionHandler&lt;/code&gt; isn't consulted (the thread shut down; it didn't throw), so there's no &lt;code&gt;PENDING_ERROR → ERROR&lt;/code&gt; transition.&lt;code&gt;KafkaStreams.state()&lt;/code&gt; last saw a rebalance start, and that's where it stays.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result: a &lt;code&gt;KafkaStreams&lt;/code&gt; instance with &lt;strong&gt;zero live threads&lt;/strong&gt; that reports&lt;code&gt;REBALANCING&lt;/code&gt; indefinitely. It will never process a record, never error, and never change state again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why every Kafka UI has this bug
&lt;/h2&gt;

&lt;p&gt;If your tool renders &lt;code&gt;KafkaStreams.state()&lt;/code&gt; — and that's the obvious, documented thing to render — you have this bug. The state enum simply has no value for "all my threads are dead but nobody told the coordinator layer."&lt;code&gt;REBALANCING&lt;/code&gt; is the truthful answer to the wrong question.&lt;/p&gt;

&lt;p&gt;The signal that &lt;em&gt;does&lt;/em&gt; exist is one call away:&lt;code&gt;metadataForLocalThreads()&lt;/code&gt; returns per-thread metadata including each thread's state. A client reporting &lt;code&gt;REBALANCING&lt;/code&gt; whose thread set is empty — or whose threads are all &lt;code&gt;DEAD&lt;/code&gt; — is not rebalancing. It's gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, in two halves
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Surface it.&lt;/strong&gt; We derive the displayed state instead of trusting the raw one:&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="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;KafkaStreams&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;State&lt;/span&gt; &lt;span class="nf"&gt;effectiveState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KafkaStreams&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;State&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                                         &lt;span class="nc"&gt;Collection&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ThreadMetadata&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;threads&lt;/span&gt;&lt;span class="o"&gt;)&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;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;KafkaStreams&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;State&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;REBALANCING&lt;/span&gt;
            &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;threads&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="n"&gt;threads&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;allMatch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"DEAD"&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;threadState&lt;/span&gt;&lt;span class="o"&gt;()))))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;KafkaStreams&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;State&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ERROR&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;state&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;Genuine rebalances have live threads (&lt;code&gt;STARTING&lt;/code&gt;, &lt;code&gt;PARTITIONS_ASSIGNED&lt;/code&gt;, …) and pass through untouched. In our end-to-end test, deleting a running stream's source topic flips the reported state to &lt;code&gt;ERROR&lt;/code&gt; within seconds — where before it showed &lt;code&gt;REBALANCING&lt;/code&gt; until someone read the log.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prevent the common case.&lt;/strong&gt; The most frequent way to hit this is a typo'd topic name at deploy time. Since a missing source topic is &lt;em&gt;unrecoverable by design&lt;/em&gt;, we now validate every source node's topics against the cluster before building the topology, and fail the deploy with the missing names — one &lt;code&gt;listTopics()&lt;/code&gt; round-trip. (Fail-open if the listing itself errors: a broker hiccup shouldn't block a deploy that would have succeeded; the state derivation above is the backstop.)&lt;/p&gt;

&lt;p&gt;Neither half needs anything from the broker that isn't already public API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;KafkaStreams.state()&lt;/code&gt; is the state of the &lt;em&gt;coordinator conversation&lt;/em&gt;, not the health of your processing. Dead threads don't move it.&lt;/li&gt;
&lt;li&gt;If you're operating Kafka Streams with your own dashboards: alert on thread liveness (&lt;code&gt;metadataForLocalThreads()&lt;/code&gt;, or the &lt;code&gt;alive-stream-threads&lt;/code&gt;metric), not on &lt;code&gt;state() != RUNNING&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If you're building a tool: derive, don't relay. The raw state is truthful and useless at the same time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both fixes shipped in &lt;a href="https://www.alginte.com/releases/0.7.0/" rel="noopener noreferrer"&gt;Alginte 0.7.0&lt;/a&gt;. The stuck demo that taught us this now recovers in seconds — and deploying against a typo'd topic tells you the topic's name instead of miming a rebalance.&lt;/p&gt;

</description>
      <category>kafkastreams</category>
    </item>
  </channel>
</rss>
