<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://patterncatalyst.github.io/patterncatalyst-blog/feed.xml" rel="self" type="application/atom+xml" /><link href="https://patterncatalyst.github.io/patterncatalyst-blog/" rel="alternate" type="text/html" /><updated>2026-10-10T22:53:07+00:00</updated><id>https://patterncatalyst.github.io/patterncatalyst-blog/feed.xml</id><title type="html">Pattern Catalyst Blog</title><subtitle>Field notes mined from our workshops, tutorials, and reference builds.</subtitle><entry><title type="html">Leave the Toolchain Behind: A C++ Service in 26 MB Instead of 689</title><link href="https://patterncatalyst.github.io/patterncatalyst-blog/2026/10/10/leave-the-toolchain-behind/" rel="alternate" type="text/html" title="Leave the Toolchain Behind: A C++ Service in 26 MB Instead of 689" /><published>2026-10-10T00:00:00+00:00</published><updated>2026-10-10T00:00:00+00:00</updated><id>https://patterncatalyst.github.io/patterncatalyst-blog/2026/10/10/leave-the-toolchain-behind</id><content type="html" xml:base="https://patterncatalyst.github.io/patterncatalyst-blog/2026/10/10/leave-the-toolchain-behind/"><![CDATA[<p>A C++ service compiles to a 4 MB binary and ships inside a 689 MB image. The
binary is 0.6% of what gets pulled from the registry, scanned for CVEs, and
walked on cold start. The other 685 MB is the compiler, the package-manager
cache, a pile of object files, and the source tree, none of which the running
program ever touches.</p>

<p>The instinct is to go hunting for a smaller base image or a leaner compiler
install. That is the wrong lever. The toolchain is not bloat to be trimmed; it
is build-time machinery that has no business being in the runtime image at all.
The fix is to leave it behind.</p>

<h2 id="what-the-685-mb-is-doing">What the 685 MB is doing</h2>

<p>A first-pass Containerfile builds and runs in the same image. Install the
compiler, copy the source, compile, set an entrypoint. It works, and it
produces this:</p>

<table>
  <thead>
    <tr>
      <th>What</th>
      <th>How much</th>
      <th>Why it’s in there</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">gcc</code> / <code class="language-plaintext highlighter-rouge">gcc-c++</code>, headers, linker</td>
      <td>~400 MB</td>
      <td>needed to compile, not to run</td>
    </tr>
    <tr>
      <td>Conan cache (sources, variants, binaries)</td>
      <td>~200 MB</td>
      <td>needed to resolve deps, not to use them</td>
    </tr>
    <tr>
      <td>CMake configure cache, <code class="language-plaintext highlighter-rouge">.o</code> files, <code class="language-plaintext highlighter-rouge">.a</code> archives</td>
      <td>~80 MB</td>
      <td>build intermediates</td>
    </tr>
    <tr>
      <td>Source tree</td>
      <td>~5 MB</td>
      <td>needed to be compiled, not executed</td>
    </tr>
  </tbody>
</table>

<p>Every one of those lines is present only because the build happened in the image
you shipped. None of it is a dependency of the program. The compiler does not
run in production. The Conan cache resolved your dependencies months ago. The
<code class="language-plaintext highlighter-rouge">.o</code> files were consumed by the linker and never read again.</p>

<figure class="excalidraw-embed">
  <object type="image/svg+xml" data="/patterncatalyst-blog/assets/diagrams/leave-the-toolchain-behind.svg" aria-label="A comparison of two build strategies. On the left, a single-stage image stacks the compiler and Conan cache (about 600 MB), object files and source tree (about 85 MB), and the 4 MB binary, and the entire 689 MB ships. On the right, a build stage holds the discarded toolchain and produces the 4 MB binary, a COPY --from=build arrow carries only the binary into a runtime image built on a 14 MB ubi-micro base, for 26.4 MB total.">
    A comparison of two build strategies. On the left, a single-stage image stacks the compiler and Conan cache (about 600 MB), object files and source tree (about 85 MB), and the 4 MB binary, and the entire 689 MB ships. On the right, a build stage holds the discarded toolchain and produces the 4 MB binary, a COPY --from=build arrow carries only the binary into a runtime image built on a 14 MB ubi-micro base, for 26.4 MB total.
  </object><figcaption class="excalidraw-embed__caption">The toolchain builds the binary; only the binary needs to travel to production</figcaption><p class="excalidraw-embed__links">
    <a href="/patterncatalyst-blog/assets/diagrams/leave-the-toolchain-behind.excalidraw" download="">Download .excalidraw source</a>
    <span aria-hidden="true">·</span>
    <a href="https://excalidraw.com" target="_blank" rel="noopener">Edit on excalidraw.com</a>
  </p>
</figure>

<h2 id="multi-stage-builds-the-whole-trick-is-one-line">Multi-stage builds: the whole trick is one line</h2>

<p>A multi-stage Containerfile declares more than one <code class="language-plaintext highlighter-rouge">FROM</code>. Each <code class="language-plaintext highlighter-rouge">FROM</code> begins a
fresh image. You name the build image with <code class="language-plaintext highlighter-rouge">AS</code>, compile in it, then start a
second image for runtime and copy just the finished binary across. Only the
final stage is tagged; the build stage is discarded the moment the build
finishes.</p>

<p>Here is the real build stage from the tutorial’s demo, lightly trimmed:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Containerfile.ubi-multistage — build stage</span>
<span class="k">ARG</span><span class="s"> UBI_VERSION=10.2</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">registry.access.redhat.com/ubi10/ubi:${UBI_VERSION}</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">build</span>
<span class="k">RUN </span>dnf <span class="nb">install</span> <span class="nt">-y</span> <span class="nt">--setopt</span><span class="o">=</span><span class="nv">install_weak_deps</span><span class="o">=</span>False <span class="se">\
</span>        gcc gcc-c++ cmake ninja-build git <span class="se">\
</span>    <span class="o">&amp;&amp;</span> dnf clean all
<span class="k">WORKDIR</span><span class="s"> /src</span>
<span class="k">COPY</span><span class="s"> src/ ./src/</span>
<span class="k">COPY</span><span class="s"> CMakeLists.txt CMakePresets.json ./</span>
<span class="k">RUN </span>cmake <span class="nt">--preset</span> release <span class="se">\
</span> <span class="o">&amp;&amp;</span> cmake <span class="nt">--build</span> <span class="nt">--preset</span> release <span class="nt">-j</span><span class="s2">"</span><span class="si">$(</span><span class="nb">nproc</span><span class="si">)</span><span class="s2">"</span> <span class="se">\
</span> <span class="o">&amp;&amp;</span> strip <span class="nt">--strip-all</span> build/release/demo-svc
</code></pre></div></div>

<p>Then the runtime stage starts over from a smaller base and copies one file:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Containerfile.ubi-multistage — runtime stage</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">registry.access.redhat.com/ubi10/ubi-minimal:${UBI_VERSION}</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">runtime</span>
<span class="k">RUN </span>microdnf <span class="nb">install</span> <span class="nt">-y</span> <span class="nt">--setopt</span><span class="o">=</span><span class="nv">install_weak_deps</span><span class="o">=</span>0 libstdc++ <span class="se">\
</span> <span class="o">&amp;&amp;</span> microdnf clean all
<span class="k">WORKDIR</span><span class="s"> /app</span>
<span class="k">COPY</span><span class="s"> --from=build /src/build/release/demo-svc /app/demo-svc</span>
<span class="k">USER</span><span class="s"> 1001:1001</span>
<span class="k">EXPOSE</span><span class="s"> 8080</span>
<span class="k">ENTRYPOINT</span><span class="s"> ["/app/demo-svc"]</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">COPY --from=build /src/build/release/demo-svc /app/demo-svc</code> is the entire
mechanism. It reaches into the finished <code class="language-plaintext highlighter-rouge">build</code> image and lifts out one file.
The compiler, the Conan cache, the object files, and the source tree stay behind
in a stage that is never tagged and never pushed. They die with stage one.</p>

<p>Build <em>time</em> barely changes, because you are still compiling the same code. What
changes is what ships. On <code class="language-plaintext highlighter-rouge">ubi-minimal</code> this gets the demo from 689 MB to about
114 MB. The next decision takes it the rest of the way.</p>

<h2 id="picking-the-runtime-base-is-an-incident-response-decision">Picking the runtime base is an incident-response decision</h2>

<p>Red Hat’s Universal Base Image ships in three runtime tiers, and the gap between
them is not about megabytes:</p>

<table>
  <thead>
    <tr>
      <th>Tier</th>
      <th>Size</th>
      <th>Shell</th>
      <th>Package manager</th>
      <th>Pick it for</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ubi10/ubi</code></td>
      <td>~210 MB</td>
      <td><code class="language-plaintext highlighter-rouge">bash</code></td>
      <td><code class="language-plaintext highlighter-rouge">dnf</code></td>
      <td>build stages, debug images</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ubi10/ubi-minimal</code></td>
      <td>~100 MB</td>
      <td><code class="language-plaintext highlighter-rouge">bash</code></td>
      <td><code class="language-plaintext highlighter-rouge">microdnf</code></td>
      <td>most production C++ services</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ubi10/ubi-micro</code></td>
      <td>~14 MB</td>
      <td>none</td>
      <td>none</td>
      <td>security-sensitive, measured deployments</td>
    </tr>
  </tbody>
</table>

<p>It is tempting to read that table top to bottom and reach for the smallest row.
Resist that. The real question is what your on-call playbook does when the
service misbehaves at 3 a.m.</p>

<p><code class="language-plaintext highlighter-rouge">ubi-minimal</code> keeps a working <code class="language-plaintext highlighter-rouge">bash</code> and <code class="language-plaintext highlighter-rouge">microdnf</code>, so <code class="language-plaintext highlighter-rouge">podman exec -it ...
bash</code> drops you into a live container and you can install <code class="language-plaintext highlighter-rouge">ldd</code> or poke around.
That convenience costs roughly 85 MB over <code class="language-plaintext highlighter-rouge">ubi-micro</code>.</p>

<p><code class="language-plaintext highlighter-rouge">ubi-micro</code> is about 14 MB before your binary lands. There is no shell, no
<code class="language-plaintext highlighter-rouge">dnf</code>, no <code class="language-plaintext highlighter-rouge">ldd</code>, no <code class="language-plaintext highlighter-rouge">strace</code>. You cannot <code class="language-plaintext highlighter-rouge">exec</code> a <code class="language-plaintext highlighter-rouge">bash</code> into it because there
is no <code class="language-plaintext highlighter-rouge">bash</code> to exec. If your binary needs <code class="language-plaintext highlighter-rouge">libssl</code> or <code class="language-plaintext highlighter-rouge">libstdc++</code>, you either
link them statically or <code class="language-plaintext highlighter-rouge">COPY</code> them explicitly from the build stage. The
tutorial’s <code class="language-plaintext highlighter-rouge">ubi-micro</code> variant goes fully static and lands at <strong>26.4 MB</strong>:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Containerfile.ubi-micro — runtime stage (binary is statically linked)</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">registry.access.redhat.com/ubi10/ubi-micro:${UBI_VERSION}</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">runtime</span>
<span class="k">WORKDIR</span><span class="s"> /app</span>
<span class="k">COPY</span><span class="s"> --from=build /src/build/release-static/demo-svc /app/demo-svc</span>
<span class="c"># ubi-micro has no /etc/passwd lookups to worry about</span>
<span class="k">USER</span><span class="s"> 1001:1001</span>
<span class="k">EXPOSE</span><span class="s"> 8080</span>
<span class="k">ENTRYPOINT</span><span class="s"> ["/app/demo-svc"]</span>
</code></pre></div></div>

