<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Alginte engineering blog</title><description>Engineering notes from a visual Kafka Streams builder: client-library traps, testing stories, and the reasoning behind design decisions.</description><link>https://www.alginte.com/</link><item><title>The compiler was never what you wanted</title><link>https://www.alginte.com/blog/kafka-streams-without-codegen/</link><guid isPermaLink="true">https://www.alginte.com/blog/kafka-streams-without-codegen/</guid><description>Reading an Avro topic from Kafka Streams in Java is seven steps, and five of them run again every time you need a field somebody added. What that buys is a compiler — and a compiler answers a narrower question than the one you actually have.</description><pubDate>Thu, 03 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&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=&quot;https://avro.apache.org/&quot;&gt;Avro&lt;/a&gt; against a schema in the
&lt;a href=&quot;https://docs.confluent.io/platform/current/schema-registry/index.html&quot;&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=&quot;https://kafka.apache.org/documentation/streams/&quot;&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{&quot;type&quot;: &quot;record&quot;, &quot;name&quot;: &quot;Order&quot;, &quot;namespace&quot;: &quot;com.alginte.demo&quot;,
 &quot;fields&quot;: [
   {&quot;name&quot;: &quot;orderId&quot;,    &quot;type&quot;: &quot;string&quot;},
   {&quot;name&quot;: &quot;customerId&quot;, &quot;type&quot;: &quot;string&quot;},
   {&quot;name&quot;: &quot;item&quot;,       &quot;type&quot;: &quot;string&quot;},
   {&quot;name&quot;: &quot;quantity&quot;,   &quot;type&quot;: &quot;int&quot;},
   {&quot;name&quot;: &quot;priceEur&quot;,   &quot;type&quot;: &quot;double&quot;}]}
&lt;/code&gt;&lt;/pre&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=&quot;https://github.com/confluentinc/kafka-streams-examples/blob/v8.4.0-2/pom.xml#L426-L441&quot;&gt;Confluent&apos;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&apos;s equivalent has the
same shape:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;plugin&amp;gt;
  &amp;lt;groupId&amp;gt;org.apache.avro&amp;lt;/groupId&amp;gt;
  &amp;lt;artifactId&amp;gt;avro-maven-plugin&amp;lt;/artifactId&amp;gt;
  &amp;lt;version&amp;gt;1.12.1&amp;lt;/version&amp;gt;
  &amp;lt;executions&amp;gt;&amp;lt;execution&amp;gt;
    &amp;lt;phase&amp;gt;generate-sources&amp;lt;/phase&amp;gt;
    &amp;lt;goals&amp;gt;&amp;lt;goal&amp;gt;schema&amp;lt;/goal&amp;gt;&amp;lt;/goals&amp;gt;
    &amp;lt;configuration&amp;gt;
      &amp;lt;sourceDirectory&amp;gt;${project.basedir}/src/main/avro&amp;lt;/sourceDirectory&amp;gt;
      &amp;lt;!-- without this, string fields generate as CharSequence, not String;
           Confluent&apos;s own examples set it for the same reason --&amp;gt;
      &amp;lt;stringType&amp;gt;String&amp;lt;/stringType&amp;gt;
    &amp;lt;/configuration&amp;gt;
  &amp;lt;/execution&amp;gt;&amp;lt;/executions&amp;gt;
&amp;lt;/plugin&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And step 5 is the part you actually wanted to write:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;builder.stream(&quot;orders&quot;, Consumed.with(Serdes.Void(), orderSerde))
       .filter((key, order) -&amp;gt; order.getQuantity() * order.getPriceEur() &amp;gt; 50)
       .to(&quot;big-orders&quot;, Produced.with(Serdes.Void(), orderSerde));
&lt;/code&gt;&lt;/pre&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&apos;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&apos;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(&quot;EU&quot;)&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=&quot;https://www.alginte.com&quot;&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=&quot;https://docs.spring.io/spring-framework/reference/core/expressions.html&quot;&gt;SpEL&lt;/a&gt;,
Spring&apos;s expression language, evaluated once per record:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;value.get(&apos;quantity&apos;) * value.get(&apos;priceEur&apos;) &amp;gt; 50
&lt;/code&gt;&lt;/pre&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=&quot;https://www.alginte.com/blog/kafka-streams-without-codegen/&quot;&gt;&lt;img src=&quot;https://www.alginte.com/video/preview-as-you-type.webp&quot; alt=&quot;The sample preview panel on a map node, showing a real record drawn from the orders topic on the in line. Typing value.get(&apos;item&apos;) makes the out line read Kettle; replacing the expression with value.get(&apos;quantity&apos;) * value.get(&apos;priceEur&apos;) makes the out line read 51. The editor reports no issues throughout and nothing is deployed.&quot; /&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=&quot;https://www.alginte.com/blog/building-a-spel-editor/&quot;&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(&apos;quantiy&apos;)&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&apos;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=&quot;https://avro.apache.org/docs/1.12.0/api/java/org/apache/avro/generic/GenericRecord.html&quot;&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(&quot;quantiy&quot;)&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;hr /&gt;
&lt;p&gt;&lt;em&gt;This came out of building &lt;a href=&quot;https://www.alginte.com&quot;&gt;Alginte&lt;/a&gt;, a visual Kafka
Streams builder — self-hosted, free to run.&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>SpEL in Kafka Streams: from evaluator to editor</title><link>https://www.alginte.com/blog/building-a-spel-editor/</link><guid isPermaLink="true">https://www.alginte.com/blog/building-a-spel-editor/</guid><description>Spring&apos;s SpEL tokenizer is package-private. What it takes to build an editor on an evaluator: highlighting, completions, and errors a user can act on.</description><pubDate>Mon, 31 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A Kafka Streams application is two things: a graph, and the logic inside it.
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;code&gt;join&lt;/code&gt; become the nodes, the chaining becomes the edges, and the lambda inside
each operator is where the work happens:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;record Enriched(String customer, String product, double total) { }

builder.stream(&quot;orders&quot;, Consumed.with(Serdes.Void(), orderSerde))
       .mapValues(value -&amp;gt; new Enriched(
               value.getCustomerId(),
               value.getItem(),
               value.getQuantity() * value.getPriceEur()))
       .filter((key, value) -&amp;gt; value.total() &amp;gt; 50)
       .to(&quot;orders-enriched&quot;, Produced.with(Serdes.Void(), enrichedSerde));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A graph is a picture, so we draw it: operators dragged onto a canvas and wired
together.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.alginte.com/images/topology-on-canvas.png&quot; alt=&quot;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&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The same four operators as the Java above, drawn rather than chained — one box
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
expression each carry a red badge, and the step above counts them; the source and
sink already have their topics. The graph is finished and the contents are not.
Watching it built, and what happens after, is the subject of
&lt;a href=&quot;https://www.alginte.com/blog/visual-kafka-streams-builder/&quot;&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
record — and it is the only part of the whole thing doing anything specific to
your data. That part, and only that part, is what an expression language is
for.&lt;/p&gt;
&lt;p&gt;Written as an expression, that body needs no class and no build around it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;{&apos;customer&apos;: value.get(&apos;customerId&apos;),
 &apos;product&apos;: value.get(&apos;item&apos;),
 &apos;total&apos;: value.get(&apos;quantity&apos;) * value.get(&apos;priceEur&apos;)}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The filter&apos;s body is one line: &lt;code&gt;value.get(&apos;total&apos;) &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;em&gt;right&lt;/em&gt; is the hard part: compile, package, point the app at a broker, produce