<p>So the decision is: if your playbook starts with “shell into the container,”
ship <code class="language-plaintext highlighter-rouge">ubi-minimal</code>. If your playbook starts with “attach an ephemeral debug
sidecar that shares the PID namespace and carries gdb,” ship <code class="language-plaintext highlighter-rouge">ubi-micro</code>. The
sidecar posture is where most production deployments land, and it is what makes
the 26 MB image safe to operate rather than merely small.</p>

<h2 id="a-26-mb-image-is-opaque-so-write-down-whats-inside-it">A 26 MB image is opaque, so write down what’s inside it</h2>

<p>Shrinking the image removes your ability to interrogate it. On <code class="language-plaintext highlighter-rouge">ubi-micro</code> you
cannot run <code class="language-plaintext highlighter-rouge">dnf list installed</code>, you cannot <code class="language-plaintext highlighter-rouge">ldd</code> the binary, you cannot ask the
image what it was built against. Six months from now a CVE drops against a range
of <code class="language-plaintext highlighter-rouge">libstdc++</code> versions and nobody can tell from the image whether you are
affected.</p>

<p>The answer is to write the toolchain identity into the image metadata at build
time, where a small image can still carry it cheaply:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">LABEL</span><span class="s"> org.opencontainers.image.title="demo-svc" \</span>
      org.opencontainers.image.revision="${GIT_SHA}" \
      tutorial.toolchain="gcc-14" \
      tutorial.linkage="static" \
      tutorial.lto="thin"
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">podman inspect &lt;image&gt; | jq '.[0].Config.Labels'</code> reads them back at incident
time without running anything. The standard <code class="language-plaintext highlighter-rouge">org.opencontainers.image.*</code> keys
cover provenance; a project-specific prefix records the things that bite C++
specifically: which libc and libstdc++ the binary expects, which
micro-architecture it was tuned for, whether LTO or PGO is in play. The image is
too small to hold the toolchain, so it holds the toolchain’s name tag instead.</p>

<h2 id="the-trap-that-makes-this-a-c-problem">The trap that makes this a C++ problem</h2>

<p>A Go binary carries its runtime inside itself; an Uberjar carries the JVM’s
class libraries. C++ does not work that way. A typical C++ binary depends on a
specific <code class="language-plaintext highlighter-rouge">libstdc++.so.6</code> and a specific <code class="language-plaintext highlighter-rouge">libc.so.6</code>, and those dependencies are
implicit right up until they are fatal.</p>