a few records, read what came out. Every step is routine, the whole loop is
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
collapses. It widens the set of people who can experiment with stream
processing, and it makes learning it cheap — the barrier was never the DSL, it
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;a href=&quot;https://docs.spring.io/spring-framework/reference/core/expressions.html&quot;&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
it, and what that editor shows has to agree with what will actually run. The
surest way is to tokenize with the same code the evaluator uses. SpEL has one.
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=&quot;https://docs.confluent.io/platform/current/schema-registry/index.html&quot;&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
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&apos;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=&quot;https://microsoft.github.io/monaco-editor/&quot;&gt;Monaco&lt;/a&gt;, the
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&apos;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
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 &quot;fields&quot;, 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
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;img src=&quot;https://www.alginte.com/images/inferred-completions.png&quot; alt=&quot;A SpEL completion dropdown in the filter node, offering get(&apos;customer&apos;), get(&apos;product&apos;) and get(&apos;total&apos;), with the type hint reading &amp;quot;customer : string (inferred)&amp;quot;&quot; /&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=&quot;https://www.alginte.com/blog/visual-kafka-streams-builder/&quot;&gt;The earlier post has this as a
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(&apos;item&apos;) == &apos;Kettle&apos;&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
it costs an afternoon the first time.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.alginte.com/blog/building-a-spel-editor/&quot;&gt;&lt;img src=&quot;https://www.alginte.com/video/errors-before-deploy.webp&quot; alt=&quot;Typing value.get(&apos;item&apos;).contains(&apos;Kettle&apos;) 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&quot; /&gt;&lt;/a&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 &quot;invalid
expression&quot;, 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&apos;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;a href=&quot;https://www.alginte.com/blog/building-a-spel-editor/&quot;&gt;&lt;img src=&quot;https://www.alginte.com/video/preview-as-you-type.webp&quot; alt=&quot;The Sample preview panel on a map node, showing a real record drawn from the orders topic on the in line. Typing value.get(&apos;item&apos;) makes the out line read Kettle; replacing the expression with value.get(&apos;quantity&apos;) * value.get(&apos;priceEur&apos;) makes the out line read 51. The editor reports no issues throughout and nothing is deployed.&quot; /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&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(&apos;item&apos;)&lt;/code&gt; gives
&lt;code&gt;&quot;Kettle&quot;&lt;/code&gt;, then the intro&apos;s own &lt;code&gt;quantity * priceEur&lt;/code&gt; gives &lt;code&gt;51&lt;/code&gt;, the value the
&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&apos;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&apos;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[&apos;item&apos;]&lt;/code&gt; — becomes:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;indexing with &lt;code&gt;[&apos;...&apos;]&lt;/code&gt; is not supported on schema-backed records — use
&lt;code&gt;get(&apos;fieldName&apos;)&lt;/code&gt; instead (the completions suggest it). A deployed topology
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
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
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 &quot;the sample record&quot;. 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;&quot;Invalid map access syntax. Use &lt;code&gt;value[&apos;key&apos;]&lt;/code&gt; without a dot.&quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The rewriting covers Avro&apos;s generic types and Jackson&apos;s nodes. Protobuf is not
in that set.&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
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.
Against Spring Framework 7.0.8:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&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;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
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;
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
start and end positions, which looks like exactly the supported route we missed.
But a parser needs a valid expression, and an editor spends most of its life
holding an invalid one. &lt;code&gt;value.&lt;/code&gt; is precisely when completions have to fire, and
it does not parse; nor does this post&apos;s own map literal before its closing
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;a href=&quot;https://github.com/spring-projects/spring-framework/tree/v7.0.8/spring-expression/src/main/java/org/springframework/expression/spel/standard&quot;&gt;Spring&apos;s &lt;code&gt;spel.standard&lt;/code&gt; package&lt;/a&gt;,
under Apache-2.0, into a package of the same name in our own tree — which is
what puts those package-private types within reach.&lt;/p&gt;
&lt;p&gt;All of it exists to enable one line:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;List&amp;lt;Token&amp;gt; tokens = new Tokenizer(inputData).process();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What we add on top is thin, and most of it is a coordinate transform. Spring
reports absolute character offsets; editors want line and column. So each
&lt;code&gt;Token&lt;/code&gt; becomes a &lt;code&gt;SpELToken(start, end, line, charPositionInLine, type, modifiers)&lt;/code&gt; — the shape Monaco and LSP want — and goes over a WebSocket to the
browser. There they are served through a &lt;code&gt;DocumentSemanticTokensProvider&lt;/code&gt;,
which is the highlighting: every colour in the expression comes from the same
tokenizer the evaluator parses with, so what the editor shows and what runs
cannot drift apart.&lt;/p&gt;
&lt;p&gt;That is the whole of it. Spring&apos;s authors had already written the hard part;
nothing was missing but a way to call it. What we carry is not
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
system, the IDE, refactoring, unit tests, and the entire library ecosystem, in
exchange for a string in a text box. That trade is defensible for &lt;em&gt;&quot;what does
this do to my records&quot;&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;em&gt;invalid&lt;/em&gt; — a method that does not exist, a syntax it cannot parse. One that is
type-correct and simply always false passes every check. The preview above will
faithfully show you the wrong answer; nothing will tell you that is what it
is.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Package-private internals carry no compatibility promise.&lt;/strong&gt; Our copy of the
tokenizer can break between Spring versions with no deprecation cycle, and that
is our problem rather than Spring&apos;s. We took the risk knowingly; it is still a
risk.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Protobuf is opaque&lt;/strong&gt; — its records arrive as a &lt;code&gt;DynamicMessage&lt;/code&gt;, which offers
no field access by name — and unlike the rest of this, that one is entirely ours
to fix. It is on the list.&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.
Package-private is a deliberate choice — internals stay private precisely so
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
that we missed, we would much rather be told than go on carrying a copy of
somebody else&apos;s tokenizer. And if there is not, we are probably not the last
people who will want one.&lt;/p&gt;
&lt;p&gt;Either way the pieces are all there: the tokenizer works, the errors carry
positions, the compiler doubles as a type checker. We would just like a
supported way to hold one of them.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;This came out of building &lt;a href=&quot;https://www.alginte.com&quot;&gt;Alginte&lt;/a&gt;, a visual Kafka
Streams builder — self-hosted, free to run.&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>Kafka Streams topologies you can draw and run</title><link>https://www.alginte.com/blog/visual-kafka-streams-builder/</link><guid isPermaLink="true">https://www.alginte.com/blog/visual-kafka-streams-builder/</guid><description>A Kafka Streams diagram shows the shape but not the logic. What changes when you draw the topology with the expressions inside, checked on real records.</description><pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;You have an &lt;code&gt;orders&lt;/code&gt; topic. Each record carries a customer, an item, a
quantity, and a price. You want the big ones — total over 50 — in a topic of
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
logic.&lt;/p&gt;
&lt;p&gt;Getting them to run is a different size of job: a build file, a serde
configuration, a jar, somewhere to put it, and a redeploy every time you want
to check if your expression was right about the data. The work is small. The
apparatus around it is not — and none of it tells you anything until the whole
thing is up.&lt;/p&gt;
&lt;p&gt;That distance is the subject here. Not that Kafka Streams is hard, because it
is not — but that the trip from &lt;em&gt;knowing what you want&lt;/em&gt; to &lt;em&gt;watching it
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
there, and the missing part is smaller than it looks. Deploy a topology and
ask it to describe itself; here is a real one: four operators, reading
&lt;code&gt;orders&lt;/code&gt; and writing &lt;code&gt;orders-enriched&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&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;p&gt;That is a graph. Nodes, directed edges, a topological order. The DSL
constructs one; &lt;code&gt;describe()&lt;/code&gt; prints one, and it is what anyone sketches on a
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
position, what feeds it, and what it feeds. What it &lt;strong&gt;does&lt;/strong&gt; lives somewhere
else — the lambda inside &lt;code&gt;mapValues&lt;/code&gt;, the entire reason that node exists, is in
your source rather than in the description. A topology description captures
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
Kafka Streams application — ours included, until recently — tends to be a
picture of the shape. Fill the boxes with real bodies and the picture stops
being documentation of the program and becomes the program — something you can
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
carries the id of the schema it was written against. So the shape is described
before any topology exists, and a record and its schema are connected before
anyone draws anything — you can watch it work.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.alginte.com/blog/visual-kafka-streams-builder/&quot;&gt;&lt;img src=&quot;https://www.alginte.com/video/record-to-contract.webp&quot; alt=&quot;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&quot; /&gt;&lt;/a&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
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 —
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
total. In a Java project, you would write that as a lambda and compile it; here
it is an expression, and this is the whole of it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;{&apos;customer&apos;: value.get(&apos;customerId&apos;),
 &apos;product&apos;:  value.get(&apos;item&apos;),
 &apos;total&apos;:    value.get(&apos;quantity&apos;) * value.get(&apos;priceEur&apos;)}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Evaluated once per record, inside the same JVM that is running the topology.