<p>The fatal version looks like this. Build the service on one distro’s glibc and
run it on an older one, and the dynamic linker goes looking at startup for a
symbol version the runtime’s <code class="language-plaintext highlighter-rouge">libc.so.6</code> does not carry:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/app/demo-svc: /lib64/libc.so.6: version `GLIBC_2.41' not found (required by /app/demo-svc)
</code></pre></div></div>

<p>The tutorial reproduces this on demand by building on Fedora 42 (glibc 2.41) and
running on <code class="language-plaintext highlighter-rouge">ubi-micro</code> (glibc 2.39). The subtle part: compiling with
<code class="language-plaintext highlighter-rouge">-static-libstdc++</code> does <em>not</em> save you, because that statically links the C++
runtime while glibc stays dynamic, so a newer glibc symbol baked in at build
time is still resolved against whatever <code class="language-plaintext highlighter-rouge">libc.so.6</code> the runtime ships. Two fixes
work. Link glibc statically too (<code class="language-plaintext highlighter-rouge">-static</code>), which is what the 26.4 MB
<code class="language-plaintext highlighter-rouge">ubi-micro</code> variant does and why it has no libc to mismatch. Or, more directly,
use the same UBI release for both stages so the build glibc and the runtime
glibc are identical by construction. UBI-10-on-UBI-10 sidesteps the whole class
of failure without any static linking at all.</p>

<p>That is the same shape as the micro-architecture pitfall, where a binary built
with <code class="language-plaintext highlighter-rouge">-march=native</code> on an AVX-512 host crashes with <code class="language-plaintext highlighter-rouge">SIGILL</code> on an older
runtime CPU. In both cases the build environment and the runtime environment
disagree, and the image strategy is what makes that disagreement visible or
invisible.</p>

<h2 id="the-takeaway">The takeaway</h2>

<p>Image size for a C++ service is not a packaging afterthought. It is a decision
about which dynamic libraries travel with the binary, which ones you assume at
the runtime end, and how you will diagnose the thing once it is small enough to
be opaque. Multi-stage builds leave the toolchain behind. The UBI tier you pick
encodes your incident-response posture. Labels buy back the introspection the
small image gave up. Get those three right and the same 4 MB binary that was
shipping in 689 MB ships in 26.</p>

<h2 id="source-project">Source project</h2>

<p>This post is mined from <strong>§4, “Container Strategy: UBI, ubi-micro,
multi-stage”</strong> of
<a href="https://patterncatalyst.github.io/cpp-container-optimization-tutorial/docs/04-image-strategy/">Optimizing Modern C++ with Containers</a>,
which walks through the full argument, the layer-caching ordering rules, and the
production diagnostic recipe for reading a small image.</p>

<p>The code samples are drawn from the tutorial’s runnable
<a href="https://github.com/patterncatalyst/cpp-container-optimization-tutorial/tree/main/examples/demo-01-image-strategy"><code class="language-plaintext highlighter-rouge">demo-01-image-strategy</code></a>,
which builds the same service four ways and prints the verified sizes:
<a href="https://github.com/patterncatalyst/cpp-container-optimization-tutorial/blob/main/examples/demo-01-image-strategy/Containerfile.single-stage-naive"><code class="language-plaintext highlighter-rouge">Containerfile.single-stage-naive</code></a> (689 MB),
<a href="https://github.com/patterncatalyst/cpp-container-optimization-tutorial/blob/main/examples/demo-01-image-strategy/Containerfile.ubi-multistage"><code class="language-plaintext highlighter-rouge">Containerfile.ubi-multistage</code></a> (114 MB),
<a href="https://github.com/patterncatalyst/cpp-container-optimization-tutorial/blob/main/examples/demo-01-image-strategy/Containerfile.ubi-micro"><code class="language-plaintext highlighter-rouge">Containerfile.ubi-micro</code></a> (26.4 MB), and
<a href="https://github.com/patterncatalyst/cpp-container-optimization-tutorial/blob/main/examples/demo-01-image-strategy/Containerfile.ubi-micro-glibc-mismatch"><code class="language-plaintext highlighter-rouge">Containerfile.ubi-micro-glibc-mismatch</code></a> (the one engineered to fail).</p>

<p>Full project: <a href="https://patterncatalyst.github.io/cpp-container-optimization-tutorial/">site</a>
· <a href="https://github.com/patterncatalyst/cpp-container-optimization-tutorial">repo</a>.</p>]]></content><author><name>Pattern Catalyst</name></author><category term="cloud-native" /><category term="cpp" /><category term="containers" /><category term="multi-stage-build" /><category term="ubi" /><category term="image-size" /><summary type="html"><![CDATA[A 4 MB C++ binary routinely ships inside a 689 MB image. The fix isn't to shrink the toolchain, it's to leave it behind. Multi-stage builds plus the right UBI runtime tier get the same service down to 26 MB, and the tier you pick is really an incident-response decision.]]></summary></entry><entry><title type="html">Who Knows the Sequence? Three Ways to Coordinate an Order Saga in Quarkus</title><link href="https://patterncatalyst.github.io/patterncatalyst-blog/2026/10/10/who-knows-the-sequence/" rel="alternate" type="text/html" title="Who Knows the Sequence? Three Ways to Coordinate an Order Saga in Quarkus" /><published>2026-10-10T00:00:00+00:00</published><updated>2026-10-10T00:00:00+00:00</updated><id>https://patterncatalyst.github.io/patterncatalyst-blog/2026/10/10/who-knows-the-sequence</id><content type="html" xml:base="https://patterncatalyst.github.io/patterncatalyst-blog/2026/10/10/who-knows-the-sequence/"><![CDATA[<p>Every event-driven system eventually runs into one question, and it is not a
framework question. When several steps have to happen in order, who decides the
order? An order is placed, a payment is captured, a shipment is dispatched, a
customer is notified. Something has to know that <code class="language-plaintext highlighter-rouge">payment</code> comes before
<code class="language-plaintext highlighter-rouge">shipping</code>. The interesting part is that “something” can live in three
different places, and where you put it decides more about the system than which
library you picked.</p>

<p>The Quarkus data-mesh reference architecture runs all three answers side by side
over the same order-triage domain, with the same business logic underneath, so
the only thing that varies is the coordination shape. That makes it a clean place
to compare them. This post walks the three, then gets to the distinction that
matters in an incident: not whether the sequence is written as code or declared
as data, but whether any single artifact knows the sequence at all.</p>

<h2 id="one-domain-three-coordinators">One domain, three coordinators</h2>

<p>The three legs coordinate the same work. Two of them are “orchestration” and
still disagree sharply, which is the first clue that the usual two-way framing is
too coarse.</p>

<ul>
  <li><strong>Kafka choreography.</strong> Four services react to each other through topics with
no coordinator. <code class="language-plaintext highlighter-rouge">order-service</code> publishes <code class="language-plaintext highlighter-rouge">order.placed</code> and has never heard of
the others. <code class="language-plaintext highlighter-rouge">payment-service</code> subscribes to <code class="language-plaintext highlighter-rouge">order.placed</code> and emits
<code class="language-plaintext highlighter-rouge">payment.captured</code>; <code class="language-plaintext highlighter-rouge">shipping-service</code> subscribes to that and emits
<code class="language-plaintext highlighter-rouge">shipment.dispatched</code>; <code class="language-plaintext highlighter-rouge">notification-service</code> also subscribes to <code class="language-plaintext highlighter-rouge">order.placed</code>
directly, a fourth reaction running in parallel with the payment chain.</li>
  <li><strong>A Camel route.</strong> One route in <code class="language-plaintext highlighter-rouge">ai-rules-service</code> sequences the steps
imperatively, top to bottom, in a single file.</li>
  <li><strong>A Quarkus Flow workflow.</strong> The same steps in the same service, declared as a
task-graph document the engine interprets, structurally the same shape as a
CNCF Serverless Workflow definition.</li>
</ul>

<figure class="excalidraw-embed">
  <object type="image/svg+xml" data="/patterncatalyst-blog/assets/diagrams/who-knows-the-sequence.svg" aria-label="Three columns over one order-triage domain. In the Kafka choreography column, four independent services (order, payment, shipping, notification) are joined only by topic-name arrows, with no single box holding the sequence. In the Camel column, one OrderTriageRoute file lists the steps top to bottom, so the sequence lives in code. In the Quarkus Flow column, a workflow document points to two tasks, classify and decide, so the sequence lives in data. The caption under each column answers who knows the sequence: no single artifact, the route, the document.">
    Three columns over one order-triage domain. In the Kafka choreography column, four independent services (order, payment, shipping, notification) are joined only by topic-name arrows, with no single box holding the sequence. In the Camel column, one OrderTriageRoute file lists the steps top to bottom, so the sequence lives in code. In the Quarkus Flow column, a workflow document points to two tasks, classify and decide, so the sequence lives in data. The caption under each column answers who knows the sequence: no single artifact, the route, the document.
  </object><figcaption class="excalidraw-embed__caption">One domain, three coordinators. The split that matters is the bottom row: who knows the sequence.</figcaption><p class="excalidraw-embed__links">
    <a href="/patterncatalyst-blog/assets/diagrams/who-knows-the-sequence.excalidraw" download="">Download .excalidraw source</a>
    <span aria-hidden="true">·</span>
    <a href="https://excalidraw.com" target="_blank" rel="noopener">Edit on excalidraw.com</a>
  </p>
</figure>

<h2 id="who-knows-the-sequence">Who knows the sequence</h2>

<p>Start with the choreography chain, because its answer is the surprising one: <strong>no
file in the repo contains the sequence.</strong> No artifact holds the string
“order.placed, then payment.captured, then shipment.dispatched” as a single
thing. <code class="language-plaintext highlighter-rouge">PaymentProcessor.process</code> knows it consumes one topic and produces
another, and nothing more. <code class="language-plaintext highlighter-rouge">ShipmentProcessor.process</code> knows the mirror image.
Each method is a complete description of what <em>it</em> does. That these methods chain
into a three-hop saga is a fact about the running system, not a fact recorded
anywhere in it. Reconstructing “what happens when an order is placed” means
reading four files in four Maven modules and joining them in your head by topic
name. The system has that knowledge; no single part of it does.</p>

<p>The Camel route is the opposite extreme. The route <em>is</em> the sequence, and it is
short enough to read in one sitting:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Camel orchestration — OrderTriageRoute.java</span>
<span class="c1">// The route is the sequence: read top to bottom, nothing elided.</span>
<span class="n">from</span><span class="o">(</span><span class="s">"direct:triage"</span><span class="o">)</span>
    <span class="o">.</span><span class="na">routeId</span><span class="o">(</span><span class="s">"triage-order"</span><span class="o">)</span>
    <span class="o">.</span><span class="na">unmarshal</span><span class="o">().</span><span class="na">json</span><span class="o">(</span><span class="nc">JsonLibrary</span><span class="o">.</span><span class="na">Jackson</span><span class="o">,</span> <span class="nc">OrderCreate</span><span class="o">.</span><span class="na">class</span><span class="o">)</span>
    <span class="o">.</span><span class="na">bean</span><span class="o">(</span><span class="n">triageService</span><span class="o">,</span> <span class="s">"classify"</span><span class="o">)</span>
    <span class="o">.</span><span class="na">bean</span><span class="o">(</span><span class="n">triageService</span><span class="o">,</span> <span class="s">"decide"</span><span class="o">)</span>
    <span class="o">.</span><span class="na">marshal</span><span class="o">().</span><span class="na">json</span><span class="o">(</span><span class="nc">JsonLibrary</span><span class="o">.</span><span class="na">Jackson</span><span class="o">);</span>
</code></pre></div></div>

<p>A reviewer who reads those lines has read the entire business process for that
endpoint, in order. The Flow workflow occupies the same slot but answers “what is
the sequence” with a document instead of a trace:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Quarkus Flow orchestration — OrderTriageWorkflow.java</span>
<span class="c1">// Same two steps, declared as a task graph the engine interprets.</span>
<span class="k">return</span> <span class="nc">FlowWorkflowBuilder</span><span class="o">.</span><span class="na">workflow</span><span class="o">(</span><span class="s">"order-triage"</span><span class="o">)</span>
    <span class="o">.</span><span class="na">tasks</span><span class="o">(</span>
        <span class="nc">FlowDSL</span><span class="o">.</span><span class="na">function</span><span class="o">(</span><span class="s">"classify"</span><span class="o">,</span> <span class="nl">triageService:</span><span class="o">:</span><span class="n">classify</span><span class="o">),</span>
        <span class="nc">FlowDSL</span><span class="o">.</span><span class="na">function</span><span class="o">(</span><span class="s">"decide"</span><span class="o">,</span> <span class="nl">triageService:</span><span class="o">:</span><span class="n">decide</span><span class="o">))</span>
    <span class="o">.</span><span class="na">build</span><span class="o">();</span>
</code></pre></div></div>

<p>Both orchestration legs beat choreography on “where do I even look,” but they
beat it in different senses. The Camel route’s sequence is knowable by <em>reading
code</em>. The Flow workflow’s sequence is knowable by <em>reading data</em>: you can
inspect, diff, or version the descriptor without executing it. That is the
distinction the usual declarative-versus-imperative framing points at, and it is
real. It is just not the one that hurts most at three in the morning.</p>

<h2 id="coupling-what-changes-when-you-add-a-step">Coupling: what changes when you add a step</h2>

<p>Measure coupling by asking what has to change, and where, when a step is added.</p>

<p>Add a fifth reaction to <code class="language-plaintext highlighter-rouge">order.placed</code>, say an analytics service, and the
choreography chain costs the existing services nothing. <code class="language-plaintext highlighter-rouge">order-service</code> does not
import a client, does not add a call, does not even learn the new service exists.
The new service pays the entire wiring cost by subscribing on its own, exactly as
<code class="language-plaintext highlighter-rouge">notification-service</code> already does:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Kafka choreography — PaymentProcessor.java</span>
<span class="c1">// Reacts to order.placed; knows nothing of shipping or notification downstream.</span>
<span class="nd">@Incoming</span><span class="o">(</span><span class="nc">Topics</span><span class="o">.</span><span class="na">ORDER_PLACED_CHANNEL</span><span class="o">)</span>
<span class="nd">@Outgoing</span><span class="o">(</span><span class="nc">Topics</span><span class="o">.</span><span class="na">PAYMENT_CAPTURED_CHANNEL</span><span class="o">)</span>
<span class="kd">public</span> <span class="nc">PaymentCaptured</span> <span class="nf">process</span><span class="o">(</span><span class="nc">OrderPlaced</span> <span class="n">orderPlaced</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">String</span> <span class="n">orderId</span> <span class="o">=</span> <span class="n">orderPlaced</span><span class="o">.</span><span class="na">getOrderId</span><span class="o">();</span>
    <span class="nc">PaymentCaptured</span> <span class="n">existing</span> <span class="o">=</span> <span class="n">paymentStore</span><span class="o">.</span><span class="na">findByOrderId</span><span class="o">(</span><span class="n">orderId</span><span class="o">);</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">existing</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="n">existing</span><span class="o">;</span> <span class="c1">// idempotent on redelivery, not a compensation</span>
    <span class="o">}</span>
    <span class="c1">// ... build, persist, and return PaymentCaptured ...</span>
<span class="o">}</span>
</code></pre></div></div>

<p>The only shared thing is the schema, the <code class="language-plaintext highlighter-rouge">OrderPlaced</code> Avro type. No participant
holds a reference to another participant’s class, bean, or address.</p>

<p>Here is the part that cuts against the tidy story. Both orchestration legs hold a
direct <code class="language-plaintext highlighter-rouge">@Inject TriageService</code> reference and call its methods, so their coupling
to the business logic is equally tight, and tighter than choreography’s. Adding a
step means editing the one coordinator: in Camel you insert a line into the fluent
chain; in Flow you add an entry to the <code class="language-plaintext highlighter-rouge">.tasks(...)</code> list. Both are one edit, one
file, one diff, one deploy. On this axis Camel and Flow sit almost on top of each
other, and both sit far from choreography, the declarative-versus-imperative
difference notwithstanding.</p>

<h2 id="failure-handling-what-is-implemented-not-what-is-possible">Failure handling: what is implemented, not what is possible</h2>

<p>This is where comparisons usually go wrong, because the engines can all do far
more than any given codebase exercises. So be precise about what this repo
implements today.</p>

<p>Choreography’s failure handling is <strong>per-hop idempotency, not compensation.</strong>
<code class="language-plaintext highlighter-rouge">PaymentProcessor</code> and <code class="language-plaintext highlighter-rouge">ShipmentProcessor</code> both guard against redelivery by
checking for an existing record before acting, which is what makes at-least-once
Kafka delivery safe to build on. But nothing reacts to a <em>failed</em> payment by
un-dispatching a shipment, and nothing refunds a capture when a shipment fails.
There is no saga orchestrator and no dead-letter configuration. Kafka’s consumer
redelivery is the only safety net wired up.</p>

<p>Camel’s failure handling here is <strong>default propagation.</strong> The route has no
<code class="language-plaintext highlighter-rouge">.onException(...)</code> and no custom error handler, so an exception from <code class="language-plaintext highlighter-rouge">classify</code>
propagates back through the REST binding. What the route does get by default is
that any exception stops execution at the point of failure, so <code class="language-plaintext highlighter-rouge">decide</code> never
runs against a <code class="language-plaintext highlighter-rouge">classify</code> that returned garbage without raising.</p>

<p>Flow’s failure handling is, in this repo, <strong>the same as Camel’s: unexercised.</strong>
The descriptor declares two tasks and nothing else, no retry policy and no
compensation task. A 120-second timeout exists at the caller boundary, but that
is HTTP hygiene, not workflow-level compensation.</p>

<p>The fair comparison, then, is not “which engine handles failure better.” All
three lean on idempotency or exception propagation today. The difference is
<strong>where the fix would go</strong> when you need real compensation:</p>

<ul>
  <li>In choreography, a new consumer that reacts to a failure event, itself
published as an event, keeping the pattern decentralized.</li>
  <li>In Camel, <code class="language-plaintext highlighter-rouge">.onException()</code> or <code class="language-plaintext highlighter-rouge">.doTry()</code>/<code class="language-plaintext highlighter-rouge">.doCatch()</code> clauses on the one route.</li>
  <li>In Flow, retry and compensation nodes added to the one workflow document. The
Serverless Workflow spec supports them as first-class constructs, which is the
one real edge of the declarative shape: compensation becomes expressible <em>as
data</em>. “The spec supports it” is not the same as “this workflow uses it,” and
keeping those two sentences apart is the difference between reading a demo and
believing its marketing.</li>
</ul>

<h2 id="debuggability">Debuggability</h2>

<p>An incident forces a concrete question: where do you put your eyes first?</p>

<p>For choreography, the answer is “it depends which hop is silent,” and the path is
per-service. Check <code class="language-plaintext highlighter-rouge">order-service</code>’s logs and the <code class="language-plaintext highlighter-rouge">order.placed</code> topic, then
<code class="language-plaintext highlighter-rouge">payment-service</code> and <code class="language-plaintext highlighter-rouge">payment.captured</code>, then <code class="language-plaintext highlighter-rouge">shipping-service</code>. There is no
single place that reports “did the whole chain finish.” That is the standing cost
of loose coupling, paid on every investigation.</p>

<p>The Camel route is one log stream: one request, one thread, one place to read.
The Flow workflow is inspectable per instance and per task, because progress is
tracked against named nodes rather than only as a log stream. That is a real
advantage for long-running or branching workflows. For this particular two-task,
caller-blocks-on-await workflow it stays mostly theoretical, a caveat the
comparison should name rather than skip.</p>

<h2 id="operational-cost-tracks-coupling-almost-exactly">Operational cost tracks coupling, almost exactly</h2>

<p>Running choreography in production means running, monitoring, scaling, and
deploying four services plus Kafka plus a schema registry. The payoff is that
those failure domains are independent: a slow <code class="language-plaintext highlighter-rouge">notification-service</code> never backs
up <code class="language-plaintext highlighter-rouge">payment-service</code>, because they share nothing but a topic. Both orchestration
legs run inside a <em>single</em> service, so they are cheaper to operate and they share
fate. If <code class="language-plaintext highlighter-rouge">ai-rules-service</code> is down, both endpoints are down together; if the
shared Ollama model is slow, both feel it identically, because they call through
the same bean.</p>

<figure class="excalidraw-embed">
  <object type="image/svg+xml" data="/patterncatalyst-blog/assets/diagrams/coupling-and-operational-cost.svg" aria-label="Three side-by-side columns comparing the same order-triage saga under three coordinators. The Kafka choreography column: no coordinator, who-knows-it is no single artifact (four handlers joined by topic), coupling is loosest (shared schema only), ops cost is four services plus Kafka plus a schema registry, failure today is per-hop idempotency with no cross-hop compensation; best fit is an event others react to. The Camel route column: sequence in code, who-knows-it is one route read top to bottom, coupling is tight to the business bean (one file to edit), ops cost is one service with shared fate, failure today is default exception propagation; best fit is the sequence as reviewable code. The Quarkus Flow column: sequence in data, who-knows-it is one document (a declared task graph), coupling is tight to the business bean (one file to edit), ops cost is one service with shared fate, failure today is caller-side timeout only; best fit is an inspectable, versionable document.">
    Three side-by-side columns comparing the same order-triage saga under three coordinators. The Kafka choreography column: no coordinator, who-knows-it is no single artifact (four handlers joined by topic), coupling is loosest (shared schema only), ops cost is four services plus Kafka plus a schema registry, failure today is per-hop idempotency with no cross-hop compensation; best fit is an event others react to. The Camel route column: sequence in code, who-knows-it is one route read top to bottom, coupling is tight to the business bean (one file to edit), ops cost is one service with shared fate, failure today is default exception propagation; best fit is the sequence as reviewable code. The Quarkus Flow column: sequence in data, who-knows-it is one document (a declared task graph), coupling is tight to the business bean (one file to edit), ops cost is one service with shared fate, failure today is caller-side timeout only; best fit is an inspectable, versionable document.
  </object><figcaption class="excalidraw-embed__caption">One saga, three coordinators, compared on the axes that bite in production: who knows the sequence, coupling, operational cost, and failure handling today.</figcaption><p class="excalidraw-embed__links">
    <a href="/patterncatalyst-blog/assets/diagrams/coupling-and-operational-cost.excalidraw" download="">Download .excalidraw source</a>
    <span aria-hidden="true">·</span>
    <a href="https://excalidraw.com" target="_blank" rel="noopener">Edit on excalidraw.com</a>
  </p>
</figure>

<p>So the trade is legible. Choreography’s loosest coupling buys the most
independent operability at the highest service count. Both orchestration legs buy
operational simplicity at the cost of shared fate for everything the coordinator
touches.</p>

<h2 id="reach-for-which-when">Reach for which, when</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Kafka choreography</th>
      <th>Camel route</th>
      <th>Quarkus Flow</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Who knows the sequence</td>
      <td>No one artifact; reconstructed from four handlers joined by topic name</td>
      <td>One route, read top to bottom</td>
      <td>One document, read as a declared task graph</td>
    </tr>
    <tr>
      <td>Coupling</td>
      <td>Loosest; shared schema only</td>
      <td>Tight to the business bean; one file to edit</td>
      <td>Tight to the business bean; one file to edit</td>
    </tr>
    <tr>
      <td>Failure handling today</td>
      <td>Per-hop idempotency; no cross-hop compensation</td>
      <td>Default exception propagation</td>
      <td>Caller-side timeout only</td>
    </tr>
    <tr>
      <td>Where compensation would go</td>
      <td>A new consumer for a failure event</td>
      <td><code class="language-plaintext highlighter-rouge">.onException()</code> on the route</td>
      <td>Retry/compensation nodes in the document</td>
    </tr>
    <tr>
      <td>Debuggability</td>
      <td>Per-service logs and per-topic inspection</td>
      <td>One log stream, one thread</td>
      <td>Per-instance, per-task history</td>
    </tr>
    <tr>
      <td>Operational cost</td>
      <td>Four services + Kafka + registry</td>
      <td>One service, shared fate</td>
      <td>One service, shared fate</td>
    </tr>
    <tr>
      <td>Best fit</td>
      <td>An event other parts of the system react to, where the publisher should not know who is listening</td>
      <td>A fixed process where the sequence is the valuable, reviewable artifact and you want imperative control</td>
      <td>A fixed process where you want the sequence as an inspectable, versionable document and the engine’s retry/compensation vocabulary on hand</td>
    </tr>
  </tbody>
</table>

<h2 id="the-question-worth-carrying">The question worth carrying</h2>

<p>The split that survives contact with production is not declarative versus
imperative. Camel and Flow land in nearly the same place on coupling, on where
the business logic lives, and on how much failure handling exists today. They
differ in how you would extend each one: edit a method chain, or edit a task
graph. Choreography is the real outlier, because it removes the central artifact
entirely, and that single decision ripples through coupling, debuggability,
testing, and operational cost in lockstep.</p>

<p>So when you reach for a coordinator, ask the question the three columns answer
differently. When this breaks in production, who knows the sequence? If the
answer is “no one, by design, and that is the point,” you want choreography. If
the answer needs to be “this file,” you want a route. If it needs to be “this
document, which we can inspect and version,” you want a workflow. Pick the shape
whose answer you can live with during an incident, and the library choice mostly
follows from there.</p>

<h2 id="bonus-camels-saga-eip-and-why-it-changes-the-state-story">Bonus: Camel’s Saga EIP, and why it changes the state story</h2>

<p>Here is a property the three coordinators share: all of them are
stateless. The Camel route and the Flow document each <em>describe</em> the sequence,
but the running service holds nothing between requests. A request arrives, the
sequence executes, the service forgets. That is what lets you run many replicas
and scale them to zero without a second thought, and it is the same posture a
well-behaved service keeps in any language.</p>

<p>Camel has a first-class way to make a route survive partial failure: the Saga
EIP, its implementation of the saga / long-running-action pattern. You mark the
route <code class="language-plaintext highlighter-rouge">.saga()</code>, and each participating step registers a <code class="language-plaintext highlighter-rouge">.compensation()</code>
endpoint (and, if it needs one, a <code class="language-plaintext highlighter-rouge">.completion()</code> endpoint). If a later step
fails, Camel calls the earlier steps’ compensations in reverse order to undo
their side effects:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Camel Saga — OrderSagaRoute.java</span>
<span class="c1">// Same top-to-bottom sequence, but each step now declares how to undo itself.</span>
<span class="n">from</span><span class="o">(</span><span class="s">"direct:place-order"</span><span class="o">)</span>
    <span class="o">.</span><span class="na">saga</span><span class="o">()</span>
        <span class="o">.</span><span class="na">timeout</span><span class="o">(</span><span class="mi">30</span><span class="o">,</span> <span class="nc">TimeUnit</span><span class="o">.</span><span class="na">SECONDS</span><span class="o">)</span>           <span class="c1">// a stuck saga auto-compensates</span>
    <span class="o">.</span><span class="na">to</span><span class="o">(</span><span class="s">"direct:capture-payment"</span><span class="o">)</span>
    <span class="o">.</span><span class="na">to</span><span class="o">(</span><span class="s">"direct:arrange-shipping"</span><span class="o">);</span>

<span class="n">from</span><span class="o">(</span><span class="s">"direct:capture-payment"</span><span class="o">)</span>
    <span class="o">.</span><span class="na">saga</span><span class="o">()</span>
        <span class="o">.</span><span class="na">propagation</span><span class="o">(</span><span class="nc">SagaPropagation</span><span class="o">.</span><span class="na">MANDATORY</span><span class="o">)</span>
        <span class="o">.</span><span class="na">compensation</span><span class="o">(</span><span class="s">"direct:refund-payment"</span><span class="o">)</span>   <span class="c1">// called in reverse if a later step fails</span>
        <span class="o">.</span><span class="na">option</span><span class="o">(</span><span class="s">"paymentId"</span><span class="o">,</span> <span class="n">simple</span><span class="o">(</span><span class="s">"${body.paymentId}"</span><span class="o">))</span>
    <span class="o">.</span><span class="na">bean</span><span class="o">(</span><span class="n">paymentService</span><span class="o">,</span> <span class="s">"capture"</span><span class="o">);</span>

<span class="n">from</span><span class="o">(</span><span class="s">"direct:refund-payment"</span><span class="o">)</span>                     <span class="c1">// the compensating action — must be idempotent</span>
    <span class="o">.</span><span class="na">bean</span><span class="o">(</span><span class="n">paymentService</span><span class="o">,</span> <span class="s">"refund"</span><span class="o">);</span>              <span class="c1">// reads the paymentId saved via option()</span>
</code></pre></div></div>

<p>That is a real improvement over the default exception propagation in the
comparison above. But look at what it costs. A saga is a state machine: it moves
through Active, Completing, and Completed on the happy path, or through
Compensating and Compensated on failure, with a timeout that auto-compensates a
saga that gets stuck. Something has to hold where each in-flight saga currently
is.</p>

<figure class="excalidraw-embed">
  <object type="image/svg+xml" data="/patterncatalyst-blog/assets/diagrams/camel-saga-state-machine.svg" aria-label="A state machine on the left and a list of persisted state on the right. The state machine starts at Active (saga started). On all steps ok it moves to Completing (run completion URIs) and then Completed. On error or timeout it moves from Active to Compensating (undo in reverse) and then, via the compensation URIs, to Compensated. The right panel lists what the coordinator must persist per in-flight saga: the participants and their compensation and completion URIs, the saved option() values such as the ids a refund needs, the status and which step is next, and the timeout deadline. A note explains that InMemorySagaService keeps this in the JVM heap so a crash mid-saga loses it and nothing compensates, whereas an LRA coordinator persists it and the Camel service goes back to stateless.">
    A state machine on the left and a list of persisted state on the right. The state machine starts at Active (saga started). On all steps ok it moves to Completing (run completion URIs) and then Completed. On error or timeout it moves from Active to Compensating (undo in reverse) and then, via the compensation URIs, to Compensated. The right panel lists what the coordinator must persist per in-flight saga: the participants and their compensation and completion URIs, the saved option() values such as the ids a refund needs, the status and which step is next, and the timeout deadline. A note explains that InMemorySagaService keeps this in the JVM heap so a crash mid-saga loses it and nothing compensates, whereas an LRA coordinator persists it and the Camel service goes back to stateless.
  </object><figcaption class="excalidraw-embed__caption">A saga is a state machine. The sequence can live in code; the saga&#39;s progress has to live in a store.</figcaption><p class="excalidraw-embed__links">
    <a href="/patterncatalyst-blog/assets/diagrams/camel-saga-state-machine.excalidraw" download="">Download .excalidraw source</a>
    <span aria-hidden="true">·</span>
    <a href="https://excalidraw.com" target="_blank" rel="noopener">Edit on excalidraw.com</a>
  </p>
</figure>

<p>The default <code class="language-plaintext highlighter-rouge">CamelSagaService</code> is <code class="language-plaintext highlighter-rouge">InMemorySagaService</code>, and it keeps all of
that in the JVM heap. Fine for a demo, and fatal in production: if the process
dies between “payment captured” and “shipping arranged,” the saga state dies with
it, no compensation ever runs, and you are left with a payment that was captured
and will never be refunded. The stateless property we just praised is gone, in
the one way that hurts most.</p>

<p>So making a Camel saga correct is, in the end, a state-externalization exercise.
You delegate the saga state to a coordinator that persists it: <code class="language-plaintext highlighter-rouge">camel-lra</code> plus a
MicroProfile LRA coordinator (Narayana) writes the participant list, the
compensation URIs, and the saved options to durable storage, and after a restart
the coordinator drives the outstanding compensations to completion.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Non-durable: saga state lives in the JVM heap, lost on crash.</span>
<span class="n">camelContext</span><span class="o">.</span><span class="na">addService</span><span class="o">(</span><span class="k">new</span> <span class="nc">InMemorySagaService</span><span class="o">());</span>

<span class="c1">// Durable: delegate saga state to an LRA coordinator that persists it,</span>
<span class="c1">// so the Camel service itself goes back to holding nothing.</span>
<span class="nc">LRASagaService</span> <span class="n">saga</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">LRASagaService</span><span class="o">();</span>
<span class="n">saga</span><span class="o">.</span><span class="na">setCoordinatorUrl</span><span class="o">(</span><span class="s">"http://lra-coordinator:8080"</span><span class="o">);</span>
<span class="n">saga</span><span class="o">.</span><span class="na">setLocalParticipantUrl</span><span class="o">(</span><span class="s">"http://ai-rules-service:8080"</span><span class="o">);</span>
<span class="n">camelContext</span><span class="o">.</span><span class="na">addService</span><span class="o">(</span><span class="n">saga</span><span class="o">);</span>
</code></pre></div></div>

<p>The rest of the work follows from treating it as a state machine with external
storage: give every step a compensation route, use <code class="language-plaintext highlighter-rouge">.option(...)</code> to persist the
identifiers those compensations need, make each compensation idempotent because
the coordinator may retry it, and set a <code class="language-plaintext highlighter-rouge">.timeout(...)</code> so a wedged saga does not
sit half-applied forever. Do that and the state lives outside the process again,
and the service is stateless once more. It is the same externalize-your-state
move that keeps any service replaceable, applied to the one piece of coordination
that does need to remember something.</p>

<p>This is why the saga sits in a bonus rather than in the three-way comparison. The
route and the workflow answer “who knows the sequence.” The saga answers a second
question on top of it, “who knows how far we got, and will that knowledge survive
a crash,” and answering it well means putting the progress somewhere durable. The
sequence can live in code or in a document. The saga’s state has to live in a
store. Keeping those two questions separate is the whole point: add a saga only
when you need the second answer, and know that you are trading the stateless
posture for a coordinator you now have to operate.</p>

<p>For the mechanics, see Camel’s
<a href="https://camel.apache.org/components/next/eips/saga-eip.html">Saga EIP</a> and the
<a href="https://camel.apache.org/components/next/others/lra.html">camel-lra component</a>.</p>

<hr />

<p><strong>Source project.</strong> This post is drawn from
<a href="https://patterncatalyst.github.io/datamesh-reference-arch-quarkus/docs/13-orchestration-styles/">Chapter 13, orchestration styles</a>
and the deeper
<a href="https://patterncatalyst.github.io/datamesh-reference-arch-quarkus/docs/21-orchestration-engines-compared/">Appendix comparing the three engines</a>
of the
<a href="https://patterncatalyst.github.io/datamesh-reference-arch-quarkus/">Data Mesh Reference Architecture on Quarkus</a>,
which runs all three legs green over one shipping/order domain. The code shown
here is real:
<a href="https://github.com/patterncatalyst/datamesh-reference-arch-quarkus/blob/main/examples/ai-rules-service/src/main/java/com/patterncatalyst/datamesh/airules/OrderTriageRoute.java"><code class="language-plaintext highlighter-rouge">OrderTriageRoute.java</code></a>,
<a href="https://github.com/patterncatalyst/datamesh-reference-arch-quarkus/blob/main/examples/ai-rules-service/src/main/java/com/patterncatalyst/datamesh/airules/OrderTriageWorkflow.java"><code class="language-plaintext highlighter-rouge">OrderTriageWorkflow.java</code></a>,
and
<a href="https://github.com/patterncatalyst/datamesh-reference-arch-quarkus/blob/main/examples/payment-service/src/main/java/com/patterncatalyst/datamesh/payment/PaymentProcessor.java"><code class="language-plaintext highlighter-rouge">PaymentProcessor.java</code></a>.
Full source is on
<a href="https://github.com/patterncatalyst/datamesh-reference-arch-quarkus">GitHub</a>.</p>]]></content><author><name>Pattern Catalyst</name></author><category term="cloud-native" /><category term="quarkus" /><category term="kafka" /><category term="camel" /><category term="orchestration" /><category term="event-driven" /><category term="saga" /><summary type="html"><![CDATA[An order places, a payment captures, a shipment dispatches. Who decides that order? Kafka choreography, a Camel route, and a Quarkus Flow document each answer differently, and the real split isn't declarative versus imperative. It's who holds the sequence when production breaks.]]></summary></entry><entry><title type="html">Cardinality, Not Instrumentation, Is What Makes Observability Expensive</title><link href="https://patterncatalyst.github.io/patterncatalyst-blog/2026/10/09/cardinality-is-what-makes-observability-expensive/" rel="alternate" type="text/html" title="Cardinality, Not Instrumentation, Is What Makes Observability Expensive" /><published>2026-10-09T00:00:00+00:00</published><updated>2026-10-09T00:00:00+00:00</updated><id>https://patterncatalyst.github.io/patterncatalyst-blog/2026/10/09/cardinality-is-what-makes-observability-expensive</id><content type="html" xml:base="https://patterncatalyst.github.io/patterncatalyst-blog/2026/10/09/cardinality-is-what-makes-observability-expensive/"><![CDATA[<p>A dashboard that runs fine in a demo can start timing out, or show up on a
billing report as a six-figure line item, the first time it meets production
traffic. The instrumentation code did not change. The volume and the shape of
what it emits did. Cardinality is the single biggest lever on observability
cost, and it is almost always invisible at the point where the damage is done:
a line of code that looked harmless when it was written.</p>

<p>Three cost problems hide behind that bill, and they are distinct: metric
cardinality, log volume, and trace volume. Each has its own failure mode and
its own fix, and all three get cheaper to control the earlier you give the
collector a say in what leaves the building.</p>

<h2 id="a-metric-is-a-family-of-time-series">A metric is a family of time series</h2>

<p>Start with the part that surprises people most. A metric is not one stream of
numbers. It is a family of time series, one for every unique combination of
label values it has ever carried. A counter named <code class="language-plaintext highlighter-rouge">http_requests_total</code> with
labels <code class="language-plaintext highlighter-rouge">method</code>, <code class="language-plaintext highlighter-rouge">route</code>, and <code class="language-plaintext highlighter-rouge">status_code</code> does not produce one series; it
produces one per observed combination: <code class="language-plaintext highlighter-rouge">GET /cart 200</code>, <code class="language-plaintext highlighter-rouge">POST /checkout 500</code>,
<code class="language-plaintext highlighter-rouge">GET /cart 404</code>, and so on. The metrics backend (Mimir, in the stack this is
drawn from) allocates storage, an index entry, and query-time bookkeeping for
every one of them, and keeps it until retention ages it out.</p>

<p>That is fine when the label values come from a small, closed set. HTTP method
has a handful of values. A templated route such as <code class="language-plaintext highlighter-rouge">/cart/{id}</code> has a few
dozen. Status code has a few hundred, of which a service exercises maybe
twenty. Multiply those together and a well-behaved metric produces a few
thousand series, which any backend built in the last decade handles without
noticing.</p>

<p>The failure mode is a label whose value set has no ceiling: a raw user ID, a
session token, a request ID, an email address, a full URL with its query string
still attached, a container ID. Each one multiplies into the series count, and
because the set of values grows without bound, the series count grows without
bound too. A counter that looked like it tracked requests per route quietly
becomes requests per route per user who has ever hit the service, and the
backend ends up carrying a series for every person who tried the product once
two years ago and never came back. That is a cardinality explosion, and it is
the most common reason a metrics backend falls over or a bill spikes.</p>

<figure class="excalidraw-embed">
  <object type="image/svg+xml" data="/patterncatalyst-blog/assets/diagrams/cardinality-explosion.svg" aria-label="Diagram comparing two versions of the same counter http_requests_total. With a bounded label set of method, route, and status it produces about three thousand time series, with cheap storage and fast queries. Adding one unbounded label, user_id, turns the same counter into millions of time series, with high storage cost and slow dashboards. The metric definition looks nearly identical in both cases.">
    Diagram comparing two versions of the same counter http_requests_total. With a bounded label set of method, route, and status it produces about three thousand time series, with cheap storage and fast queries. Adding one unbounded label, user_id, turns the same counter into millions of time series, with high storage cost and slow dashboards. The metric definition looks nearly identical in both cases.
  </object><figcaption class="excalidraw-embed__caption">One unbounded label turns a few thousand series into millions; the code looks the same at review time</figcaption><p class="excalidraw-embed__links">
    <a href="/patterncatalyst-blog/assets/diagrams/cardinality-explosion.excalidraw" download="">Download .excalidraw source</a>
    <span aria-hidden="true">·</span>
    <a href="https://excalidraw.com" target="_blank" rel="noopener">Edit on excalidraw.com</a>
  </p>
</figure>

<p>OpenTelemetry’s own guidance is explicit here: attributes with unbounded value
sets belong on spans and logs, which carry per-event detail, not on metric
labels, which exist to be aggregated (see the
<a href="https://opentelemetry.io/docs/specs/semconv/">semantic conventions</a> and the
<a href="https://opentelemetry.io/docs/specs/otel/metrics/">metrics specification</a>). The
rule of thumb that holds up across every language: before adding a label to a
metric, ask whether you could list every value it will ever take. If you
cannot, it does not belong on that metric.</p>

<p>Cardinality has a second cost that bills as latency rather than dollars. A query
for the error rate of one route has to scan and aggregate every series that
matches, so the more series exist, the more work the engine does to collapse
them into the single line a dashboard shows. An explosion does not only make
storage expensive; it makes every dashboard built on the affected metric
slower, sometimes slow enough to time out. That timeout is usually the first
symptom an on-call engineer notices, long before anyone opens a billing report.</p>

<h2 id="label-hygiene-is-a-design-discipline">Label hygiene is a design discipline</h2>

<p>Fixing cardinality after the fact is a scramble: finding which series exploded,
patching instrumentation in a dozen places, then waiting out a retention window
for the damage to roll off storage. Applied up front it is cheap, and it comes
down to a few habits.</p>

<ul>
  <li><strong>Favor templated routes over literal paths.</strong> A framework that already knows
the route template should report <code class="language-plaintext highlighter-rouge">/orders/{orderId}</code>, not <code class="language-plaintext highlighter-rouge">/orders/8f21ac</code>.
The identifier belongs on the span or the log line, where it tags one event
instead of multiplying a whole family of series.</li>
  <li><strong>Bucket instead of enumerate.</strong> A wide-ranging numeric value, a payload size
or a duration, belongs in a histogram, whose buckets are a bounded dimension.
The raw value as a label is not.</li>
  <li><strong>Push high-cardinality identifiers down the signal stack.</strong> A user ID, order
ID, or trace ID is exactly the right thing to attach to a span attribute or a
structured log field. It should never also become a metric label.</li>
  <li><strong>Review new labels like schema changes.</strong> A reviewer who asks one question,
“what is the set of values this can take, and is it bounded?”, catches almost
every cardinality problem before it ships, because the answer is usually
obvious once it is said out loud.</li>
</ul>

<h2 id="logs-and-traces-fail-differently">Logs and traces fail differently</h2>

<p>Logs do not explode in the metrics sense, because a log line does not
pre-aggregate into a time series. Loki, the log store in this stack, indexes a
small set of labels and keeps the rest of each line as compressed text. The log
cost problem is blunter: volume. Every <code class="language-plaintext highlighter-rouge">DEBUG</code> line left on in production, every
health check logged at <code class="language-plaintext highlighter-rouge">INFO</code>, every framework that logs once per database
query, scales linearly with traffic, and storage is priced by the gigabyte
ingested and the days retained.</p>

<p>Three controls compound here. Log-level discipline keeps <code class="language-plaintext highlighter-rouge">DEBUG</code> and <code class="language-plaintext highlighter-rouge">TRACE</code> in
development and short diagnostic windows, not steady-state production.
Deduplication folds a retry loop that would log the same warning on every
attempt into one line with a count. Retention tiering keeps recent data hot and
moves or drops the rest; that is where the multi-month savings come from, more
than trimming what gets logged in the first place.</p>

<p>One cardinality mistake does carry over to logs: a stream label that varies per
request shatters a single log stream into thousands of tiny ones and defeats the
compression that makes Loki affordable. Keep stream labels bounded (service,
environment, level) and let high-cardinality detail live in the line content,
searchable with a query-time filter.</p>

<p>Traces carry the highest per-unit cost of the three, because one trace is a tree
of spans, each with its own attributes, events, and timing, and Tempo stores and
indexes all of it. Capturing every trace is viable at modest traffic and
untenable as volume grows, which is what
<a href="https://patterncatalyst.github.io/otel-observability-tutorial/docs/20-sampling/">sampling</a>
is for: head and tail strategies decide which traces are worth keeping before
they reach storage. Sampling sits upstream of everything else here, so a service
emitting one hundred percent of its traces is the top candidate for runaway
storage regardless of how clean its spans are. On top of sampling, span
attribute hygiene matters: a span that attaches a full request body, or a stack
trace on every exception, adds bytes to every stored span, multiplied by however
many survive sampling.</p>

<h2 id="control-spend-at-the-collector">Control spend at the collector</h2>

<p>Everything above is something a service owner sets at the instrumentation layer:
which labels to add, which log level to run, which attributes to attach. The
<a href="https://patterncatalyst.github.io/otel-observability-tutorial/docs/19-the-collector/">collector</a>
is the second, and often more practical, place to enforce it, because it applies
the decision centrally without redeploying every service that emits telemetry.</p>

<p>A <code class="language-plaintext highlighter-rouge">filter</code> processor drops whole signals, spans, or log records that match a
condition; high-volume, low-value health-check traces are the usual first
target. An <code class="language-plaintext highlighter-rouge">attributes</code> processor in <code class="language-plaintext highlighter-rouge">delete</code> mode strips a specific attribute
as telemetry passes through, which is the practical backstop for cardinality:
even if a library attaches a raw user ID to every span by default, the collector
removes it before export rather than waiting for every team to patch its code.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">processors</span><span class="pi">:</span>
  <span class="na">attributes/drop-user-id</span><span class="pi">:</span>
    <span class="na">actions</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s">user.id</span>
        <span class="na">action</span><span class="pi">:</span> <span class="s">delete</span>
  <span class="na">filter/drop-health-checks</span><span class="pi">:</span>
    <span class="na">traces</span><span class="pi">:</span>
      <span class="na">span</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s1">'</span><span class="s">attributes["http.route"]</span><span class="nv"> </span><span class="s">==</span><span class="nv"> </span><span class="s">"/healthz"'</span>
</code></pre></div></div>

<p>A <code class="language-plaintext highlighter-rouge">transform</code> processor goes further, rewriting a high-cardinality attribute
into a bucketed or templated form instead of dropping it outright, keeping some
diagnostic value while still bounding the cardinality. For metrics, aggregation
(in the collector, or through SDK views it forwards) lets a team decide a
metric needs an application-wide total rather than a per-pod breakdown, cutting
the series count by the number of replicas without losing the number alerting
depends on.</p>

<figure class="excalidraw-embed">
  <object type="image/svg+xml" data="/patterncatalyst-blog/assets/diagrams/ch21-cost-controls.svg" aria-label="Metrics, logs, and traces flow from services into an OpenTelemetry Collector, where filter, aggregate, and attribute-drop processors reduce volume before it reaches backend storage.">
    Metrics, logs, and traces flow from services into an OpenTelemetry Collector, where filter, aggregate, and attribute-drop processors reduce volume before it reaches backend storage.
  </object><figcaption class="excalidraw-embed__caption">Controlling spend centrally at the collector, across all three signals</figcaption><p class="excalidraw-embed__links">
    <a href="/patterncatalyst-blog/assets/diagrams/ch21-cost-controls.excalidraw" download="">Download .excalidraw source</a>
    <span aria-hidden="true">·</span>
    <a href="https://excalidraw.com" target="_blank" rel="noopener">Edit on excalidraw.com</a>
  </p>
</figure>

<p>The thread through all of these is central ownership. A platform or
observability team sets retention and cost policy once, in the collector
configuration, and it applies across every service, every language, and every
team, whichever ones were careful about cardinality and which were not. That is
the payoff of putting a collector in the data path: a direct SDK-to-backend
pipeline has no interception point, so every cost control becomes a
per-service, per-language exercise instead of one shared policy.</p>

<h2 id="the-habit-worth-keeping">The habit worth keeping</h2>

<p>Cost control in observability is not a single switch. It is the sum of small
decisions: a bounded label, a sane log level, a sampling rate matched to
traffic, a collector processor that catches what instrumentation missed. None of
them is expensive on its own. What is expensive is discovering that all three
signals have been growing unbounded at once, usually the same morning a billing
alert fires or a backend starts rejecting writes under load.</p>

<p>So carry one question forward. When you add any label, attribute, or log field,
ask what values it can take and whether that set has a ceiling. When you read a
collector configuration, check whether it forwards everything it receives or
actively decides what is worth keeping. A pipeline with no filtering, no
attribute processing, and no sampling is not a more complete observability
setup. It is a more expensive one, usually without a matching gain in anything a
human can use while debugging a live incident.</p>

<hr />

<p><strong>Source project.</strong> This post is drawn from the
<a href="https://patterncatalyst.github.io/otel-observability-tutorial/docs/21-cardinality-and-cost/">cardinality and cost chapter</a>
of the
<a href="https://patterncatalyst.github.io/otel-observability-tutorial/">OpenTelemetry Observability Tutorial</a>,
which builds all five signals (traces, metrics, logs, baggage, and profiles)
across Spring Boot, Quarkus, and Python on one Grafana LGTM stack. The
<a href="https://patterncatalyst.github.io/otel-observability-tutorial/docs/20-sampling/">sampling chapter</a>
covers trace volume in depth, and the
<a href="https://patterncatalyst.github.io/otel-observability-tutorial/docs/19-the-collector/">collector chapter</a>
covers the data-path pipeline these cost controls plug into. Full source is on
<a href="https://github.com/patterncatalyst/otel-observability-tutorial">GitHub</a>.</p>]]></content><author><name>Pattern Catalyst</name></author><category term="observability" /><category term="observability" /><category term="opentelemetry" /><category term="cardinality" /><category term="cost" /><category term="metrics" /><summary type="html"><![CDATA[A metric isn't one number, it's a family of time series, one per unique label combination. Add a single unbounded label and a cheap counter becomes a six-figure line item. Here's how cardinality drives observability cost, and where to control it.]]></summary></entry><entry><title type="html">L7 Routing Is a Four-Layer Decision Chain, Not an Appliance in a Rack</title><link href="https://patterncatalyst.github.io/patterncatalyst-blog/2026/10/07/l7-routing-for-cloud-native-apps/" rel="alternate" type="text/html" title="L7 Routing Is a Four-Layer Decision Chain, Not an Appliance in a Rack" /><published>2026-10-07T00:00:00+00:00</published><updated>2026-10-07T00:00:00+00:00</updated><id>https://patterncatalyst.github.io/patterncatalyst-blog/2026/10/07/l7-routing-for-cloud-native-apps</id><content type="html" xml:base="https://patterncatalyst.github.io/patterncatalyst-blog/2026/10/07/l7-routing-for-cloud-native-apps/"><![CDATA[<p>Layer-7 routing used to mean a load balancer appliance in a rack. It doesn’t anymore. It is
layered software running inside Kubernetes that makes a decision on every HTTP and gRPC
envelope crossing the network, both north/south (clients into the system) and east/west
(service to service). Most of the decisions that affect reliability and delivery speed happen
east/west, not at the edge.</p>

<p>Four layers (edge, gateway, mesh, and the application itself) each see and decide different
things, and most of the work happens deeper than teams expect. The decision chain runs layer
by layer: what the mesh does with TLS, sticky sessions, and traffic steering; what only the
service itself can decide with rule-driven routing; and why east/west is where most routing
lives.</p>

<h2 id="l4-versus-l7-what-each-layer-can-decide-on">L4 versus L7: what each layer can decide on</h2>

<p>A Layer-4 load balancer sees <em>connections</em>: IPs, ports, the TCP 5-tuple. It picks a backend
and hash-balances, but it can’t read URLs, headers, cookies, methods, or gRPC metadata. A
Layer-7 router sees the whole <em>conversation</em>: host, path, headers, cookies, JWT claims, query
parameters, the HTTP method, the gRPC <code class="language-plaintext highlighter-rouge">service.method</code> pair. Every routing decision below
operates on that envelope.</p>

<p>TLS is the one nuance to keep in mind. An L4 balancer can route by SNI <em>without</em> terminating
TLS, which is cheap but opaque. An L7 router typically terminates TLS so it can read
everything else, which is richer but slightly slower.</p>

<figure class="excalidraw-embed">
  <object type="image/svg+xml" data="/patterncatalyst-blog/assets/diagrams/l7-four-layer-stack.svg" aria-label="North/south: a client passes through edge/ingress (TLS, host/path), then the API gateway (auth, rate limit), then a mesh sidecar (mTLS, retries), then the service (in-app rules). East/west: service A and service B talk through mesh sidecars that add mTLS, outlier detection, and locality routing. Each layer makes a different kind of decision and emits a trace span.">
    North/south: a client passes through edge/ingress (TLS, host/path), then the API gateway (auth, rate limit), then a mesh sidecar (mTLS, retries), then the service (in-app rules). East/west: service A and service B talk through mesh sidecars that add mTLS, outlier detection, and locality routing. Each layer makes a different kind of decision and emits a trace span.
  </object><figcaption class="excalidraw-embed__caption">L7 routing is a four-layer stack; the mesh applies the same routing to every internal call, not just at the edge</figcaption><p class="excalidraw-embed__links">
    <a href="/patterncatalyst-blog/assets/diagrams/l7-four-layer-stack.excalidraw" download="">Download .excalidraw source</a>
    <span aria-hidden="true">·</span>
    <a href="https://excalidraw.com" target="_blank" rel="noopener">Edit on excalidraw.com</a>
  </p>
</figure>

<h2 id="the-four-layer-stack">The four-layer stack</h2>

<p>L7 routing lives in four layers, and each handles a <em>different kind</em> of decision. A request
is a chain of independent L7 decisions across all four.</p>

<p>The <strong>edge / ingress</strong> layer (HAProxy, Nginx, Envoy, Traefik) is where TLS terminates and
coarse host/path routing happens. The <strong>API gateway</strong> (Kong, Apigee, an Istio gateway, Spring
Cloud Gateway) handles product-level concerns: authentication, rate limiting, and
transformation. The <strong>service mesh</strong> (Istio with Envoy sidecars, or Linkerd) governs
east/west traffic, giving every internal call mTLS, retries, outlier detection, and
locality-aware load balancing. When a decision depends on business rules the platform can’t
know, routing pushes into the <strong>app</strong> itself. Each layer can refuse, transform, or branch the
request, and each emits a trace span, so the request’s journey is observable end to end.</p>

<h2 id="content-based-routing">Content-based routing</h2>

<p>HAProxy and Nginx are the workhorses of the edge: mature, fast, and predictable. HAProxy reads
the envelope with ACLs (<code class="language-plaintext highlighter-rouge">path_beg</code> for URL prefixes, <code class="language-plaintext highlighter-rouge">hdr</code> for headers, <code class="language-plaintext highlighter-rouge">content-type</code> for
the body kind) and <code class="language-plaintext highlighter-rouge">use_backend</code> dispatches on them; Nginx uses <code class="language-plaintext highlighter-rouge">location</code> blocks and the
<code class="language-plaintext highlighter-rouge">map</code> directive to turn a header value into an upstream pool. Envoy is the modern sidecar
choice because it speaks HTTP/2 and gRPC natively.</p>

<p>But the form most teams reach for today is the mesh’s: the same content-based primitives
expressed as Kubernetes CRDs that Istio compiles down to Envoy config across every sidecar.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">networking.istio.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">VirtualService</span>
<span class="na">metadata</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">name</span><span class="pi">:</span> <span class="nv">orders</span> <span class="pi">}</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">hosts</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">orders</span><span class="pi">]</span>
  <span class="na">http</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">match</span><span class="pi">:</span>                                    <span class="c1"># gRPC by service.method</span>
    <span class="pi">-</span> <span class="na">uri</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">prefix</span><span class="pi">:</span> <span class="nv">/acme.orders.v1.OrderService/</span> <span class="pi">}</span>
    <span class="na">route</span><span class="pi">:</span> <span class="pi">[{</span> <span class="nv">destination</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">host</span><span class="pi">:</span> <span class="nv">orders</span><span class="pi">,</span> <span class="nv">subset</span><span class="pi">:</span> <span class="nv">v2</span> <span class="pi">}</span> <span class="pi">}]</span>
  <span class="pi">-</span> <span class="na">match</span><span class="pi">:</span>                                    <span class="c1"># by header</span>
    <span class="pi">-</span> <span class="na">headers</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">x-internal</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">exact</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1"</span> <span class="pi">}</span> <span class="pi">}</span>
    <span class="na">route</span><span class="pi">:</span> <span class="pi">[{</span> <span class="nv">destination</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">host</span><span class="pi">:</span> <span class="nv">orders</span><span class="pi">,</span> <span class="nv">subset</span><span class="pi">:</span> <span class="nv">v2</span> <span class="pi">}</span> <span class="pi">}]</span>
  <span class="pi">-</span> <span class="na">match</span><span class="pi">:</span>                                    <span class="c1"># by cookie</span>
    <span class="pi">-</span> <span class="na">headers</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">cookie</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">regex</span><span class="pi">:</span> <span class="s2">"</span><span class="s">.*canary=on.*"</span> <span class="pi">}</span> <span class="pi">}</span>
    <span class="na">route</span><span class="pi">:</span> <span class="pi">[{</span> <span class="nv">destination</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">host</span><span class="pi">:</span> <span class="nv">orders</span><span class="pi">,</span> <span class="nv">subset</span><span class="pi">:</span> <span class="nv">v2</span> <span class="pi">}</span> <span class="pi">}]</span>
  <span class="pi">-</span> <span class="na">route</span><span class="pi">:</span> <span class="pi">[{</span> <span class="nv">destination</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">host</span><span class="pi">:</span> <span class="nv">orders</span><span class="pi">,</span> <span class="nv">subset</span><span class="pi">:</span> <span class="nv">v1</span> <span class="pi">}</span> <span class="pi">}]</span>   <span class="c1"># default</span>
</code></pre></div></div>

<p>Three match rules, by URI prefix (which is how you route on a gRPC <code class="language-plaintext highlighter-rouge">service.method</code>, since
gRPC paths look like <code class="language-plaintext highlighter-rouge">/package.Service/Method</code>), by header, and by cookie, each send to a
different subset. The default route at the bottom is <em>critical</em>: without it, unmatched
requests have nowhere to go. The CRD lives in Git, gets reviewed like any other change, and
Istio reconfigures every sidecar within seconds.</p>

<p>The runnable example backing this post uses a lighter touch at the edge: plain Envoy weighted
clusters and a header override, with no CRDs required:
<a href="https://github.com/patterncatalyst/cloud-native-design-patterns/blob/main/examples/22-l7-routing/envoy/envoy.yaml"><code class="language-plaintext highlighter-rouge">examples/22-l7-routing/envoy/envoy.yaml</code></a>.</p>

<h2 id="tls-termination">TLS termination</h2>

<p>TLS at the edge comes in three modes. <strong>Terminate-and-decrypt</strong> is the common case; it lets
the router do everything L7. <strong>Terminate-and-re-encrypt</strong> is the most secure default in a
mesh, since mTLS inside is automatic. <strong>Passthrough</strong> is for traffic you must not see (client
mTLS for regulated flows): route by SNI only, decrypt nothing. cert-manager is the standard
answer for public certificates; SPIFFE-style identity and Envoy’s SDS handle
service-to-service certs automatically inside the mesh. A handshake is milliseconds, so
amortise it with session resumption, TLS 1.3, and HTTP/2 multiplexing.</p>

<h2 id="sticky-sessions">Sticky sessions</h2>

<p>There are three affinity modes, and the default is the one you want. <strong>No affinity</strong> sends
any request to any pod, which keeps load even and assumes client state lives elsewhere (Redis,
the JWT, the database). <strong>Cookie-based</strong> stickiness is the fallback when something stateful
must stay on one pod: the balancer sets a cookie and routes subsequent requests back to the
same pod. That ties the session to one pod’s lifetime, so a restart loses it. <strong>IP-hash</strong>
needs no cookie but is fragile behind NAT and mobile networks, where many users share an
address and load skews.</p>

<p>The real cost is that stickiness fights elastic scaling, fast restarts, and graceful
shutdown, which together are the entire cloud-native delivery model. Treat it as a deliberate
choice for a known constraint, never a default, and prefer to externalise state (Redis for
session data, JWT claims for identity, a pub/sub backplane for fan-out) so the balancer can
return to no-affinity.</p>

<h2 id="intelligent-traffic-steering">Intelligent traffic steering</h2>

<p>Six familiar release shapes are all the same L7 primitive with different inputs and weights:
canary (a small weighted slice to a new version), blue/green (two environments flipped
atomically), A/B (a stable cohort mapped to a variant by a user id), header-based dark
launches (route opted-in users by a header), geo/latency (EU users to EU pods), and
shadow/mirror (copy a percentage of real traffic to a new version without returning its
responses). The canonical one is the weighted canary: a <code class="language-plaintext highlighter-rouge">VirtualService</code> splits traffic by
weight, and a <code class="language-plaintext highlighter-rouge">DestinationRule</code> defines the subsets by pod label.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">networking.istio.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">VirtualService</span>
<span class="na">metadata</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">name</span><span class="pi">:</span> <span class="nv">orders</span> <span class="pi">}</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">hosts</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">orders</span><span class="pi">]</span>
  <span class="na">http</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">route</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">destination</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">host</span><span class="pi">:</span> <span class="nv">orders</span><span class="pi">,</span> <span class="nv">subset</span><span class="pi">:</span> <span class="nv">v1</span> <span class="pi">}</span>
      <span class="na">weight</span><span class="pi">:</span> <span class="m">90</span>                              <span class="c1"># 90% on the stable version</span>
    <span class="pi">-</span> <span class="na">destination</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">host</span><span class="pi">:</span> <span class="nv">orders</span><span class="pi">,</span> <span class="nv">subset</span><span class="pi">:</span> <span class="nv">v2</span> <span class="pi">}</span>
      <span class="na">weight</span><span class="pi">:</span> <span class="m">10</span>                              <span class="c1"># 10% on the canary</span>
<span class="nn">---</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">networking.istio.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">DestinationRule</span>
<span class="na">metadata</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">name</span><span class="pi">:</span> <span class="nv">orders</span> <span class="pi">}</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">host</span><span class="pi">:</span> <span class="s">orders</span>
  <span class="na">subsets</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">v1</span>
    <span class="na">labels</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">version</span><span class="pi">:</span> <span class="nv">v1</span> <span class="pi">}</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">v2</span>
    <span class="na">labels</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">version</span><span class="pi">:</span> <span class="nv">v2</span> <span class="pi">}</span>
</code></pre></div></div>

<p>Ramping the canary means changing the weights in Git (typically 10, 25, 50, 100 percent, with
metric gates between steps), and a controller like Flagger or Argo Rollouts flips the weights
back in seconds if error rate or latency on <code class="language-plaintext highlighter-rouge">v2</code> crosses a threshold. Header-based steering
adds precision: an upstream identity proxy sets a header for opted-in users, and the
<code class="language-plaintext highlighter-rouge">VirtualService</code> routes <em>those</em> users to <code class="language-plaintext highlighter-rouge">v2</code> on real production data with zero blast radius.</p>

<p>Payload modification is cheap: header inject/strip and URL rewrites are nearly free because
the router is already parsing the request line and headers. (This is how the W3C <code class="language-plaintext highlighter-rouge">traceparent</code>
header propagates, and how a gateway validates a JWT once and injects trusted claims
downstream.) Body transformation is expensive: it forces the router to buffer the full
payload, which kills streaming and risks memory under load. The rule of thumb: if you can do
it in the headers, do it in the headers.</p>

<h2 id="routing-inside-the-app-by-business-rules">Routing inside the app by business rules</h2>

<p>There’s a threshold where routing leaves the network and enters the service. If the decision
fits in a few YAML lines and depends only on the envelope, do it at the mesh. If it depends
on the <em>payload</em>, requires a decision table that domain experts edit, or involves many
intersecting conditions (routing a healthcare message by patient class and encounter type,
say), push it into a rule engine inside the app, behind the mesh, rather than a network CRD.
The discipline is “data, not code”: the routing logic lives in an externalised ruleset the
domain experts own, separate from the dispatcher, and changes without redeploying the service.</p>

<p>Each ecosystem composes this from its own rule engine (usually a Rete forward-chaining engine)
feeding a small dispatcher. The ruleset reads as a set of “when … then …” statements that set
a list of destinations:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># orders_rules.py  —  edited by domain experts, not developers (durable-rules)
</span><span class="kn">from</span> <span class="n">durable.lang</span> <span class="kn">import</span> <span class="n">ruleset</span><span class="p">,</span> <span class="n">when_all</span><span class="p">,</span> <span class="n">m</span>

<span class="k">with</span> <span class="nf">ruleset</span><span class="p">(</span><span class="sh">"</span><span class="s">orders</span><span class="sh">"</span><span class="p">):</span>

    <span class="nd">@when_all</span><span class="p">((</span><span class="n">m</span><span class="p">.</span><span class="n">amount_cents</span> <span class="o">&gt;</span> <span class="mi">50_000</span><span class="p">)</span> <span class="o">&amp;</span> <span class="p">(</span><span class="n">m</span><span class="p">.</span><span class="n">tier</span> <span class="o">==</span> <span class="sh">"</span><span class="s">VIP</span><span class="sh">"</span><span class="p">))</span>
    <span class="k">def</span> <span class="nf">vip</span><span class="p">(</span><span class="n">c</span><span class="p">):</span>                              <span class="c1"># high-value VIP orders
</span>        <span class="n">c</span><span class="p">.</span><span class="n">m</span><span class="p">.</span><span class="n">where_to</span> <span class="o">=</span> <span class="p">[</span><span class="sh">"</span><span class="s">kafka:orders.priority</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">http:notify-vip</span><span class="sh">"</span><span class="p">]</span>

    <span class="nd">@when_all</span><span class="p">(</span><span class="n">m</span><span class="p">.</span><span class="n">region</span> <span class="o">==</span> <span class="sh">"</span><span class="s">EU</span><span class="sh">"</span><span class="p">)</span>
    <span class="k">def</span> <span class="nf">eu</span><span class="p">(</span><span class="n">c</span><span class="p">):</span>                               <span class="c1"># European orders → EU region
</span>        <span class="n">c</span><span class="p">.</span><span class="n">m</span><span class="p">.</span><span class="n">where_to</span> <span class="o">=</span> <span class="p">[</span><span class="sh">"</span><span class="s">kafka:orders.eu</span><span class="sh">"</span><span class="p">]</span>

    <span class="nd">@when_all</span><span class="p">(</span><span class="o">+</span><span class="n">m</span><span class="p">.</span><span class="n">order_id</span><span class="p">)</span>                   <span class="c1"># catch-all (lowest priority)
</span>    <span class="k">def</span> <span class="nf">default</span><span class="p">(</span><span class="n">c</span><span class="p">):</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="nf">getattr</span><span class="p">(</span><span class="n">c</span><span class="p">.</span><span class="n">m</span><span class="p">,</span> <span class="sh">"</span><span class="s">where_to</span><span class="sh">"</span><span class="p">,</span> <span class="bp">None</span><span class="p">):</span>
            <span class="n">c</span><span class="p">.</span><span class="n">m</span><span class="p">.</span><span class="n">where_to</span> <span class="o">=</span> <span class="p">[</span><span class="sh">"</span><span class="s">kafka:orders.default</span><span class="sh">"</span><span class="p">]</span>
</code></pre></div></div>

<p>The dispatcher enriches the input with whatever context the rules need, evaluates the
ruleset, and fans out to each destination over the right transport. The pattern is the
classic content-based router from the enterprise-integration literature. The value is that the
<em>decision</em> is data a domain expert edits, not code a developer redeploys.</p>

<p>The runnable version of this dispatcher is a FastAPI service that routes VIP orders to a
priority topic and lets you change the threshold at runtime with no redeploy:
<a href="https://github.com/patterncatalyst/cloud-native-design-patterns/blob/main/examples/22-l7-routing/python/router-service/main.py"><code class="language-plaintext highlighter-rouge">examples/22-l7-routing/python/router-service/main.py</code></a>.</p>

<h2 id="the-cost-of-l7-hops">The cost of L7 hops</h2>

<p>Every L7 capability costs time, and it’s a budget you spend before your service even runs.
TLS termination is sub-2ms, path/header routing sub-1ms, JWT validation a few ms, a WAF a few
more, each mesh sidecar about a millisecond, a rate-limit Redis lookup a couple of ms. Body
inspection is the outlier at roughly 5–50ms depending on size.</p>

<p>The working heuristic is to stay under about 10ms of total L7 on hot paths. With four layers
(edge, gateway, sidecar out, sidecar in), that leaves little room per layer. The common
pitfalls follow from this: too many hops nobody measured until p99 spiked; expensive regex
matches; sticky sessions fighting the platform; body inspection blowing the budget;
per-request auth lookups cascading under load; and timeout misalignment, which is the
gateway-to-mesh-to-service version of a retry storm. The fix is the same one that fixes retry
storms: bound retries, align timeouts top-down, and give the deepest layer the strictest
budget.</p>

<h2 id="eastwest-l7-between-services">East/west: L7 between services</h2>

<p>Most L7 decisions in a microservices system aren’t at the edge; they happen east/west, every
time one service calls another. The mesh’s sidecar pair handles that traffic with no app
code: mTLS (encryption <em>and</em> identity), HTTP/2 multiplexing, native gRPC, retries with
backoff and jitter, outlier detection that ejects misbehaving pods, locality-aware load
balancing that prefers same-zone pods, and per-call traces. Same Istio CRDs as the edge,
pointed at an internal host:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">networking.istio.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">DestinationRule</span>
<span class="na">metadata</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">name</span><span class="pi">:</span> <span class="nv">payments</span> <span class="pi">}</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">host</span><span class="pi">:</span> <span class="s">payments.acme.svc.cluster.local</span>
  <span class="na">trafficPolicy</span><span class="pi">:</span>
    <span class="na">connectionPool</span><span class="pi">:</span>
      <span class="na">http</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">http2MaxRequests</span><span class="pi">:</span> <span class="nv">1000</span><span class="pi">,</span> <span class="nv">maxRequestsPerConnection</span><span class="pi">:</span> <span class="nv">10</span> <span class="pi">}</span>
    <span class="na">outlierDetection</span><span class="pi">:</span>                              <span class="c1"># eject bad pods</span>
      <span class="na">consecutive5xxErrors</span><span class="pi">:</span> <span class="m">5</span>
      <span class="na">interval</span><span class="pi">:</span> <span class="s">30s</span>
      <span class="na">baseEjectionTime</span><span class="pi">:</span> <span class="s">30s</span>
    <span class="na">loadBalancer</span><span class="pi">:</span>
      <span class="na">localityLbSetting</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">enabled</span><span class="pi">:</span> <span class="nv">true</span> <span class="pi">}</span>         <span class="c1"># prefer same zone</span>
  <span class="na">subsets</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="pi">{</span> <span class="nv">name</span><span class="pi">:</span> <span class="nv">v1</span><span class="pi">,</span> <span class="nv">labels</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">version</span><span class="pi">:</span> <span class="nv">v1</span> <span class="pi">}</span> <span class="pi">}</span>
  <span class="pi">-</span> <span class="pi">{</span> <span class="nv">name</span><span class="pi">:</span> <span class="nv">v2</span><span class="pi">,</span> <span class="nv">labels</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">version</span><span class="pi">:</span> <span class="nv">v2</span> <span class="pi">}</span> <span class="pi">}</span>
<span class="nn">---</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">networking.istio.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">VirtualService</span>
<span class="na">metadata</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">name</span><span class="pi">:</span> <span class="nv">payments</span> <span class="pi">}</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">hosts</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">payments.acme.svc.cluster.local</span><span class="pi">]</span>            <span class="c1"># internal DNS — east/west</span>
  <span class="na">http</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">route</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="pi">{</span> <span class="nv">destination</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">host</span><span class="pi">:</span> <span class="nv">payments</span><span class="pi">,</span> <span class="nv">subset</span><span class="pi">:</span> <span class="nv">v1</span> <span class="pi">},</span> <span class="nv">weight</span><span class="pi">:</span> <span class="nv">95</span> <span class="pi">}</span>
    <span class="pi">-</span> <span class="pi">{</span> <span class="nv">destination</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">host</span><span class="pi">:</span> <span class="nv">payments</span><span class="pi">,</span> <span class="nv">subset</span><span class="pi">:</span> <span class="nv">v2</span> <span class="pi">},</span> <span class="nv">weight</span><span class="pi">:</span> <span class="nv">5</span> <span class="pi">}</span>
</code></pre></div></div>

<p>That last block is a canary release of a backend service no external client knows exists,
which you cannot do through an edge load balancer.</p>

<p>gRPC east/west has a few sharp edges. Route on the gRPC URI prefix for per-method canaries.
Prefer least-request load balancing over round-robin, because gRPC connections are long-lived
and round-robining connections (rather than requests) can starve backends. Be careful
retrying streaming RPCs, since replaying a half-finished stream redoes half the work. And
remember gRPC returns its real status in HTTP trailers, so outlier detection has to read the
trailer: a <code class="language-plaintext highlighter-rouge">200</code> with a non-OK gRPC status is a failure, and a check that ignores it would
call a failing service healthy.</p>

<!--
  Additional figures from the source appendix — the L7 journey, sticky-session
  affinity modes, the six traffic-steering shapes, and east/west sidecar
  mechanics — are available as paired SVG + .excalidraw assets in the source
  project if this post grows into a series. Pattern for adding one here:

  <figure class="excalidraw-embed">
  <object type="image/svg+xml"
          data="/patterncatalyst-blog/assets/diagrams/%3Cname%3E.svg"
          aria-label="&lt;describe what the diagram shows&gt;">
    &lt;describe what the diagram shows&gt;
  </object><figcaption class="excalidraw-embed__caption">&lt;figure caption&gt;</figcaption><p class="excalidraw-embed__links">
    <a href="/patterncatalyst-blog/assets/diagrams/%3Cname%3E.excalidraw" download>Download .excalidraw source</a>
    <span aria-hidden="true">·</span>
    <a href="https://excalidraw.com" target="_blank" rel="noopener">Edit on excalidraw.com</a>
  </p>
</figure>

-->

<h2 id="when-to-use-which-layer">When to use which layer</h2>

<p>Each layer has a job it does well, and pushing work to the wrong one is where complexity comes
from. Edge/ingress for the things every client crosses (TLS, host/path). API gateway for
product-shaped policy (auth, rate limits, plans). Mesh for east/west and everything internal.
In-app for business rules. This split prevents the slow growth of baroque <code class="language-plaintext highlighter-rouge">VirtualService</code>
configs that encode rules domain experts need to maintain: if it’s a business rule, it belongs
in a rule engine inside a service, not a network CRD. The organisational failure to avoid is
the cluster that ends up running three ingress controllers because three teams each picked
one. Choose one ingress, one gateway, one mesh; manage routing through GitOps; watch
certificate rotation; and load-test the failure paths.</p>

<h3 id="prove-it-yourself">Prove it yourself</h3>

<p>Apply the canary <code class="language-plaintext highlighter-rouge">VirtualService</code>/<code class="language-plaintext highlighter-rouge">DestinationRule</code>, drive a few hundred requests with <code class="language-plaintext highlighter-rouge">hey</code>,
and confirm the split lands near the configured weight: roughly 10% of responses carrying the
<code class="language-plaintext highlighter-rouge">v2</code> build marker (a response header or version field), the rest <code class="language-plaintext highlighter-rouge">v1</code>. Bump the weight in Git,
re-apply, and watch the ratio move within seconds. Then flip a request’s <code class="language-plaintext highlighter-rouge">x-internal</code> header
and confirm it goes to <code class="language-plaintext highlighter-rouge">v2</code> every time regardless of weight, since header match beats weight.</p>

<p>For the in-app router: post one VIP order over the configured threshold and one ordinary
order, and confirm the VIP order landed on <code class="language-plaintext highlighter-rouge">orders.priority</code> (and triggered the <code class="language-plaintext highlighter-rouge">notify-vip</code>
HTTP call) while the ordinary one fell through to <code class="language-plaintext highlighter-rouge">orders.default</code>. Then change only the
ruleset, not the dispatcher, and confirm the routing changes without a redeploy. Weights that
track the config, and a ruleset that can move routing without a code change, are the two halves
of L7 routing working. The full runnable version is Envoy config plus a FastAPI dispatcher,
with a <code class="language-plaintext highlighter-rouge">verify.sh</code> that checks all of it, in
<a href="https://github.com/patterncatalyst/cloud-native-design-patterns/tree/main/examples/22-l7-routing/"><code class="language-plaintext highlighter-rouge">examples/22-l7-routing/</code></a>
(<a href="https://github.com/patterncatalyst/cloud-native-design-patterns/blob/main/examples/22-l7-routing/README.md">README</a>).</p>

<hr />

<p><strong>Source project:</strong> this post is adapted from Appendix I of
<a href="https://patterncatalyst.github.io/cloud-native-design-patterns/">Cloud-Native Design Patterns</a>, where it’s part of a
larger tutorial covering event-driven architecture, sagas, observability, and the rest of
the cloud-native API toolbox.</p>]]></content><author><name>Pattern Catalyst</name></author><category term="cloud-native" /><category term="cloud-native" /><category term="l7-routing" /><category term="kubernetes" /><category term="api-gateway" /><category term="service-mesh" /><summary type="html"><![CDATA[In 2026, L7 routing is no longer a box in a rack. It is layered software (edge, gateway, mesh, app) that decides on every HTTP and gRPC envelope, and the layer that owns each decision is what you need to get right.]]></summary></entry></feed>