The parse happens up front, and the parsed expression is cached, so the
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
a simplification — it follows from &lt;em&gt;who writes it&lt;/em&gt;. A Kafka Streams lambda is
compiled by whoever owns the deployment, so trust is implicit and never has to
be examined. Move authoring into a console and the author is a user: the code
arrives at runtime, from outside, and the process it runs in is yours. The
obvious alternative — letting people upload compiled code — answers the same
need by handing a stranger arbitrary execution inside that process.&lt;/p&gt;
&lt;p&gt;We use &lt;a href=&quot;https://docs.spring.io/spring-framework/reference/core/expressions.html&quot;&gt;Spring&apos;s SpEL&lt;/a&gt;
for this. It is embeddable, it has an evaluation context you can restrict
deliberately rather than by accident, and in a Spring application it is already
on the classpath.&lt;/p&gt;
&lt;p&gt;Which makes the sandbox a precondition rather than a feature. Expressions
evaluate against a restricted context: no type references, no constructors, no
bean resolution. The honest word for that is &lt;em&gt;containment&lt;/em&gt; — we can tell you
exactly what is blocked, and we cannot prove that nobody will find a way
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;code&gt;orders&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.alginte.com/blog/visual-kafka-streams-builder/&quot;&gt;&lt;img src=&quot;https://www.alginte.com/video/building-the-topology.webp&quot; alt=&quot;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&quot; /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The beat worth pausing on is the source&apos;s serde section, about a third of the
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; —
because a registered subject makes the value type a contract. The &lt;strong&gt;key&lt;/strong&gt; side
stays empty until you say so, because there is no &lt;code&gt;orders-key&lt;/code&gt; subject and nothing
to derive it from.&lt;/p&gt;
&lt;p&gt;From the source down, each operator either preserves the value or replaces it.
&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;
ends the contract, because after it the value is whatever your expression
returned. That is not a property of any tool — it is how the DSL works, and it
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
say something useful while you type — and a real record underneath answers
back. Type, look, adjust: that is the loop.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.alginte.com/blog/visual-kafka-streams-builder/&quot;&gt;&lt;img src=&quot;https://www.alginte.com/video/schema-aware-authoring.webp&quot; alt=&quot;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&quot; /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Two different kinds of knowledge appear in that clip, and the difference
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&apos;s own fields&lt;/strong&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
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
schema, in any topic, anywhere. It exists because the expression above computed
it, and the shape was observed by evaluating that expression against a real
record. The UI marks fields like this &lt;code&gt;(inferred)&lt;/code&gt; for exactly that reason: it
is one record&apos;s observation, not a promise. If your data is heterogeneous, one
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
different one, roll a fresh one from the schema, or type the case you are
actually worried about — the order with the null field, the quantity nobody
expected — and watch what your expression does to it. Whatever you put in
there, every downstream node reads the same record, so one story flows through
the whole chain.&lt;/p&gt;
&lt;p&gt;Inference reads one execution path of one sample, so there are nodes where it
will be wrong and nodes where it has nothing to offer. Any node can &lt;strong&gt;declare
its output&lt;/strong&gt; instead — a serde per side, and a subject if that shape is
registered. A node that declares one becomes a fresh start: the walk resumes
from it, and everything below reads what you stated rather than what was
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
shape worth describing is usually a shape worth registering — so declaring a
node&apos;s output asks the same question binding a source does, and takes a
contract if one exists.&lt;/p&gt;
&lt;p&gt;One thing the completions cannot warn you about: the values are runtime
objects, not the Java types their names suggest. 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 behaves, because SpEL
compares &lt;code&gt;CharSequence&lt;/code&gt; content, so &lt;code&gt;value.get(&apos;item&apos;) == &apos;Kettle&apos;&lt;/code&gt; is true when
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;code&gt;CharSequence&lt;/code&gt; does not declare, so it needs a &lt;code&gt;.toString()&lt;/code&gt; first. It is the
kind of detail that lives in serde Javadoc rather than anywhere you would think
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
topology does once it is deployed. A preview that disagrees with the runtime
teaches you something false and lets you find out at deploy time — worse than
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
it. Every expression in the test suite runs through all three surfaces. Two you
have already seen: the editor&apos;s validator, and the preview under the sample.
The third is the topology itself — built by the same code that builds a
deployed one, running the same serdes, fed a record, and asked what came out of the
sink. That leg runs in-process rather than against a broker, which is the only
way to do it per expression at test speed, but nothing about the expression&apos;s
path is simulated.&lt;/p&gt;
&lt;p&gt;All three have to agree: same success or failure, same output once serialized,
same verdict from a filter. The cases run across Avro, Protobuf, and JSON
Schema, and inside those, nested records, unions, arrays, enums, and logical
types — and the set only grows: every disagreement we find becomes a case
before it becomes a fix. They are usually small and specific, like arithmetic
on an Avro field that the editor flagged and the runtime handled perfectly
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;a href=&quot;https://www.alginte.com/blog/visual-kafka-streams-builder/&quot;&gt;&lt;img src=&quot;https://www.alginte.com/video/deploy-and-watch.webp&quot; alt=&quot;Setting a dead-letter topic in the stream configuration, submitting the topology, and watching per-node throughput badges appear on the deployed canvas&quot; /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Those badges exist because of a naming decision made much earlier. Runtime
metrics are tagged with &lt;em&gt;operator names&lt;/em&gt;, so joining them back to the picture
means knowing what each operator is called — and Kafka&apos;s own answer,
&lt;code&gt;KSTREAM-MAPVALUES-0000000003&lt;/code&gt;, is generated from graph position and shifts the
moment you insert an operator upstream. The names in this topology are derived
from the nodes instead, which is why the &lt;code&gt;describe()&lt;/code&gt; output at the top of this
post reads &lt;code&gt;order-total-enrich&lt;/code&gt;. That is worth more than legibility: those names
also land in JMX, in log lines, and in the internal topic names on your
cluster.&lt;/p&gt;
&lt;p&gt;Read them carefully, though. The source reports an exact count; everything
downstream reports an &lt;em&gt;attributed&lt;/em&gt; one, taken from the surrounding subtopology.
For a linear chain, the number is correct, but it cannot tell you which records
survived a predicate — the filter&apos;s effect shows up on the sink topic&apos;s
offsets, not on the node&apos;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
questions: what is actually in these records, does this field mean what its
name suggests, what does my transformation do to the awkward ones. Answering
those in a project means writing an app to find out. Answering them here takes
the time it takes to type an expression, and the answers come from records that
are really on the topic.&lt;/p&gt;
&lt;p&gt;The loop also explains an architectural choice. Most Kafka consoles are
readers: they ask the cluster questions — what topics exist, where are the
consumer groups, what do the metrics say — and never execute anything
themselves. Evaluating your expression against a real record and showing you
what came out is not a question you can ask Kafka. It means building the
topology and running it, so a Kafka Streams application deployed from this
console runs inside the console&apos;s own process.&lt;/p&gt;
&lt;p&gt;Having everything in one place pays off after the deploy, too. The sink topic
is a topic like any other: the browser that showed you &lt;code&gt;orders&lt;/code&gt; in the first
clip will show you &lt;code&gt;orders-enriched&lt;/code&gt;, so you can read what your expression
actually produced — not just how many records got there. That is the round trip
closed: the schema you started from, the records you tested against, and the
output you caused, all reachable without leaving. Consumer groups, connectors,
and ksqlDB sit on the same sidebar for the same reason, and that is the
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,
which is why the sandbox above came first. If you already have a console you
like for browsing a cluster, keep it — this one is shaped the way it is
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 say nothing.&lt;/strong&gt; A join emits when a pair co-occurs — same key,
both sides, inside the window. Showing you its output means either finding a
real pair across both inputs, or inventing the side that has not arrived; the
second asserts a match that may never happen, and we are not willing to do it.
So the walk halts at a join, and the nodes after it are reported unevaluated
rather than guessed — until you declare the join&apos;s output yourself, which
starts a fresh chain from there. Wire only one input and you get a different
answer again, because then the chain can reach it but its output is not
modelled.&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,
generate them from the schema, and write the awkward case by hand — but nothing
enumerates the shapes your stream actually contains, and the loop will never
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 are opaque to all of this&lt;/strong&gt; — and this one is ours, not
Kafka&apos;s. They arrive as &lt;code&gt;DynamicMessage&lt;/code&gt;, which has no &lt;code&gt;get(String)&lt;/code&gt;, so field
access from an expression is unavailable, not simply awkward. An expression
language can be taught to read a &lt;code&gt;DynamicMessage&lt;/code&gt;; we have not done it.
Meanwhile, the completions decline to offer fields they know an expression
cannot reach, which is the right answer and a thin consolation.&lt;/p&gt;
&lt;p&gt;Those three are not the same kind of limit. Sampling one record at a time is
not something a different tool fixes; it will still be true in whatever you
author with instead. Protobuf access is a gap in what we have built, and it can
be closed. The join is neither: declining to guess is a decision, and decisions
can be wrong.&lt;/p&gt;
&lt;p&gt;What a tool can do is derive what is derivable, say nothing where nothing is
knowable, and let you run an expression against a record that actually exists
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
sentence, with a build and a deploy standing between you and knowing whether
it was right. The four clips are that distance getting shorter: the schema
read out of the records, the expression checked against one of them before it
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
you wanted was a shape you now understand well enough to go and write in Java,
the value has to survive you doing that, and it does: the fields you confirmed
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
you would want to explore this way, and the ones where none of this would
help.&lt;/p&gt;
&lt;p&gt;All of this is in the &lt;a href=&quot;https://docs.alginte.com/installation/playground&quot;&gt;playground&lt;/a&gt;:
the &lt;code&gt;orders&lt;/code&gt; topic with its registered schema, and the topology from these
clips deployed and running against it. One command, and you can take it apart
instead of taking our word for it.&lt;/p&gt;
</content:encoded></item><item><title>KafkaStreams.state() says REBALANCING. Every thread is dead.</title><link>https://www.alginte.com/blog/kafka-streams-rebalancing-forever/</link><guid isPermaLink="true">https://www.alginte.com/blog/kafka-streams-rebalancing-forever/</guid><description>A Kafka Streams client whose processing threads have all died keeps reporting REBALANCING — forever. Why it happens, how to detect it, and what any tool showing raw state() should do instead.</description><pubDate>Sun, 02 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&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&apos;t exist&lt;/strong&gt;, and
here&apos;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&apos;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&apos;t consulted (the thread shut down;
it didn&apos;t throw), so there&apos;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&apos;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&apos;s the obvious,
documented thing to render — you have this bug. The state enum simply has no
value for &quot;all my threads are dead but nobody told the coordinator layer.&quot;
&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&apos;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&apos;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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;static KafkaStreams.State effectiveState(KafkaStreams.State state,
                                         Collection&amp;lt;ThreadMetadata&amp;gt; threads) {
    if (state == KafkaStreams.State.REBALANCING
            &amp;amp;&amp;amp; (threads.isEmpty()
                || threads.stream().allMatch(t -&amp;gt; &quot;DEAD&quot;.equals(t.threadState())))) {
        return KafkaStreams.State.ERROR;
    }
    return state;
}
&lt;/code&gt;&lt;/pre&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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;t move it.&lt;/li&gt;
&lt;li&gt;If you&apos;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&apos;re building a tool: derive, don&apos;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=&quot;https://www.alginte.com/releases/0.7.0/&quot;&gt;Alginte 0.7.0&lt;/a&gt;. The stuck demo that
taught us this now recovers in seconds — and deploying against a typo&apos;d topic
tells you the topic&apos;s name instead of miming a rebalance.&lt;/p&gt;
</content:encoded></item></channel></rss>