<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Architecture Weekly]]></title><description><![CDATA[Weekly Software Architecture resources to boost your knowledge and developer skills.]]></description><link>https://www.architecture-weekly.com</link><image><url>https://substackcdn.com/image/fetch/$s_!dCx9!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F3ab6cef1-9ce5-47fa-9306-17854b383dc1_358x358.png</url><title>Architecture Weekly</title><link>https://www.architecture-weekly.com</link></image><generator>Substack</generator><lastBuildDate>Wed, 29 Jul 2026 00:39:02 GMT</lastBuildDate><atom:link href="https://www.architecture-weekly.com/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Oskar Dudycz]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[architectureweekly@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[architectureweekly@substack.com]]></itunes:email><itunes:name><![CDATA[Oskar Dudycz]]></itunes:name></itunes:owner><itunes:author><![CDATA[Oskar Dudycz]]></itunes:author><googleplay:owner><![CDATA[architectureweekly@substack.com]]></googleplay:owner><googleplay:email><![CDATA[architectureweekly@substack.com]]></googleplay:email><googleplay:author><![CDATA[Oskar Dudycz]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[Fixing bugs in Event Sourcing is hard, for real?]]></title><description><![CDATA[Every system ends up with bad data.]]></description><link>https://www.architecture-weekly.com/p/fixing-bugs-in-event-sourcing-is</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/fixing-bugs-in-event-sourcing-is</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 27 Jul 2026 14:16:43 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!3jWN!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3dbca68a-8f4a-4629-8c8c-beba6ec0e3ef_1000x541.gif" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every system ends up with bad data. Some of it comes from integrations, some from users doing things nobody predicted, and a good part of it comes from us, because we shipped a change that looked fine in review.</p><p>This is about what we can do on Tuesday morning, when someone from support writes: <em>&#8220;the price on this reservation looks wrong&#8221;</em>.</p><h2>The bug</h2><p>Imagine we&#8217;re working on a hotel reservations system. Pricing there is fiddly: rate plans get revised, extras are added after booking, and the city sets the tourist tax on its own schedule.</p><p>Let&#8217;s say that the tourist tax used to be a single value in configuration, 4.50 per person per night, multiplied by nights and guests. That was enough for years, because the rate never moved.</p><p>Then the city announced an increase to 7.00 from 1 April. It appeared that one value can&#8217;t express that: a stay from 30 March to 2 April has nights on both sides of the boundary. So we added rates with validity periods and a lookup to resolve them, and shipped it on 12 March. </p><p>The lookup resolves the rate once per reservation, from the check-out date, and applies it to every night. For a stay inside a single period that gives the right answer, which is every case the tests covered. For a stay crossing 1 April, it charges 7.00 for the March nights too. Crazy, but well, that&#8217;s how life is sometimes.</p><p>A rate change has people checking numbers they&#8217;d normally trust, and on 19 March a front office manager reported that the projected tax for early-April arrivals doesn&#8217;t match what she gets counting by hand. We confirmed it and deployed a fix at 16:02 the same day.</p><p>Then the actual work starts. Which reservations are wrong, by how much, and what do we do about them?</p><h2>What we have, with state</h2><p>Take the reservation support asked about. Seven nights, two guests, 27 March to 3 April, booked on 14 March, breakfast added two days later. Here&#8217;s the row:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">| id&#9;| check_in   | check_out  | rate_plan_id | total_amount&#9;| updated_at       |
|-------|------------|------------|--------------|--------------|------------------|
| 8f21&#9;| 2026-03-27 | 2026-04-03 | RP-STD-2026&#9; | 1358.00&#9;| 2026-03-16 14:02 |</code></pre></div><p>The total is wrong. What should it be? The row doesn&#8217;t say how the number was produced: no breakdown of rooms, extras and tax, no record of which nights got which tax rate, and no version on <code>rate_plan_id</code>, so we can&#8217;t tell which prices were in force on 14 March. <code>updated_at</code> says 16 March, which is the breakfast, not the booking.</p><p>So we recompute and write the migration:</p><p>It matches 1,240 rows. We run it at 17:30 on 19 March.</p><h2>What that migration does</h2><p>Around 900 of those rows genuinely came from the broken build. The other 340 were booked before 12 March, had correct totals, and were caught because someone changed a room or added a note during the window. <code>updated_at</code> moves for all of that, and there&#8217;s nothing in the row that separates &#8220;this changed&#8221; from &#8220;this changed because of the bug&#8221;. We recalculate them along with the rest.</p><p><code>recalculate_total</code> reads the rate plan as it stands today, and the plans were revised on 15 March for the season. RP-STD-2026 went from 180.00 a night to 205.00. For reservation 8f21 that means:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">|&#9;  | before&#9;| after migration   | correct  |
|---------|-------------|-------------------|----------|
| rooms&#9;  | 1260.00&#9;| 1435.00&#9;    | 1260.00  |
| extras  | 168.00&#9;| 168.00&#9;    | 168.00   |
| cityTax | 98.00&#9;| 73.00&#9;            | 73.00    |
| total&#9;  | 1526.00&#9;| 1676.00&#9;    | 1501.00  |</code></pre></div><p>The migration fixed the tax and repriced the rooms. It did that to every reservation booked before 15 March, roughly 400 of them, and each guest gets a confirmation with a total higher than the one they agreed to.</p><h2>And then migration number two</h2><p>Complaints reach us around 2 April, and we work out what happened. Now we need to put the room prices back. To do that, we need two things per reservation: the date it was booked, so we know which rate plan version applied, and what the total was before we touched it.</p><p>We have neither. <code>updated_at</code> reads <code>2026-03-19 17:30</code> for all 1,240 rows we updated, so it no longer distinguishes the ones we broke from the ones we fixed from the ones we shouldn&#8217;t have touched. <code>total_amount</code> holds the value our migration wrote. The booking date was never stored separately, and the only thing that used to approximate it was <code>updated_at</code>.</p><p>So migration two is written against data that migration one produced. We reconstruct booking dates from confirmation emails where we still have them, guess at the rest, and pick a <code>WHERE</code> clause that we hope covers the right subset. If we get that wrong, the input to migration three is the output of migration two. I&#8217;ve been on both ends of this, and it doesn&#8217;t get more pleasant.</p><p>We can prepare for this in a state-based system, of course. Keep the breakdown in a JSON column, store the rate plan version on the reservation, add a <code>price_history</code> table, add a <code>corrected_by</code> marker. I&#8217;ve done all of those. The catch is that we add each one after the incident that taught us to, on the table where that incident happened, and today&#8217;s incident is on a different table. What we&#8217;re building at that point is a partial event log, decided per column, under time pressure.</p><h2>The same bug, event-sourced</h2><p>Here&#8217;s the stream for the same reservation:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">| when         | event                        | data                                                       |
|--------------|------------------------------|------------------------------------------------------------|
| 14 Mar 09:40 | `ReservationInitiated`       | 27 Mar &#8211; 3 Apr, DBL, 2 guests                              |
| 14 Mar 09:41 | `RatePlanApplied`            | RP-STD-2026 v4, 180.00 / night                             |
| 14 Mar 09:41 | `ReservationPriceCalculated` | rooms 1260.00, cityTax 98.00, total 1358.00                |
| 14 Mar 09:41 | `ReservationConfirmed`       |                                                            |
| 16 Mar 14:02 | `ExtraAdded`                 | breakfast, 2 guests, 168.00                                |
| 16 Mar 14:02 | `ReservationPriceCalculated` | rooms 1260.00, extras 168.00, cityTax 98.00, total 1526.00 |</code></pre></div><p>The corrupted value is cityTax, and it&#8217;s sitting there in plain sight. With two guests, a March night costs 9.00 in tax and an April night 14.00, so five March nights and two April nights come to 45.00 + 28.00 = 73.00. The event says 98.00, which is all seven nights at 14.00. Everything else in that event is right, and RatePlanApplied still says the booking was made at 180.00 a night under version 4, so we can calculate the correct total without reconstructing anything:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">| What    | recorded | correct |
|---------|----------|---------|
| cityTax | 98.00    | 73.00   |
| total   | 1526.00  | 1501.00 |</code></pre></div><p>That&#8217;s the difference from the table. The migration above went wrong because the inputs to the original calculation were gone, and here they aren&#8217;t. The 14 March event still says what it said on 14 March, whatever was appended later.</p><h2>Knowing which events to look at</h2><p>The other half is metadata. Here&#8217;s the full event:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;json&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-json">{
  "type": "ReservationPriceCalculated",
  "data": {
    "reservationId": "8f21",
    "nights": [
      { "date": "2026-03-27", "room": 180.00, "cityTax": 14.00 },
      { "date": "2026-03-28", "room": 180.00, "cityTax": 14.00 },
      "...",
      { "date": "2026-04-02", "room": 180.00, "cityTax": 14.00 }
    ],
    "cityTax": 98.00,
    "total": 1358.00
  },
  "metadata": {
    "timestamp": "2026-03-14T09:41:12Z",
    "correlationId": "b31e...",
    "causationId": "a07c...",
    "userId": "booking-engine",
    "buildSha": "a4f9c2e",
    "schemaVersion": 3
  }
}</code></pre></div><p>There&#8217;s one interesting metadata: <code>buildSha</code>. What is it? It&#8217;s the commit the service was running when it appended the event. It&#8217;s an environment variable and a few lines in whatever builds our metadata. On 19 March, it lets us query by the thing we actually care about:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT stream_id, data, metadata
FROM events
WHERE type = 'ReservationPriceCalculated'
  AND metadata-&gt;&gt;'buildSha' = 'a4f9c2e';</code></pre></div><p>That returns 900 events, and they&#8217;re the 900 the broken binary produced. The 340 reservations that were merely touched during the same week don&#8217;t appear, because their price events came from a different commit.</p><p>The same metadata helps with the diagnosis. <code>correlationId</code> groups everything that came from one user action, and <code>causationId</code> chains back to the command that triggered this event. When the bad value comes out of a handler three hops from the request, that chain is how we find which request it was.</p><p>Without the SHA, we&#8217;re still ahead, because the position and timestamp of an appended event don&#8217;t move once it&#8217;s written. Still, I recommend to add the sha.</p><h2>Fix forward, don&#8217;t rewrite</h2><p>The instinct is to go into the store and edit those events. I&#8217;d steer you away from that. Events are immutable, and, surprising as it sounds, having precise history, bugs included, is a valid scenario. It&#8217;s often the only way to see later what really went wrong. I put it this way in the migration section of <a href="https://event-driven.io/en/simple_events_versioning_patterns/">Simple patterns for events schema versioning</a>: <em>&#8220;you should not change the past... even including bugs, is a valid scenario.&#8221;</em></p><p>Instead, we could append a corrective event:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;typescript&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-typescript">type ReservationPriceCorrected = {
  type: 'ReservationPriceCorrected';
  data: {
    reservationId: string;
    previousTotal: number;
    correctedTotal: number;
    reason: string;
  };
};</code></pre></div><p>It&#8217;s the accountant&#8217;s move: we don&#8217;t rub out a ledger line; we post a correcting entry next to it. Then we rebuild the read models so the downstream views show the corrected total, and fix the bug so it stops producing new ones.</p><p>There&#8217;s a side effect that&#8217;s easy to miss. The correction is an event, so the next person to open this stream sees that a bug happened, when it was corrected and why. Nobody has to remember, and if we surface it in the UI, the front desk sees it too.</p><h2>If we get the correction wrong too</h2><p>We might. Say our correction script has its own bug and writes 1501.00 as the room total rather than the reservation total.</p><p>The recovery is the same operation as before, because the 14 March events are still there. We read the original calculation, the rate plan version that applied, our correction and its <code>buildSha</code>, and we append a second correction computed from the same inputs we had the first time. Nothing we did on 19 March destroyed the material we need on 2 April.</p><p>That&#8217;s the loop from the state-based version broken. Each attempt adds a fact rather than replacing the evidence, so attempt three is calculated from the booking, not from attempt two.</p><h2>The customer already fixed it</h2><p>Another reservation, four nights, two guests, 29 March to 2 April, at 210.00 a night. Rooms 840.00, tax charged 56.00, correct tax 41.00.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">| when         | what                            |                                |
|--------------|---------------------------------|--------------------------------|
| 14 Mar 09:41 | `ReservationPriceCalculated`    | cityTax 56.00, total 896.00, build a4f9c2e |
| 15 Mar 11:20 | guest calls the hotel           | "that's not what I was quoted" |
| 15 Mar 11:26 | `ReservationPriceCorrected`     | total 839.00, by anna.k        |
| 17 Mar 08:03 | `ReservationConfirmationResent` |                                |
| 19 Mar 16:02 | we deploy the fix               |                                |
| 19 Mar 17:30 | we run the bulk correction      |                                |</code></pre></div><p>Anna&#8217;s reason for that correction reads: <em>city tax recalculated to 41.00, 5% goodwill on rooms agreed with FO manager</em>. Our recalculation gives 881.00, and it&#8217;s correct by the pricing rules. Her 839.00 is also correct, because she took 42.00 off the rooms to settle an argument she was having on the phone.</p><p>If our bulk correction is a blind recalculation, at 17:30 it sets the reservation back to 881.00 and undoes her. The guest gets a third number in a third email and calls again. Support opens the reservation, sees the current total, and has no idea why it changed or that it was us. Anna&#8217;s note now describes a value that no longer exists.</p><p>With the stream, logically the check could look like in pseudo code (hey TS, no pun intended):</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;typescript&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-typescript">const needsCorrection = (events: ReservationEvent[]) =&gt; {
  const bad = events.findIndex(
    (e) =&gt;
      e.type === 'ReservationPriceCalculated' &amp;&amp;
      e.metadata.buildSha === BROKEN_BUILD,
  );

  if (bad &lt; 0) return false;

  // someone already dealt with it, leave it alone
  return !events
    .slice(bad + 1)
    .some((e) =&gt; e.type === 'ReservationPriceCorrected');
};</code></pre></div><p>Everything it skips goes on a list for a person to look at. If we&#8217;d rather model the goodwill as its own <code>DiscountGranted</code> event, the rule becomes &#8220;skip if any price-affecting event follows the bad one&#8221;, which is the safer version anyway.</p><p>We can write that check because the events are there to check. In the mutable model, a bug and a deliberate correction look identical: a number in a column. There&#8217;s nothing to branch on, unless we can reconstruct intent from an audit table, assuming the support tool writes to one.</p><p>We could do such a check and fix as part of the one-time async job; we could even generalise it and run it as the SQL migration, running it only if it wasn&#8217;t run. We could optimise it by loading multiple reservations at once, then doing bulk append, if our store allows that, but the logical work remains the same. The rest is mostly performance optimisation.</p><h2>Make the correction a feature</h2><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!3jWN!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3dbca68a-8f4a-4629-8c8c-beba6ec0e3ef_1000x541.gif" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!3jWN!,w_424,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3dbca68a-8f4a-4629-8c8c-beba6ec0e3ef_1000x541.gif 424w, https://substackcdn.com/image/fetch/$s_!3jWN!,w_848,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3dbca68a-8f4a-4629-8c8c-beba6ec0e3ef_1000x541.gif 848w, https://substackcdn.com/image/fetch/$s_!3jWN!,w_1272,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3dbca68a-8f4a-4629-8c8c-beba6ec0e3ef_1000x541.gif 1272w, https://substackcdn.com/image/fetch/$s_!3jWN!,w_1456,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3dbca68a-8f4a-4629-8c8c-beba6ec0e3ef_1000x541.gif 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!3jWN!,w_1456,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3dbca68a-8f4a-4629-8c8c-beba6ec0e3ef_1000x541.gif" width="1000" height="541" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/3dbca68a-8f4a-4629-8c8c-beba6ec0e3ef_1000x541.gif&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:541,&quot;width&quot;:1000,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;It's a feature, not a bug\&quot; - Gilfoyle : r/SiliconValleyHBO&quot;,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="It's a feature, not a bug&quot; - Gilfoyle : r/SiliconValleyHBO" title="It's a feature, not a bug&quot; - Gilfoyle : r/SiliconValleyHBO" srcset="https://substackcdn.com/image/fetch/$s_!3jWN!,w_424,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3dbca68a-8f4a-4629-8c8c-beba6ec0e3ef_1000x541.gif 424w, https://substackcdn.com/image/fetch/$s_!3jWN!,w_848,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3dbca68a-8f4a-4629-8c8c-beba6ec0e3ef_1000x541.gif 848w, https://substackcdn.com/image/fetch/$s_!3jWN!,w_1272,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3dbca68a-8f4a-4629-8c8c-beba6ec0e3ef_1000x541.gif 1272w, https://substackcdn.com/image/fetch/$s_!3jWN!,w_1456,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3dbca68a-8f4a-4629-8c8c-beba6ec0e3ef_1000x541.gif 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Better still, treat the corrective operation as a requirement and build it before we need it.</p><p>Ask the business <em>&#8220;how do you fix this today?&#8221;</em>. There&#8217;s almost always an existing answer: a form, a manager&#8217;s approval, a note in the folder. Model that. <code>CorrectReservationPrice</code> as a command, with permissions, validation and a mandatory reason, producing <code>ReservationPriceCorrected</code>.</p><p>Our bulk fix then goes through that command, with the same rules and the same events as when Anna runs it from the front desk. Nobody connects to the production database at 23:00.</p><p>And when the business says something can never happen, the follow-up is still: fine, how often?</p><p>There&#8217;s a harder version of this. I once watched a hotel checkout get stuck on a blocked financial account, which then jammed the entire night audit, with no way out except a migration or a hotfix. That&#8217;s the worst place to end up, telling a customer they can&#8217;t operate until we ship code. Bugs, bad data and stuck processes aren&#8217;t edge cases we can design away, which is the argument in <a href="https://event-driven.io/en/no_it_can_never_happen/">No, it can never happen!</a>: <em>&#8220;We cannot assume that we won&#8217;t have a bug, or our hardware or network won&#8217;t have any random failure.&#8221;</em> And if there&#8217;s no way to correct something inside the application, someone will do it with an <code>UPDATE</code> at 23:00. Read the horror story described there.</p><p>And check <a href="https://event-driven.io/en/what_texting_ex_has_to_do_with_event_driven_design/">What texting your Ex has to do with Event-Driven Design</a>.</p><h2>&#8220;But now the history contains a bug&#8221;</h2><p>Yes. The wrong price was real. It was shown to the guest, it went into a confirmation, it may have reached the invoice and the tax report. A log that shows only the corrected price can&#8217;t explain why the guest called.</p><p>If we need a clean stream later, for a regulator or a migration, still don&#8217;t edit in place. Copy and transform into a new stream, and keep the original.</p><p>None of this stops us shipping the bug, makes repricing 900 reservations free, or spares us the conversations with guests whose total changed twice.</p><p>What we get is better material to work with. We can name the events the broken build produced. We can read the value it wrote, with the inputs beside it. And we can see which reservations someone has already handled, and what they agreed to.</p><p>In a state-based system, fixing data overwrites the evidence we&#8217;d need to check whether the fix worked, which is how one migration ends up repairing the last one. Keeping the record of the mistake is what lets us check our own work afterwards.</p><p>Cheers!</p><p>Oskar</p><p><strong>p.s. Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, and putting pressure on your local government or companies. You can also support Ukraine by donating, e.g. to the <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian </a>organisation, <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">Ambulances for Ukraine</a> or <a href="https://redcross.org.ua/en/">Red Cross</a>.</p>]]></content:encoded></item><item><title><![CDATA[Throw, Result, or neither?]]></title><description><![CDATA[There is one question I keep getting about my event-sourced code: why don&#8217;t I use Result?]]></description><link>https://www.architecture-weekly.com/p/throw-result-or-neither</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/throw-result-or-neither</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 20 Jul 2026 17:42:43 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!BA7F!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0bbaf6e9-4168-4736-aba5-16b3a0dff5d5_1408x768.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!BA7F!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0bbaf6e9-4168-4736-aba5-16b3a0dff5d5_1408x768.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!BA7F!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0bbaf6e9-4168-4736-aba5-16b3a0dff5d5_1408x768.png 424w, https://substackcdn.com/image/fetch/$s_!BA7F!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0bbaf6e9-4168-4736-aba5-16b3a0dff5d5_1408x768.png 848w, https://substackcdn.com/image/fetch/$s_!BA7F!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0bbaf6e9-4168-4736-aba5-16b3a0dff5d5_1408x768.png 1272w, https://substackcdn.com/image/fetch/$s_!BA7F!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0bbaf6e9-4168-4736-aba5-16b3a0dff5d5_1408x768.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!BA7F!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0bbaf6e9-4168-4736-aba5-16b3a0dff5d5_1408x768.png" width="1408" height="768" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/0bbaf6e9-4168-4736-aba5-16b3a0dff5d5_1408x768.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:768,&quot;width&quot;:1408,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:2179829,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://www.architecture-weekly.com/i/207805592?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0bbaf6e9-4168-4736-aba5-16b3a0dff5d5_1408x768.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!BA7F!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0bbaf6e9-4168-4736-aba5-16b3a0dff5d5_1408x768.png 424w, https://substackcdn.com/image/fetch/$s_!BA7F!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0bbaf6e9-4168-4736-aba5-16b3a0dff5d5_1408x768.png 848w, https://substackcdn.com/image/fetch/$s_!BA7F!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0bbaf6e9-4168-4736-aba5-16b3a0dff5d5_1408x768.png 1272w, https://substackcdn.com/image/fetch/$s_!BA7F!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0bbaf6e9-4168-4736-aba5-16b3a0dff5d5_1408x768.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><strong>There is one question I keep getting about my event-sourced code: why don&#8217;t I use </strong><code>Result</code><strong>?</strong></p><p>It usually comes after someone sees a decision throwing an exception. They would put the failure in a <code>Result</code>, leave <code>try/catch</code> at the application boundary, and make the distinction explicit in the return type. That is a fair question, but the return type is only part of the answer.</p><p><strong>I don&#8217;t mind using exceptions. I also return business failures as events.</strong> Which one I choose depends on what the failure means and what the application needs to do with it. What&#8217;s more, I don&#8217;t mind Result either, as long as I&#8217;m using a language where it&#8217;s a native and idiomatic way to use it. Stil&#8230;</p><p><strong>Some failures are worth retaining as business data.</strong> An out-of-stock request can contribute to an unmet-demand report. A declined payment can start another process or be grouped by its reason. An item limit can explain why customers abandon an operation.</p><p>Gojko Adzic calls the systematic use of unexpected or marginal usage patterns to improve a product <strong><a href="https://www.architecture-weekly.com/p/webinar-23-gojko-adzic-on-designing">lizard optimization</a></strong>. Repeated out-of-stock requests can reveal demand we do not serve or a user experience that encourages impossible quantities. Turning every attempt into only a response or an exception log makes that pattern harder to find. An event gives the application data it can query, project, and use in another process.</p><p>Event Sourcing can help take advantage of unwanted and unexpected failures. We can model negative outcomes as events, just like regular ones. Our business logic informs us of what happened, not whether it&#8217;s a success or an error. It&#8217;s just an outcome of our decision.</p><p>So essentially we&#8217;re getting three options to deal with an unexpected scenario.</p><ul><li><p>throw an error (e.g. <code>OutOfStockError)</code> and catch it at the boundary;</p></li><li><p>return a <code>Result</code> with the failure on its error track;</p></li><li><p>return an event (e.g. <code>ProductItemOutOfStock</code>) and decide separately what we do with it.</p></li></ul><p>Having that, my answer is rarely &#8220;just throw&#8221; or &#8220;just return <code>Result</code>.&#8221; Before choosing either, I need to know what should be persisted, what the caller should receive, and whether the failure belongs in the application&#8217;s exception path.</p><h2><strong>An error as a business fact</strong></h2><p>Let&#8217;s say that the customer asked for a quantity we cannot supply. Recording that outcome can be useful even though the cart did not change. The cart may also reach its item limit or fail payment authorisation. These are expected business outcomes, and the decision can describe each one with a distinct event:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;typescript&quot;,&quot;nodeId&quot;:&quot;108b5656-59d9-4f90-89a1-6b1ee365ee63&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-typescript">const addProductItem = (
  command: AddProductItem,
  state: ShoppingCart,
): ProductItemAdded | ProductItemOutOfStock | ShoppingCartItemLimitReached =&gt; {
  if (state.status === &#8220;Confirmed&#8221;)
    throw new IllegalStateError(
      &#8220;Cannot add a product to a confirmed shopping cart&#8221;,
    );

  const { shoppingCartId, productItem, availableQuantity, maximumItems, now } =
    command.data;

  if (productItem.quantity &gt; availableQuantity)
    return {
      type: &#8220;ProductItemOutOfStock&#8221;,
      data: {
        shoppingCartId,
        productId: productItem.productId,
        requestedQuantity: productItem.quantity,
        availableQuantity,
        attemptedAt: now,
      },
    };

  if (state.productItems.length &gt;= maximumItems)
    return {
      type: &#8220;ShoppingCartItemLimitReached&#8221;,
      data: {
        maximumItems,
        requestedProductId: productItem.productId,
      },
    };

  return {
    type: &#8220;ProductItemAdded&#8221;,
    data: { shoppingCartId, productItem },
  };
};</code></pre></div><p><code>ProductItemOutOfStock</code> carries the requested and available quantities. <code>ShoppingCartItemLimitReached</code> carries the configured limit and the product that crossed it.</p><p>We could also model a single <code>ProductItemAddingFailed</code> event instead. It could have a <code>reason</code> string, but then we&#8217;d use the granularity and precision of the information. With distinct event types, a projection, workflow, endpoint, or test can check the relevant outcome directly.</p><p>What&#8217;s more, we could also add a FailedToAddProductToClosedShoppingCart and get rid of throwing entirely. We&#8217;ll get to that, but for now&#8230;</p><h2><strong>Broken rules can throw</strong></h2><p>Not every failure needs a business event. A confirmed cart can no longer be modified, so <code>AddProductItem</code> is not a valid operation for that state. A command without required data is invalid as well. An unavailable event store, a stock-service timeout, or a serialisation error means the operation could not be completed reliably. I use exceptions for these cases.</p><p>The throw happens before an event is returned, so the handler has nothing to append. The exception retains its stack and reaches the application&#8217;s error boundary.</p><p>Some people prefer to use Result and hope for deterministic decision-making. Still, in most popular dev environments (TypeScript, Java, C#, Python, etc.), we can get exceptions. Even in strongly-typed Rust, we can get a panic or other cases. In the presented code <code>loadShoppingCart</code> can reject because of data unavailability, <code>appendToStream</code> can report a concurrency or connection error, and base code library code can throw on a random, stupid null pointer. The application still needs an error boundary somewhere:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;typescript&quot;,&quot;nodeId&quot;:&quot;9d2c4c1d-85a4-4d5f-b6b1-c718f1dc71dc&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-typescript">try {
  const result = await handleAddProductItem(command);
  return mapResultToResponse(result);
} catch (error) {
  return mapToErrorResponse(error);
}</code></pre></div><p>That boundary might be inside WebAPI route, a message handler callback, or wrapped with a conventional error middleware. The remaining choice is whether expected business outcomes should use the same path.</p><h2><strong>What </strong><code>Result</code><strong> would add</strong></h2><p><code>Result</code> puts the success value and the expected failures on separate branches, visible in the type like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;typescript&quot;,&quot;nodeId&quot;:&quot;d44b4b33-9657-4d73-8287-63144f76b404&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-typescript">type Result&lt;Success, Failure&gt; =
  | { success: true; value: Success }
  | { success: false; error: Failure };</code></pre></div><p>The cart decision becomes:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;typescript&quot;,&quot;nodeId&quot;:&quot;8c35d979-8b27-4251-8c21-8584d7ed645f&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-typescript">type AddProductItemFailure =
  | ProductItemOutOfStock
  | ShoppingCartItemLimitReached;

const addProductItem = (
  command: AddProductItem,
  state: ShoppingCart,
): Result&lt;ProductItemAdded, AddProductItemFailure&gt; =&gt; {
  const { shoppingCartId, productItem, availableQuantity, maximumItems, now } =
    command.data;

  if (productItem.quantity &gt; availableQuantity)
    return {
      success: false,
      error: {
        type: &#8220;ProductItemOutOfStock&#8221;,
        data: {
          shoppingCartId,
          productId: productItem.productId,
          requestedQuantity: productItem.quantity,
          availableQuantity,
          attemptedAt: now,
        },
      },
    };

  if (state.productItems.length &gt;= maximumItems)
    return {
      success: false,
      error: {
        type: &#8220;ShoppingCartItemLimitReached&#8221;,
        data: {
          maximumItems,
          requestedProductId: productItem.productId,
        },
      },
    };

  return {
    success: true,
    value: {
      type: &#8220;ProductItemAdded&#8221;,
      data: { shoppingCartId, productItem },
    },
  };
};</code></pre></div><p>Technically it looks more or less the same: <code>ProductItemAdded</code> is returned on the success branch, while the other two errors are returned on the failure branch. The caller checks the branch before returning the event. In this application, adding the product changes the cart and produces a 204 response. Running out of stock or exceeding the limit leaves the cart unchanged and produces a 409 response. The handler appends only the success value:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;typescript&quot;,&quot;nodeId&quot;:&quot;5c539faa-466c-44c4-b8ae-47e2db183b56&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-typescript">const handleAddProductItem = async (
  command: AddProductItem,
): Promise&lt;Result&lt;ProductItemAdded, AddProductItemFailure&gt;&gt; =&gt; {
  const state = await loadShoppingCart(command.data.shoppingCartId);
  const result = addProductItem(command, state);

  if (result.success) 
    return PreconditionFailed();
  
  await eventStore.appendToStream(
    shoppingCartStreamName(command.data.shoppingCartId),
    [result.value],
  );

  return NoContent();
};</code></pre></div><p>If we used event, it could look like that:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;typescript&quot;,&quot;nodeId&quot;:&quot;64d49518-e5e3-4fd6-b8df-c9ad1778b693&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-typescript">const handleAddProductItem = async (
  command: AddProductItem,
): Promise&lt;Result&lt;ProductItemAdded, AddProductItemFailure&gt;&gt; =&gt; {
  const state = await loadShoppingCart(command.data.shoppingCartId);
  const event = addProductItem(command, state);

  if (event.type === &#8220;ProductItemOutOfStock&#8221; || event.type === &#8220;ShoppingCartItemLimitReached&#8221;) 
    return PreconditionFailed();
  
  await eventStore.appendToStream(
    shoppingCartStreamName(command.data.shoppingCartId),
    [result.value],
  );

  return NoContent();
};</code></pre></div><p>Both versions preserve the same three event types. <code>Result</code> adds a success-or-failure classification around them. Which can be fine, but if you look again at the sample, it&#8217;s still pretty easy to ignore the different consequences of the events. We&#8217;re just one check on success further from it.</p><p>I saw many codebases where people were just blindly muting all errors by default, getting no benefit from it, but just polluting the codebase. Also keeping in mind that they still need to wrap the codebase with try/catch and have a conventional mapping layer.</p><p>We can also see that the Result wrapper in our case doesn&#8217;t add much besides success/error classification. If we&#8217;d like to return different response status based on the <em>failure</em> scenario, we&#8217;d need to add the same switch. So we&#8217;re not removing the decision; we&#8217;re just making it easier to ignore.<br><br>Of course, many languages made it streamlined by adding pipe operator etc. Yet, again, many popular environments don&#8217;t have it. TypeScript has no pipe operator; the <a href="https://github.com/tc39/proposal-pipeline-operator">TC39 proposal</a> is not (yet?) part of the language. Repeating the branch adds checks throughout the call chain; introducing a helper library adds vocabulary that is not native to the language. Those libraries are extremely noisy and require tribal knowledge and onboarding.</p><p>Scott Wlaschin, who popularised railway-oriented programming, <a href="https://fsharpforfunandprofit.com/posts/against-railway-oriented-programming/">makes the same qualification</a>: <code>Result</code> models expected alternatives, but it is not intended to wrap every function or replace exceptions.</p><p><strong>Same for events; we should always discuss with the business if this information is important for them and if they want to keep it.</strong> We should also ask what should happen and if we can and should recover in our business application when this scenario happens.</p><p><code>ProductItemAdded</code>, <code>ProductItemOutOfStock</code>, and <code>ShoppingCartItemLimitReached</code> just tell what has happened. Domain doesn&#8217;t need to care how the application handles it. It can mean failure for the current request and still be worth recording because a projection or workflow consumes it. Adding those rules to <code>Result</code> would move persistence concerns into the domain decision and add premature classification. When we change the behaviour in the application and record one of those scenarios, we would need to change domain code, even if business rules are the same. Just to formally move one case from failure to success.</p><p>Adding <code>Result</code> changes every calling layer without replacing the persistence rules, batch handling, or exception boundary. I do not see much of a gain here. Of course, a codebase that already uses <code>Result</code> as its error channel may judge it differently, especially when the alternatives are not events. Thus&#8230;</p><h2><strong>Returning an event doesn&#8217;t mean persisting it</strong></h2><p><code>ProductItemOutOfStock</code> might be needed after the request. Measuring unmet demand, suggesting alternatives, or showing failed attempts to support staff requires a durable event. Still, if our business doesn&#8217;t care about the fact and we just want to log it, or forward to the HTTP Response, then we may not need to record it. Appending it would retain data the application does not need. Omitting it from the decision would instead force the endpoint to reread the outcome.</p><p>For this example, the decision returns <code>ProductItemOutOfStock</code> to the caller and the stream remains unchanged. I call this <strong>selective persistence</strong>: the decision describes the outcome, while the application decides whether it should be durable.</p><p>Command Handling can be described by the following steps:</p><ol><li><p><strong><a href="https://event-driven.io/en/how_to_get_the_current_entity_state_in_event_sourcing/">Read events from the stream and build the state from them</a></strong> (in other words <em>aggregate stream</em>).</p></li><li><p><strong>Run the business logic using the command and the state.</strong> Use the default (<em>initial</em>) state if the stream does not exist.</p></li><li><p><strong>Append the result of the business logic (so events) at the end of the stream</strong> from which you&#8217;ve read events. Use the read version (or the one provided by the user) for an <a href="https://event-driven.io/en/optimistic_concurrency_for_pessimistic_times/">optimistic concurrency check</a>.</p></li></ol><p>In a nutshell, we could generalise it as:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;typescript&quot;,&quot;nodeId&quot;:&quot;b73de783-e16c-4dbd-91dd-52cfb88b42fd&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-typescript">function handleCommand&lt;State, Command, Event&gt;(
  eventStore: EventStore, 
  streamId: string,
  decide: (state: State) =&gt; Event[],
  evolve: (event: Event, state: State) =&gt; State,
  initialState: () =&gt; State,
  // &#128071; See what I did here
  skipEvent?: (event: Event) =&gt; boolean
  ) {
  // 1. Get the state
  const { state, currentStreamVersion } = await eventStore.aggregateStream(streamName, {
    evolve,
    initialState,
  });

  // 2. Run business logic
  const events = decide(state);

  // 3. Filter out &#8220;failure&#8221; events 
  const eventsToAppend = skipEvent ? events.filter(skipEvent) : events;
  
  // 4. Append events
  const appendResult = await eventStore.appendToStream(streamName, [event], {
    expectedStreamVersion: currentStreamVersion,
  });

  // 5. Return result
  return {
    ...appendResult,
    events, // &#128072; all events
    appendedEvents, // &#128072; only those that were appended
    newState: evolve(aggregation.state, event),
  }
}</code></pre></div><p>A command handler reads a stream, rebuilds its state, runs a decision, applies the produced events, and appends them with an optimistic concurrency check. <a href="https://github.com/event-driven-io/emmett">Emmett</a> takes care of that flow. The application provides <code>evolve</code> to apply an event to state and <code>initialState</code> for a new stream.</p><p>We can pass <code>skipEvent</code> function that inspects the decision&#8217;s outcome before anything is appended. The decision still returns the same events; the function only changes how the handler treats them.</p><p>In Emmett, I allow it to encapsulate the Command Handler setup by:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;typescript&quot;,&quot;nodeId&quot;:&quot;74c5b4e9-dbf4-4b30-9e67-a133c6a8cfb9&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-typescript">// 1. Command handler setup
const handle = CommandHandler&lt;ShoppingCart, ShoppingCartEvent&gt;({
  evolve,
  initialState,
  middleware: [
    skipOn(
      (event) =&gt; event.type === &#8220;ProductItemOutOfStock&#8221; 
        || event.type === &#8220;ShoppingCartItemLimitReached&#8221;
    ),
  ],
});

// 2. Usage
const result = handle(eventStore, shoppingCartId, addProductItem);</code></pre></div><p>I think that this setup clearly shows that we&#8217;re separating the business logic from how the business logic handles that, by explicitly using <code>skipOn </code>middleware, we&#8217;re telling that when such an event happens, we don&#8217;t want to store it, but we&#8217;ll still return it in the result.</p><p>This gives us the option to still do e.g. error mapping to HTTP status as:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;typescript&quot;,&quot;nodeId&quot;:&quot;116e4d31-0100-4ae3-92bc-a1909107ea9a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-typescript">import {
  Conflict,
  NoContent,
  on,
  ResponseFromEvents,
  toWeakETag,
} from &#8220;@event-driven-io/emmett-expressjs&#8221;;

const addProductItemApi = (router: Router) =&gt;
  router.post(
    &#8220;/shopping-carts/:shoppingCartId/product-items&#8221;,
    on(async (request: AddProductItemRequest) =&gt; {
      const shoppingCartId = request.params.shoppingCartId;
      const productId = String(request.body.productId);
      const availableQuantity = await inventory.getAvailableQuantity(productId);

      const command = {
        type: &#8220;AddProductItem&#8221;,
        data: {
          shoppingCartId,
          productItem: {
            productId,
            quantity: Number(request.body.quantity),
          },
          availableQuantity,
          maximumItems: shoppingCartPolicy.maximumItems,
          now: new Date(),
      },

      const { events, nextExpectedStreamVersion } = await handle(
        eventStore, 
        shoppingCartId, 
        (state) =&gt; addProductItem(state, command),
      );

      if(events.some(event =&gt; event.type !== &#8220;ProductItemAdded&#8221;))
        return PreconditionFailed();

      return NoContent({
        eTag: toWeakETag(result.nextExpectedStreamVersion),
      }),
  });</code></pre></div><p>We could, of course, do more advanced error or status mapping by returning exact data, etc. But this example proves that we&#8217;re not losing anything compared to the Result scenario. The code is still pretty simple, as with throwing, but more explicit and straightforward.</p><h2><strong>Several decisions on one stream</strong></h2><p>You may be wondering why I added middleware, which sounds posh for simple filtering. But let&#8217;s discuss one more scenario: a batch import from the external e-commerce system.</p><p>A batch import often contains the contents of multiple shopping carts. Passing all of them to one command handler would be the wrong boundary here: each cart has its own stream and its own concurrency check.</p><p>Even a single imported shopping cart can still require several operations. Especially if we&#8217;re trying to deduce what has happened to a shopping cart in the external system, or we should add some specific operations on top (e.g., discounts, etc.).</p><p>The common scenario for imports is translating state changes (e.g., products in the shopping cart) into commands. This gives at least a chance to make our domain context meaningful. Not perfect, but we&#8217;ve got to save ourselves sometimes.</p><p>After translation, we may end up with a sequence of granular business operations. The mapping could look like that:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;typescript&quot;,&quot;nodeId&quot;:&quot;fcd1d451-1322-4762-b695-94f83ef44a98&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-typescript">const importCartCommands: AddProductItem[] = importedCart.productItems.map(
  (productItem) =&gt; {
    type: &#8220;AddProductItem&#8221;,
    data: {
      shoppingCartId: importedCart.shoppingCartId,
      productItem,
      availableQuantity: stockByProductId[productItem.productId],
      maximumItems,
      now,
    },
  }),
);</code></pre></div><p>And feed it into our command handler:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;typescript&quot;,&quot;nodeId&quot;:&quot;6a43bc50-e9c4-4ced-90e7-0b2dd5fd4c7f&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-typescript">const { events, nextExpectedStreamVersion } = await handle(
  eventStore, 
  shoppingCartId, 
  importCartCommands.map(command =&gt; (state) =&gt; addProductItem(state, command)),
);</code></pre></div><p>Every command uses the existing addProductItem decision and sees the provisional state produced by the previous accepted products. That allows us to apply our own rules and also handle idempotence correctly. Persistence waits until every command has been accepted.</p><p>After batch processing, we can gather the result events. What&#8217;s more, each operation should use the state with the applied event from the previous command. We store the result in the shopping carts as a single atomic batch once the last command has been handled.</p><p>Technically it&#8217;s the same command logic, just wrapped with foreach, as we now have a sequence of decisions instead of the single one.</p><p>What should happen if we encounter unwanted scenarios? So e.g. <code>ProductItemOutOfStock</code> or <code>ShoppingCartItemLimitReached</code>? Should we stop processing or continue? Let me be a consultant for a moment: It depends! Fine, but on what? On your business logic.</p><p>In our case, we probably wouldn&#8217;t want to stop adding product items if one of them was out of stock. Tho, we may decide that there&#8217;s no point in processing it further if we already reached the items limit.</p><p>How to do it? We&#8217;d need to define a mapping function with different possible outcomes.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;typescript&quot;,&quot;nodeId&quot;:&quot;a6a22548-7c2a-4108-9831-373279250935&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-typescript">type MapDecision = &lt;Event&gt;(event: Event) =&gt; void | &#8216;APPEND&#8217; | &#8216;SKIP&#8217; | &#8216;STOP&#8217; | &#8216;REJECT&#8217;;</code></pre></div><p>Where:</p><ul><li><p>APPEND or no result - success, append this event,</p></li><li><p>SKIP - ignore this outcome, don&#8217;t append it, but continue processing,</p></li><li><p>STOP - ignore this outcome, stop processing and append only those that were already accepted,</p></li><li><p>REJECT - reject the whole batch, even if there were some successful events.</p></li></ul><p>The example could be defined for all shopping cart events not to need repeat it in all functions:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;typescript&quot;,&quot;nodeId&quot;:&quot;e446e84d-496a-4009-a76d-4a974efabbf2&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-typescript">const mapShoppingCartDecision = (event: ShoppingCartEvent) =&gt; {
  switch(event.type) {
    case &#8220;ProductItemOutOfStock&#8221;:
      return &#8220;SKIP&#8221;;
    case &#8220;ShoppingCartItemLimitReached&#8221;:
      return &#8220;STOP&#8221;;
    case &#8220;ShoppingCartWasAlreadyConfirmed&#8221;:
      return &#8220;REJECT&#8221;;    
  }
}</code></pre></div><p>We could pass it to our command handler and decide what to do with our processing.</p><p>In Emmett, I wrapped it as:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;typescript&quot;,&quot;nodeId&quot;:&quot;7b569a1c-04b4-4adc-9dec-6205abff48d3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-typescript">const handle = CommandHandler&lt;ShoppingCart, ShoppingCartEvent&gt;({
  evolve,
  initialState,
  middleware: [
    skipOn(
      (event) =&gt; event.type === &#8220;ProductItemOutOfStock&#8221;,
    ),
    stopOn(
      (event) =&gt; event.type === &#8220;ShoppingCartItemLimitReached&#8221;
    ),
    rejectOn(
      (event) =&gt; event.type === &#8220;ShoppingCartWasAlreadyConfirmed&#8221;
    ),
  ],
});</code></pre></div><p>Still, as you see, we&#8217;re building on top of our business logic. It remains the same; we&#8217;re just handling persistence, statuses, etc.</p><p>Returned events show which command handling passed before the rejection and which event stopped the import. The importer can report that outcome against the source record and continue with the next cart, which has a different stream.</p><p>For one product, rejecting meant leaving the stream unchanged. For an imported cart, it may also mean discarding the accepted events produced earlier for the same record.</p><p>If every command is accepted, the handler appends their events together to that cart stream. If one decision returns ProductItemOutOfStock or ShoppingCartItemLimitReached, result.events contains the outcomes produced up to that point, the imported record makes no change to the cart, and later commands are not considered.</p><p>The import job can map those events to a record-level outcome. It can report the exact business issue to the source system and continue with the next cart:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;typescript&quot;,&quot;nodeId&quot;:&quot;2aa15a63-b821-42f0-8bd7-6f7d56993cca&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-typescript">const validatedProductIds = result.events.flatMap((event) =&gt;
  event.type === &#8220;ProductItemAdded&#8221; ? [event.data.productItem.productId] : [],
);

const failure = result.events.find(
  (event) =&gt;
    event.type === &#8220;ProductItemOutOfStock&#8221; ||
    event.type === &#8220;ShoppingCartItemLimitReached&#8221;,
);

const importOutcome = (() =&gt; {
  switch (failure?.type) {
    case &#8220;ProductItemOutOfStock&#8221;:
      return {
        status: &#8220;WaitingForStock&#8221; as const,
        validatedProductIds,
        productId: failure.data.productId,
        requestedQuantity: failure.data.requestedQuantity,
        availableQuantity: failure.data.availableQuantity,
      };

    case &#8220;ShoppingCartItemLimitReached&#8221;:
      return {
        status: &#8220;Rejected&#8221; as const,
        validatedProductIds,
        productId: failure.data.requestedProductId,
        maximumItems: failure.data.maximumItems,
      };

    default:
      return {
        status: &#8220;Imported&#8221; as const,
        importedProductIds: validatedProductIds,
        streamVersion: result.nextExpectedStreamVersion,
      };
  }
})();

await importStatusStore.record(importedCart.externalId, importOutcome);

switch (importOutcome.status) {
  case &#8220;Imported&#8221;:
    await salesChannel.confirmImport(
      importedCart.externalId,
      importOutcome.streamVersion,
    );
    break;

  case &#8220;WaitingForStock&#8221;:
    await pendingImports.waitForStock(
      importedCart.externalId,
      importOutcome.productId,
    );
    break;

  case &#8220;Rejected&#8221;:
    await salesChannel.rejectImport(importedCart.externalId, {
      productId: importOutcome.productId,
      maximumItems: importOutcome.maximumItems,
    });
    break;
}</code></pre></div><p>A saved-list restore uses different rules. Available products return to the cart, unavailable ones are reported, and reaching the cart limit ends the restore. Products added before either outcome remain valid, so this operation is not atomic.</p><p>The additions run in one call because every decision needs the cart state produced by the accepted products before it. Otherwise, each decision would evaluate the item limit against an outdated cart.</p><p>The loop stages and applies available products. It returns an unavailable product without staging it and continues. It also returns the item-limit event without staging it, then stops because the cart cannot accept another distinct product</p><p>And here we&#8217;re getting back to the essential question.</p><h2>So should we throw or not?</h2><p>If we&#8217;re getting the request from our UI, we&#8217;re the authority, so throwing could be fine; we return the status information telling the computer says no. (Read more in <a href="https://event-driven.io/en/how_to_validate_business_logic/">How to validate business logic</a>).</p><p>Still, should we throw on an import scenario? Who&#8217;s the authority here? What we&#8217;re doing is internalising decisions that someone else already made and is just informing us about, or telling us to do our job based on this information.</p><p>So throwing is not a valid option here. The external system told us that its cart contains these products. That is true about the source system, but it does not mean our cart can accept them. The importer translates the external record into commands, and the returned events describe how our local model understood each products.</p><p>For an accepted record, the ProductItemAdded events identify the imported products and the resulting stream version is recorded. If stock is missing, the importer records the checked products and the missing quantity. It can continue with other records, then rebuild this cart&#8217;s commands with current availability after an inventory update. Retrying the unchanged commands immediately would only reproduce the same outcome. If the cart exceeds its item limit, waiting for stock cannot help; the source record needs correcting, so the importer marks it as rejected.</p><p>Of course we could still throw if this operation is synchronous and we want to give quick feedback to the caller. Still, typically, we run batch operations as a background process; also, to have proper resilience, you already saw above how much integration could happen during such an import. Services we integrate with can be unavailable, have transient issues, time out, etc.</p><p>Those failures enter the import job&#8217;s retry or dead-letter policy because there is no reliable business outcome to report yet. The returned events let the application choose between continuing, waiting for a relevant business change, and rejecting one record. Exceptions would just stop processing and not allow recovery and continue with next steps (Read more in <a href="https://event-driven.io/en/no_it_can_never_happen/">No, it can never happen!</a>)</p><p>There can be various error cases also on our side.</p><p>If another request changes the cart before our append, <a href="https://github.com/event-driven-io/emmett">Emmett</a> handles the concurrency conflict by loading the stream again and rerunning the decisions against its new state. Decision middleware runs again as well.</p><p>An out-of-stock import is not a concurrency conflict. The cart state did not become stale; the input said that only two items were available. Running the same command again still returns <code>ProductItemOutOfStock</code>. The importer waits for an inventory change and then builds a new command with the current availability.</p><p>A custom retry policy can inspect a completed attempt, but it runs after that attempt&#8217;s append phase. Retrying a result is safe only when nothing was appended, and useful only when state or input can change.</p><p>A decision and its middleware can therefore run several times during one handler call. Code that only reads the command and state can be rerun. Sending an email or charging a card inside that code would repeat the external effect. In these examples, required external data is fetched before invoking the handler and included in the command. Effects caused by appended events run later in processors, after persistence.</p><p>Middleware could also be useful for authorisation, logging, and measurements. Some of that work belongs around each decision; some should run only once for the complete handler call. Its position relative to the retry boundary determines whether it sees aggregate state and whether a retry repeats it:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;typescript&quot;,&quot;nodeId&quot;:&quot;1573588e-0aeb-4f14-b97e-b47a4f698111&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-typescript">await beforeAll(input);

const result = await retry(async () =&gt; {
  const state = await aggregate(streamName);

  for (const decision of decisions) {
    await runWithDecisionMiddleware(decision, state);
  }

  return appendSelectedEvents();
});

await afterAll(result);

return result;</code></pre></div><p>The callback before the retry boundary receives the complete input once. It can authorise the command batch without repeating that work after a concurrency conflict. State is not available yet because the stream has not been loaded.</p><p>Middleware inside the boundary receives each decision, and the state is rebuilt for the current attempt. A concurrency retry runs it again.</p><p>The callback after the boundary receives the final result once, after persistence. It can record measurements based on appended events or the new stream version. If it throws, already appended events remain committed. Validation belongs earlier; notifications that must be delivered need a durable handler or outbox.</p><h2>Never Throw in Asynchronous Handlers</h2><p>As you see, message processing can end up being pretty complex. That&#8217;s also why we use Event-Driven Architecture, as it helps to make that explicit and reason about it.</p><p>The import we discussed could be (and probably should be) triggered by an event (e.g. through a webhook), as we&#8217;re informed that those items were already processed and we should just accept or ignore it. What has been seen cannot be unseen.</p><p>Whether throwing is safe depends on what sits above the code to catch it. As discussed, a command handler behind an HTTP endpoint, its usual home, has the request: a thrown error unwinds into a response.</p><p>Asynchronous handlers, the projections, reactors, and workflows that react to events, have nothing above them. A throw propagates up, stops the processor, and leaves later events unhandled. The endpoint is only the usual home, not a rule: a command handler run from a reactor loses the same safety net. Instead of throwing, we need to turn the problem into data, so return an event.</p><p><strong>A message handler can turn a declined shopping cart operation into a compensating workflow.</strong> For instance, for <code>ProductItemOutOfStock</code> order more products from the producer. It can, of course, just skip a message that should not block processing, or stop without advancing further.</p><p><strong>The same for <a href="http://event-driven.io/en/projections_and_read_models_in_event_driven_architecture/#idempotency">read models</a>.</strong> A projection cannot prevent the source event from being recorded because that event is already in the stream. Throwing only prevents the read model from advancing. If an event carries a value the read model did not expect, throwing does not undo it. The check that should have stopped it belongs in the business logic, before the event was ever recorded; by the time the projection runs, it is too late.</p><p>It builds a read model from events that are already recorded, and a recorded event is a fact: the projection only interprets it, so it has nothing to reject. If an event carries a value the read model did not expect, throwing does not undo it. The check that should have stopped it belongs in the business logic, before the event was ever recorded; by the time the projection runs, it is too late.</p><p>So accept the event and build the best read model you can from it. Skip a duplicate, fall back to a default, clamp a value back into range, whatever keeps the read model sensible and moving. Its error handling must account for delivery and recovery rather than reuse the rules of a command decision.</p><h2><strong>Conclusion</strong></h2><p>The decision returns <code>ProductItemAdded</code>, <code>ProductItemOutOfStock</code>, or <code>ShoppingCartItemLimitReached</code> because the application needs to know which one happened. Out of stock is an unfavourable outcome, but that alone says nothing about storage. During an import it cancels the current source record. During a saved-list restore it is skipped while earlier additions remain. At the HTTP endpoint it becomes a conflict response. The event stays the same; its handling depends on the use case.</p><p>I do not try to remove exceptions from this design. Adding a product to a confirmed cart is an invalid operation. An unavailable event store prevents the handler from producing a reliable result. Both go to the application&#8217;s error boundary.</p><p><code>Result</code> could label some of the returned events as failures, but persistence, continuation, and retry still need their own decisions. The exception path remains as well. The event types already distinguish the business outcomes, so I prefer to leave them as they are.</p><p><strong>So when someone asks me whether they should throw an error or use a </strong><code>Result</code><strong> type, I sometimes tell: actually, you can do neither.</strong></p><p>Cheers!</p><p>Oskar</p><p>p.s. if you liked this one, check also:</p><ul><li><p><a href="https://event-driven.io/en/idempotent_command_handling/">Idempotent Command Handling</a></p></li><li><p><a href="https://fsharpforfunandprofit.com/posts/against-railway-oriented-programming/">Against Railway-Oriented Programming</a> by Scott Wlaschin</p></li><li><p><a href="https://file+.vscode-resource.vscode-cdn.net/home/oskar/Repos/emmett/src/docs/guides/error-handling.md">Emmett: Error Handling</a></p></li><li><p><a href="https://file+.vscode-resource.vscode-cdn.net/home/oskar/Repos/emmett/src/docs/guides/command-handling.md">Emmett: Command Handling</a></p></li></ul><p><strong>p.s. Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, and putting pressure on your local government or companies. You can also support Ukraine by donating, e.g. to the <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian </a>organisation, <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">Ambulances for Ukraine</a> or <a href="https://redcross.org.ua/en/">Red Cross</a>.</p>]]></content:encoded></item><item><title><![CDATA[On swimming, learning and my journey to being the last one]]></title><description><![CDATA[Work impacts how we live, and our lives impact how we do our work.]]></description><link>https://www.architecture-weekly.com/p/on-swimming-learning-and-my-journey</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/on-swimming-learning-and-my-journey</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 13 Jul 2026 12:32:44 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!IV8S!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fced2dc7f-0a2c-4efc-abe9-c3ec4b7bab44_500x677.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Work impacts how we live, and our lives impact how we do our work. My name is Oskar Dudycz, not Paulo Coelho, and today I&#8217;m not going to talk about software architecture or the event-driven world, but about the world in general, or to be precise, my world. I&#8217;ll let you say hello to it, but if you&#8217;re looking for a technical piece, that&#8217;s not happening today. </p><p>If this story were written in Fakt, Bild, or Sun, it could have been titled:</p><blockquote><p>Trained for 6 years. Finished as the last one.</p></blockquote><p>That&#8217;d be almost true about what has happened to me. Almost would already be a lot for Fakt, Bild or Sun. Now, let&#8217;s go with the full story.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!IV8S!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fced2dc7f-0a2c-4efc-abe9-c3ec4b7bab44_500x677.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!IV8S!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fced2dc7f-0a2c-4efc-abe9-c3ec4b7bab44_500x677.png 424w, https://substackcdn.com/image/fetch/$s_!IV8S!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fced2dc7f-0a2c-4efc-abe9-c3ec4b7bab44_500x677.png 848w, https://substackcdn.com/image/fetch/$s_!IV8S!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fced2dc7f-0a2c-4efc-abe9-c3ec4b7bab44_500x677.png 1272w, https://substackcdn.com/image/fetch/$s_!IV8S!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fced2dc7f-0a2c-4efc-abe9-c3ec4b7bab44_500x677.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!IV8S!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fced2dc7f-0a2c-4efc-abe9-c3ec4b7bab44_500x677.png" width="500" height="677" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/ced2dc7f-0a2c-4efc-abe9-c3ec4b7bab44_500x677.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:677,&quot;width&quot;:500,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:346568,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://www.architecture-weekly.com/i/206839180?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fced2dc7f-0a2c-4efc-abe9-c3ec4b7bab44_500x677.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!IV8S!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fced2dc7f-0a2c-4efc-abe9-c3ec4b7bab44_500x677.png 424w, https://substackcdn.com/image/fetch/$s_!IV8S!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fced2dc7f-0a2c-4efc-abe9-c3ec4b7bab44_500x677.png 848w, https://substackcdn.com/image/fetch/$s_!IV8S!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fced2dc7f-0a2c-4efc-abe9-c3ec4b7bab44_500x677.png 1272w, https://substackcdn.com/image/fetch/$s_!IV8S!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fced2dc7f-0a2c-4efc-abe9-c3ec4b7bab44_500x677.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p>I&#8217;m a weird type of guy, I don&#8217;t have a driving license. And neither did I have it. I learned to ride a bike when I was 9 years old and got it for my First Communion. And I didn&#8217;t learn to swim till I was 35. Yes, that happened 6 years ago.</p><p>Did I have some trauma, like drowning? Nah, just didn&#8217;t happen. Maybe because my father couldn&#8217;t swim, maybe because I didn&#8217;t have motivation. Dunno, just didn&#8217;t happen. </p><p>Of course, not having certain skills can be fine; you can learn to live without them. You can even be considered a fashionable hipster. Some folks consider my lack of a driver&#8217;s license as a personal worldview stand. The fact that I like to cycle or ride a train suggests to some that I&#8217;m an ecologist. And don&#8217;t get me wrong, I like nature, I try to be a good citizen, but that&#8217;s not my main motivation.</p><p>This is just what I do, not who I am.</p><p>So am I a swimmer now? Well, it seems that I can swim now. I took classes from 2018 to 2019, then stopped for about 3 years, and got back into it when I signed my daughter up for swimming classes.</p><p>Now, when I say I&#8217;m practising swimming, everyone assumes I&#8217;m a triathlete preparing for a competition. And well, I&#8217;m not.</p><p>I don&#8217;t even like to swim much. I mean, it&#8217;s fine, I don&#8217;t mind, but it&#8217;s not that it&#8217;s my passion. I don&#8217;t have thoughts like:</p><blockquote><p> Hell, I&#8217;d like to swim now.</p></blockquote><p>And I still have thoughts like:</p><blockquote><p> Hell, I&#8217;d play football now.</p></blockquote><p>Which I don&#8217;t do, as I don&#8217;t play football anymore. Even though it was my passion for most of my life, I wasn&#8217;t talented at it, but I liked doing it. I even played as a junior for my hometown club (Mied&#378; Legnica) and later in amateur leagues. And in those amateur leagues, I tore something called the <em>lateral patellar retinacula</em>. Actually, two of them, in both knees. Which means that my knees are unstable, and if I continued to play, it&#8217;d end up with some challenging surgery.</p><p>That also means that I had to decide to be unsuccessful somewhere else.</p><p>So, I&#8217;m an unsuccessful footballer and now an unsuccessful swimmer in the making.</p><p>I tried running and going to the gym, but it was always boring for me. I could do it for a few months, but then I quit. And off we go, for not training.</p><p>And I tried swimming.</p><p>Why? We don&#8217;t need to be capable of everything; it&#8217;s often a fine choice not to learn everything. At some point, a lack of certain skills can be limiting. You already know that it wasn&#8217;t my worldview to always have my feet on solid ground, but the main motivations were:</p><ul><li><p>It&#8217;s nice to do some swimming on holidays,</p></li><li><p>It&#8217;s NOT nice to be a lame dad who can&#8217;t swim with his daughter,</p></li><li><p>I kindaish always wanted to learn that, just didn&#8217;t happen,</p></li><li><p>It&#8217;d be good to do some exercises, keeping in mind my sitting style of work,</p></li><li><p>I had a swimming pool 10m by walk,</p></li><li><p>I had and still have a great teacher. Thanks, Justyna!</p></li></ul><p>So I started learning to swim again in 2024. It&#8217;s an interesting exercise when you learn something new as an adult. It&#8217;s more challenging because you&#8217;re overthinking rather than acting on your instincts. Or maybe your old habits are making your instincts unintuitional. It also shows how everyone has a different way of learning.</p><p>Take Breaststroke style (in Polish called &#8220;&#379;abka&#8221; - frog). Most people claim that it&#8217;s the easiest style to learn. For me? Madness, how much stuff you need to remember and coordinate: hands, back, legs. </p><p>It also shows how everything is connected. It appeared that I couldn&#8217;t do it properly because I wasn&#8217;t stretched enough. Only after I started stretching was I able to move my back and twist my legs correctly. Well almost. Almost in the Fakt, Bild and Sun way.</p><p>Ok, so I learned to swim kindaish. But the best progress I made was last year.</p><p>When we started the season in September, my coach told our group that we&#8217;d start competing. I said hell yeah, and Justyna took the irony for a real agreement. So it started.</p><p>It appeared that the shortest possible distance is 475 meters, which is around 19 swimming pool lengths. Ah, and it&#8217;s on the lake, deep for 22 meters. I didn&#8217;t know about that last part when I signed for it. I also didn&#8217;t know if I could swim for so long with the crawl style. I had probably a record of around 6-8 swimming pools. </p><p>And well, I made it.</p><p>Was I stupid or just dumb? I hope that neither of those. I used the best way to learn: practical and deadline-driven.</p><p>Instead of one training per week, I started doing two. I took some private lessons with Justyna and also started doing longer swimming sessions. Slowly but surely, I beat my record to 10, 15, 20, then 40.</p><p>Yes, it was stressful; yes, it required a patient, slow process, but it gave me at least the trust in myself that if I can swim 40, then I&#8217;ll probably make 19.</p><p>It also helped to do a real dry run in the lake and the sea. If you only swim in the swimming pool, you&#8217;ll be surprised how different swimming in a lake is.</p><p>The water is dirty; it&#8217;s super easy to lose your bearings and go in a longer circle than you should. </p><p>And guess what? The real competition brings even more surprises. The crowd of other swimmers is a challenge. Constant pushing on the start, distracting from catching the flow. Then stress, and moments of doubt about yourself. You can prepare for that, but only to some degree; the real learning comes through the next stage, which starts in competition.</p><p>Were I actually the last one? No, I was 53 out of 59. Seems I wasn&#8217;t all bad. Just half bad. I had 14m 20s time. My best on practice was 12m.</p><p>Still, not too bad for a guy who learned to swim at 35, if you ask me. I&#8217;m super proud of myself.</p><p><a href="https://talesfrom.dev/about">My friend Damian</a> (jokingly) told me that I should write this as</p><blockquote><p>&#8220;10 things I learned about EDA from practicing swimming and preparing for a competition&#8221;</p></blockquote><p>And that&#8217;s not going to happen, but let me give an inspirational quote from the man, the legend, Michael Jordan:</p><blockquote><p>I don&#8217;t compete with other people. I compete with what I&#8217;m capable of.</p></blockquote><p>And that&#8217;s what I did. Of course, Michael wasn&#8217;t competing with others, as he was the best, GOAT. I wasn&#8217;t competing, as I was one of the worst.</p><p>Yet, I was still much better than my past self. And I also achieved more than those who haven&#8217;t even tried.</p><p>Interestingly, I was sometimes perceived as humble, and sometimes as too self-assured. Maybe it comes from a common answer when someone asks me if I can do something:</p><blockquote><p>I don&#8217;t know; I haven&#8217;t tried it yet.</p></blockquote><p>But I think that stems from my belief that we are all human. We&#8217;re not worse than others, but we&#8217;re also not better than others, which can be seen as bold or humble, depending on the focus of this belief.</p><p>We won&#8217;t know what we&#8217;re capable of until we try it. And when we do, we&#8217;ll already be much further than your past selves that haven&#8217;t tried.</p><p>I&#8217;m not trying to say that we can achieve everything; I probably won&#8217;t ever be a good swimmer. If I could achieve that, then anyone can. I&#8217;m not saying that&#8217;s going to be easy. It&#8217;s not. It requires time. And time is a precious resource. Not infinite. The best way to find it is to find it. But to do it, you need to set a priority, which means something else will become less important.</p><p>Pick wisely.</p><p>Should you also swim? Well, maybe. You definitely should do some exercises if you&#8217;re a developer like me. But precisely swimming? Up to you. Should you get a hobby other than work? Definitely.</p><p>Why did I write this article? A bit to brag, as I&#8217;m happy I made it.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!ioQw!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5b4ce9c6-282d-410a-bc73-b2a131721daf_1200x1600.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!ioQw!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5b4ce9c6-282d-410a-bc73-b2a131721daf_1200x1600.jpeg 424w, https://substackcdn.com/image/fetch/$s_!ioQw!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5b4ce9c6-282d-410a-bc73-b2a131721daf_1200x1600.jpeg 848w, https://substackcdn.com/image/fetch/$s_!ioQw!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5b4ce9c6-282d-410a-bc73-b2a131721daf_1200x1600.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!ioQw!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5b4ce9c6-282d-410a-bc73-b2a131721daf_1200x1600.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!ioQw!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5b4ce9c6-282d-410a-bc73-b2a131721daf_1200x1600.jpeg" width="1200" height="1600" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/5b4ce9c6-282d-410a-bc73-b2a131721daf_1200x1600.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1600,&quot;width&quot;:1200,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:322848,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/jpeg&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.architecture-weekly.com/i/206839180?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5b4ce9c6-282d-410a-bc73-b2a131721daf_1200x1600.jpeg&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!ioQw!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5b4ce9c6-282d-410a-bc73-b2a131721daf_1200x1600.jpeg 424w, https://substackcdn.com/image/fetch/$s_!ioQw!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5b4ce9c6-282d-410a-bc73-b2a131721daf_1200x1600.jpeg 848w, https://substackcdn.com/image/fetch/$s_!ioQw!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5b4ce9c6-282d-410a-bc73-b2a131721daf_1200x1600.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!ioQw!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5b4ce9c6-282d-410a-bc73-b2a131721daf_1200x1600.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Brag about being 53 out of 59 after 6 years of training? Seems so! You need to cherish small successes that, from the right perspective, appear to be big ones.</p><p>So, are there 10 things you can learn about software engineering from practising swimming and preparing for a competition? </p><p>I don&#8217;t know; you tell me.</p><p>Cheers!</p><p>Oskar</p><p>p.s. if you liked this one, check also:</p><ul><li><p><a href="https://event-driven.io/en/borys_najlepiej_dryblowal/">Borys had the best dribbling</a>,</p></li><li><p><a href="https://event-driven.io/en/how_playing_on_guitar_helps_in_being_better_developer/">How playing on guitar can help you to be a better developer?</a></p></li><li><p><a href="https://event-driven.io/en/a_few_words_about_workaholism/">Don&#8217;t be like Ebenezer Scrooge. A few words about workaholism</a></p></li></ul><p><strong>p.s. Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, and putting pressure on your local government or companies. You can also support Ukraine by donating, e.g. to the <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian </a>organisation, <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">Ambulances for Ukraine</a> or <a href="https://redcross.org.ua/en/">Red Cross</a>.</p>]]></content:encoded></item><item><title><![CDATA[Addition by subtraction in software design]]></title><description><![CDATA[On how addition can lower the value of your software and subtraction increase it]]></description><link>https://www.architecture-weekly.com/p/addition-by-subtraction-in-software</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/addition-by-subtraction-in-software</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 06 Jul 2026 13:18:54 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!rHy1!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7395069c-b3ac-4f81-911c-3c1b8aa1b1c3_970x600.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I want it all, and I want it now. This is not only a Freddie Mercury quote but also, too often, a general plan for our software product. The picture tells more than words, so here it is, the typical product we designed:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!rHy1!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7395069c-b3ac-4f81-911c-3c1b8aa1b1c3_970x600.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!rHy1!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7395069c-b3ac-4f81-911c-3c1b8aa1b1c3_970x600.jpeg 424w, https://substackcdn.com/image/fetch/$s_!rHy1!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7395069c-b3ac-4f81-911c-3c1b8aa1b1c3_970x600.jpeg 848w, https://substackcdn.com/image/fetch/$s_!rHy1!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7395069c-b3ac-4f81-911c-3c1b8aa1b1c3_970x600.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!rHy1!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7395069c-b3ac-4f81-911c-3c1b8aa1b1c3_970x600.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!rHy1!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7395069c-b3ac-4f81-911c-3c1b8aa1b1c3_970x600.jpeg" width="970" height="600" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/7395069c-b3ac-4f81-911c-3c1b8aa1b1c3_970x600.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:600,&quot;width&quot;:970,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;Super Swiss Army Knife Meme Generator - Imgflip&quot;,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Super Swiss Army Knife Meme Generator - Imgflip" title="Super Swiss Army Knife Meme Generator - Imgflip" srcset="https://substackcdn.com/image/fetch/$s_!rHy1!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7395069c-b3ac-4f81-911c-3c1b8aa1b1c3_970x600.jpeg 424w, https://substackcdn.com/image/fetch/$s_!rHy1!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7395069c-b3ac-4f81-911c-3c1b8aa1b1c3_970x600.jpeg 848w, https://substackcdn.com/image/fetch/$s_!rHy1!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7395069c-b3ac-4f81-911c-3c1b8aa1b1c3_970x600.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!rHy1!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7395069c-b3ac-4f81-911c-3c1b8aa1b1c3_970x600.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>And, well, that&#8217;s probably not what our user wanted. They might just want a sharper steak knife, so something more like:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!JLnZ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe077e715-32d8-44bd-bc1e-68707fc5a94f_1500x1500.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!JLnZ!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe077e715-32d8-44bd-bc1e-68707fc5a94f_1500x1500.jpeg 424w, https://substackcdn.com/image/fetch/$s_!JLnZ!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe077e715-32d8-44bd-bc1e-68707fc5a94f_1500x1500.jpeg 848w, https://substackcdn.com/image/fetch/$s_!JLnZ!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe077e715-32d8-44bd-bc1e-68707fc5a94f_1500x1500.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!JLnZ!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe077e715-32d8-44bd-bc1e-68707fc5a94f_1500x1500.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!JLnZ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe077e715-32d8-44bd-bc1e-68707fc5a94f_1500x1500.jpeg" width="1456" height="1456" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/e077e715-32d8-44bd-bc1e-68707fc5a94f_1500x1500.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1456,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;6PCS German Stainless Steel Serrated Steak Knife Set - Premium Classic&quot;,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="6PCS German Stainless Steel Serrated Steak Knife Set - Premium Classic" title="6PCS German Stainless Steel Serrated Steak Knife Set - Premium Classic" srcset="https://substackcdn.com/image/fetch/$s_!JLnZ!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe077e715-32d8-44bd-bc1e-68707fc5a94f_1500x1500.jpeg 424w, https://substackcdn.com/image/fetch/$s_!JLnZ!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe077e715-32d8-44bd-bc1e-68707fc5a94f_1500x1500.jpeg 848w, https://substackcdn.com/image/fetch/$s_!JLnZ!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe077e715-32d8-44bd-bc1e-68707fc5a94f_1500x1500.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!JLnZ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe077e715-32d8-44bd-bc1e-68707fc5a94f_1500x1500.jpeg 1456w" sizes="100vw"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>I&#8217;m also to blame; I&#8217;m falling into precisely the same trap too often. Like recently, while working on <a href="https://www.architecture-weekly.com/p/announcing-strictland-new-contract">Strictland, the contract testing library I announced in the previous article</a>.</p><p>The idea behind it is to detect message schema drift easily. You write such a test:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@Test
void orderPlacedSchema_DoesNotChange() {
    // Strictland specification
    MessageContract.specification(Json.Jackson.defaults())
        .given(new OrderPlaced(orderId, "Alice", placedAt))
        .whenSerialized()
        .thenContractIsUnchanged();
}</code></pre></div><p>It serialises the message and stores the payload inside the same git repository as the test. Thanks to that, you can keep it close and detect contract changes during the review by looking at the changed files.</p><p>Initially, files were kept in the same folder as the test file. That made it accessible, as you don&#8217;t need to jump from one folder to another and see related test files. The file was named by message type class name (so in this case it&#8217;d be <code>OrderPlaced.approved.text</code>). Simple, but what if someone adds:</p><ul><li><p>Multiple tests, trying to store different snapshots for the same message type?</p></li><li><p>Multiple test files dispersed across different folders that test the same message type (e.g., in the features that consume those messages)?</p></li><li><p>Multiple message types across different modules/packages with the same name?</p></li><li><p>multiple tests for different message-type versions and variants (e.g., with all fields, with only required fields, etc.)</p></li></ul><p>All of that is solvable by some conventions, but what if you&#8217;re an open-source library creator like me? Then you can&#8217;t assume the same type of thinking, level of tidiness or knowledge of your users. You should be the one to take on the pain and make your users' lives easier. And guess what? That applies not only to open source, but also to other products. </p><p><em>&#8220;Taking the pain&#8221;</em> and <em>&#8220;helping users&#8221;</em> can be interpreted as an attempt to please everyone, which translates to the mentioned earlier: &#8220;I want it all, and I want it now.&#8221;</p><p>That&#8217;s what I tried to do. I decided to add <em>&#8220;selectable storage layouts strategies&#8221;</em>:</p><ul><li><p><strong>Near the test file</strong> - so similar to the one we had,</p></li><li><p><strong>In the nested folder near the test file - </strong>similar, but separating the snapshot files from tests, thanks to that, you could look in the nested folder, keeping the tests not polluted by the numerous tests,</p></li><li><p><strong>In the root folder</strong> - putting it close to the root folder, keeping snapshots together- still the same repo but giving potentially better accessibility.</p></li></ul><p>And <em>&#8221;selectable storage layouts strategies&#8221;</em> were not enough for me. I also wanted to add <em>&#8220;various grouping strategies&#8221; </em>where one could select whether they want to group message snapshots by:</p><ul><li><p>Message type name,</p></li><li><p>Test files and their names.</p></li><li><p>Or just apply no grouping.</p></li></ul><p>It sounded smart to me, and hell, with GenAI, I wouldn&#8217;t even need to fight with it, as my friend Claude would do it for me, right? <a href="https://www.architecture-weekly.com/p/requiem-for-a-10x-engineer-dream">RIGHT?</a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Jqep!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff01ec09e-ffe8-44cf-9673-9efd8e228dbe_625x350.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Jqep!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff01ec09e-ffe8-44cf-9673-9efd8e228dbe_625x350.jpeg 424w, https://substackcdn.com/image/fetch/$s_!Jqep!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff01ec09e-ffe8-44cf-9673-9efd8e228dbe_625x350.jpeg 848w, https://substackcdn.com/image/fetch/$s_!Jqep!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff01ec09e-ffe8-44cf-9673-9efd8e228dbe_625x350.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!Jqep!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff01ec09e-ffe8-44cf-9673-9efd8e228dbe_625x350.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Jqep!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff01ec09e-ffe8-44cf-9673-9efd8e228dbe_625x350.jpeg" width="625" height="350" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/f01ec09e-ffe8-44cf-9673-9efd8e228dbe_625x350.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:350,&quot;width&quot;:625,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;That's what she said meme&quot;,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="That's what she said meme" title="That's what she said meme" srcset="https://substackcdn.com/image/fetch/$s_!Jqep!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff01ec09e-ffe8-44cf-9673-9efd8e228dbe_625x350.jpeg 424w, https://substackcdn.com/image/fetch/$s_!Jqep!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff01ec09e-ffe8-44cf-9673-9efd8e228dbe_625x350.jpeg 848w, https://substackcdn.com/image/fetch/$s_!Jqep!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff01ec09e-ffe8-44cf-9673-9efd8e228dbe_625x350.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!Jqep!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff01ec09e-ffe8-44cf-9673-9efd8e228dbe_625x350.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Now, <em>&#8221;selectable storage layouts strategies&#8221; </em>should sound enough as a warning to me and <em>&#8220;various grouping strategies&#8221;</em> should ring a loud alarm bell. But it didn&#8217;t. I planned how to do it and started the implementation.</p><p>A few iterations later, the permutations of those <em>various strategies</em> started to pile up. Some of them were just hard to do. Some were conflicting. And most of them weren&#8217;t even useful. </p><p>Examples? I decided to make all files follow the unified format:</p><p><code>{messageTypeName}.{version}.{variant}.snap.approved.{extension}</code></p><p>And that&#8217;s fine, but then what to use as the message type name? Class name? Fine, but what about a flat layout, or grouping by message type name, with multiple messages of the same name? </p><p>Let&#8217;s use the full class path as the name? Fine, but then what if you have a root folder and a nested folder strategy? Would you nest folders by the class path, and then repeat it in the name?</p><p>Or maybe you&#8217;d differentiate the file name format depending on the strategy one chose?</p><p>Ok, but what if the user decides to change the strategy at some point?</p><p>I decided to run a sanity check I should have done earlier. Better later than too late. The discussion inside my head looked more or less like this:</p><ul><li><p><strong>Me: </strong>What&#8217;s the actual goal of this library?</p></li><li><p><strong>I:  </strong>To detect message schema drift.</p></li><li><p><strong>Me:</strong> Ok, then do I need to build the snapshot library?</p></li><li><p><strong>I: </strong>To some degree, I&#8217;m  using snapshot comparison to detect that drift.</p></li><li><p><strong>Me: </strong>Sure, but I probably don&#8217;t need to cover all the cases that the generic snapshot library has to, right?</p></li><li><p><strong>I: </strong>Right.</p></li><li><p><strong>Me: </strong>Ok, so what&#8217;s our bet for users? Are they advanced or just beginning the journey?</p></li><li><p><strong>I: </strong>At the beginning. </p></li><li><p><strong>Me: </strong>Then I should help them, set a sane default, and guide them. Especially since this is the early phase of this library, I haven&#8217;t yet received much feedback.</p></li><li><p><strong>I: </strong>Agreed, maybe we could start then with the recommended layout and grouping, and then gather feedback. I can add more strategies as needed.</p></li></ul><p>That sounds like an obvious thing to do, but it requires pausing to find the time to evaluate our assumptions. We shouldn&#8217;t always be mad at ourselves for not predicting everything earlier. Some stuff is just hard to predict. Personally, I usually see some steps after I try to implement them. Those are also the steps I explained in the <a href="https://event-driven.io/en/vibing_harness_and_ooda_loops/">article about the OODA loop</a>. We should:</p><ul><li><p><strong>Observe</strong> - This is the intake of raw, unfiltered information. In our world, this means looking at the state of the system.</p></li><li><p><strong>Orient</strong> - This is the most critical and difficult stage. It&#8217;s where we filter our observations through your experience, culture, and technical knowledge.</p></li><li><p><strong>Decide</strong> - Based on our orientation, formulate a hypothesis.</p></li><li><p><strong>Act</strong> - We execute.</p></li></ul><p>Rinse repeat.</p><p>If we think about it this way, the time spent considering those various cases is not wasted. It&#8217;s similar to the <a href="https://particular.net/blog/antirequirements">anti-requirements exercise described by Andreas &#214;hlund and David Boike</a> or <a href="https://www.architecture-weekly.com/p/residuality-theory-a-rebellious-take">Barry O'Reilly&#8217;s Residuality Theory</a>. We gather numerous needs and ideas about our product evolution and see what sticks and what remains. Not all ideas will be great; some will be bad, but that doesn&#8217;t matter; it gives us food for thought.</p><p><strong>Still, we can&#8217;t forget about the most important part: subtraction.</strong></p><p>We need to subtract all the stuff that&#8217;s not needed, leaving only the useful residues, the one that solves the main goal.</p><p>For my case, I realised that if I chose the single strategy, it would organise my contract into the root test folder like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">&#128193; src/test/resources/
  &#128193; contract-registry/
    &#128193; com/acme/orders/OrderPlaced/
      &#128196; OrderPlaced.1.default.snap.approved.json
      &#128196; OrderPlaced.2.default.snap.approved.json
    &#128193; com/acme/orders/OrderInitiated/
      &#128196; OrderInitiated.1.AllData.snap.approved.json
      &#128196; OrderInitiated.1.OnlyRequired.snap.approved.json
      &#128196; OrderInitiated.2.AllData.snap.approved.json
      &#128196; OrderInitiated.2.OnlyRequired.snap.approved.json
    &#128193; InvoiceIssuedEvent/
      &#128196; InvoiceIssuedEvent.1.default.snap.approved.json</code></pre></div><p>By nesting by message type and full path and grouping by contract name, I&#8217;m not only keeping snapshots but also creating a Contract Registry. </p><p>So this subtraction of some features not only gave me focus on the specific scenario but also enabled me to create scenarios that I would actually like to have eventually, for instance:</p><ul><li><p>keep the message schema in the same folder as snapshots,</p></li><li><p>markdown docs of the message,</p></li><li><p>Having defined the folder structure, generate documentation,</p></li><li><p>check dependency, and who uses that,</p></li><li><p>etc.</p></li></ul><p>That led me to conclude the following two rules: </p><ol><li><p><strong>The addition of imaginary features subtracts the actual value.</strong></p></li><li><p><strong>The subtraction of imaginary features adds the actual value.</strong></p></li></ol><p>It&#8217;s also aligned with my motto: <a href="https://event-driven.io/en/removability_over_maintainability/">Removability over Maintainability</a>.</p><h2>What&#8217;s the lesson then?</h2><p>We&#8217;re not wasting time on building too slowly; we&#8217;re wasting time on building stuff that doesn&#8217;t matter. All those useless features we imagine without evaluating them with people are a wasted opportunity to deliver better for them. </p><p>Nowadays, with LLMs, it&#8217;s tempting to rush into implementation and add every feature we can imagine. That&#8217;s never free. We&#8217;re increasing the cognitive load for our users and also for us; we waste time on stuff that doesn&#8217;t matter and are constantly distracted.</p><p>Yet, if we don&#8217;t do sanity checks, trying things in a proof-of-concept way can be a big multiplier. If we stop after that and rethink.</p><p>I&#8217;d still advocate doing a sanity check earlier than I did in the described scenario and thinking first, but not all can be forseen like that. So:</p><ol><li><p>Generate as many ideas as you can.</p></li><li><p>Try to think about how they match together, think about the tradeoffs, the cost of adding them, the cost of removing them and the cost of changing your mind.</p></li><li><p>Define potential solutions. </p></li><li><p><strong>Stop the addition phase</strong> and rethink what you came up with. Ask yourself what you&#8217;re building and what the actual goal is.</p></li><li><p><strong>Start the subtraction phase</strong> and cut as much as you can on the stuff that&#8217;s actually not needed. Of course, don&#8217;t drop ideas, remember that quantification is subjective. <a href="https://www.youtube.com/watch?v=yV97QwC5gnE">Deliver a simple solution that will give you enough optionality to change your mind</a>.</p></li><li><p>Send it wild and gather feedback.</p></li></ol><p>And that&#8217;s precisely what I did: I just released <a href="https://github.com/event-driven-io/strictland">Strictrland 0.4.0</a> and will plan the next steps based on user feedback!</p><p>Cheers!</p><p>Oskar</p><p><strong>p.s. Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, and putting pressure on your local government or companies. You can also support Ukraine by donating, e.g. to the <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian </a>organisation, <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">Ambulances for Ukraine</a> or <a href="https://redcross.org.ua/en/">Red Cross</a>.</p><p></p>]]></content:encoded></item><item><title><![CDATA[Announcing Strictland - new contract testing library for message compatibility]]></title><description><![CDATA[I just released something.]]></description><link>https://www.architecture-weekly.com/p/announcing-strictland-new-contract</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/announcing-strictland-new-contract</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 15 Jun 2026 17:13:15 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!aLpe!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2945a216-6ded-453b-b76e-2a2fdf5e149c_1338x784.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>I just released something. It's called Strictland.</strong> And it's a contract testing library. Why did I do it?</p><p>Before I go further, if you can&#8217;t wait, you can check it on:</p><ul><li><p>GitHub - <a href="https://github.com/event-driven-io/strictland">https://github.com/event-driven-io/strictland</a></p></li><li><p>Maven Central - <a href="https://central.sonatype.com/artifact/io.event-driven/strictland">https://github.com/event-driven-io/strictland</a></p></li></ul><p><strong>So now you already know that it&#8217;s a JVM (Java, Scala, Kotlin, etc.) Open Source library.</strong> </p><p>Let me first show you a sneak peek. Stop for a moment and have a look on this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@Test
void ensureOrderPlacedCompatibilityWithNewerVersion() {
    // Strictland specification
    MessageContract.specification(Json.Jackson.of(yourObjectMapper))
        .given(new OrderPlaced(orderId, "Alice"))
        .whenDeserializedAs(OrderPlacedWithCoupon.class)
        .thenBackwardCompatible();
}</code></pre></div><p>What would you say if you saw such a test? Think about it, we&#8217;ll get back to it.</p><h2>What&#8217;s Strictland?</h2><p>But again, why did I do it if there are mature solutions like <a href="https://pact.io/">Pact</a>, <a href="https://spring.io/projects/spring-cloud-contract">Spring Cloud Contracts</a>, or <a href="https://github.com/confluentinc/schema-registry">Confluent Schema Registry</a>?</p><p>If you've used consumer-driven contract testing, the usual approach is to run both the provider and the consumer, record the consumer's expectations against a mock, verify the provider against those expectations, and share those contracts through a broker. <br><br>In Strictland, I took a smaller, simpler approach. It serialises the message in a standard unit test and saves the output as a snapshot file that you commit alongside your code. <br><br>The test fails when the serialised shape changes, or when it contains a breaking change - up to you to specify expectations. A check confirms that an older and a newer version of the message can still read each other's data (or the other way round).</p><p><strong>Because it's only serialisation and a file, the setup stays small:</strong></p><ul><li><p>The checks are ordinary unit tests in your existing suite, so there's no broker, schema registry, or mock service to run, and nothing to start in Docker.</p></li><li><p>The contract is the serialised JSON committed alongside the test, so any format change appears in a normal diff and is reviewed like any other code.</p></li><li><p>You write the check beside the message it covers and get the answer in the same fast feedback loop as the rest of your tests.<br>The check uses your application's own serializer, so the snapshot is the exact bytes you ship.</p></li><li><p>Strictland checks the serialised shape of a message and whether its versions stay compatible. It doesn't exercise a live exchange between running services, so it complements that kind of tooling rather than replacing it.</p></li></ul><p><strong>Strictland checks the serialised shape of a message and whether its versions stay compatible.</strong> It doesn't exercise a live exchange between running services, so it complements that kind of tooling rather than replacing it.</p><p>It's not as powerful as popular tooling, but it's also much simpler to start catching our mistakes. Traditional solutions allow you to mock protocols, put a man in the middle, and even generate client code. All of that is great if you need it and have experience with it.</p><p>Most of the <a href="https://event-driven.io/en/training/">customers I&#8217;m helping through consultancy and training </a>aren't there yet. Setting up those tools is a lot of heavy lifting for them and adds additional complexity. And well, maybe they don&#8217;t need to be, as this approach served me well in my past projects. I always handcrafted such a tool in my projects, but finally decided to make it properly. </p><h2>Why such a name?</h2><p>I&#8217;m putting (too?) much effort into my projects&#8217; names. </p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!aLpe!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2945a216-6ded-453b-b76e-2a2fdf5e149c_1338x784.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!aLpe!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2945a216-6ded-453b-b76e-2a2fdf5e149c_1338x784.png 424w, https://substackcdn.com/image/fetch/$s_!aLpe!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2945a216-6ded-453b-b76e-2a2fdf5e149c_1338x784.png 848w, https://substackcdn.com/image/fetch/$s_!aLpe!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2945a216-6ded-453b-b76e-2a2fdf5e149c_1338x784.png 1272w, https://substackcdn.com/image/fetch/$s_!aLpe!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2945a216-6ded-453b-b76e-2a2fdf5e149c_1338x784.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!aLpe!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2945a216-6ded-453b-b76e-2a2fdf5e149c_1338x784.png" width="1338" height="784" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/2945a216-6ded-453b-b76e-2a2fdf5e149c_1338x784.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:784,&quot;width&quot;:1338,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;&quot;,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="https://substackcdn.com/image/fetch/$s_!aLpe!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2945a216-6ded-453b-b76e-2a2fdf5e149c_1338x784.png 424w, https://substackcdn.com/image/fetch/$s_!aLpe!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2945a216-6ded-453b-b76e-2a2fdf5e149c_1338x784.png 848w, https://substackcdn.com/image/fetch/$s_!aLpe!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2945a216-6ded-453b-b76e-2a2fdf5e149c_1338x784.png 1272w, https://substackcdn.com/image/fetch/$s_!aLpe!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2945a216-6ded-453b-b76e-2a2fdf5e149c_1338x784.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>It's a word game. Contract testing rewards a strict approach to your message shapes, and <a href="https://backtothefuture.fandom.com/wiki/Stanford_S._Strickland">Mr. Strickland</a> was strict enforcer in <em>Back to the Future</em>. That puts it in good company next to its sibling <a href="https://github.com/event-driven-io/emmett">Emmett</a>, named after Doc Emmett Brown. I also didn&#8217;t want to collide with the older <a href="https://github.com/jeffhandley/strickland">JS validation package</a>.</p><h2>How to use it?</h2><p>Getting back to the essence&#8230; How to use it?</p><p><strong>You write a small unit test that locks down a message&#8217;s format.</strong> Later, you rename a field, change a type, or adjust how a value serialises; the code still compiles and your other tests pass, but that one fails and points at what changed. You fix it in your build before a consumer or a stored event has hit the old format in production.</p><p>When a message changes by accident, a snapshot check shows you exactly what moved. When you evolve a message on purpose, a compatibility check confirms an old and a new version can still read each other&#8217;s data.</p><p>You can start by installing it from Maven Central, by adding dependency.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;xml&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-xml">&lt;dependency&gt;
  &lt;groupId&gt;io.event-driven&lt;/groupId&gt;
  &lt;artifactId&gt;strictland&lt;/artifactId&gt;
  &lt;version&gt;0.3.0&lt;/version&gt;
  &lt;scope&gt;test&lt;/scope&gt;
&lt;/dependency&gt;</code></pre></div><p>Then adding such test:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">MessageContract.specification(Json.Jackson.defaults())
    .given(new OrderPlaced(orderId, "Alice", placedAt))
    .whenSerialized()
    .thenContractIsUnchanged();</code></pre></div><p>The first run serializes the message and writes the result to an approved file named after the class, <code>OrderPlaced.approved.txt</code>, saved next to the test:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;json&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-json">{"orderId":"00000000-0000-0000-0000-000000000001","customer":"Alice","placedAt":"2024-01-01T12:00:00Z"}</code></pre></div><p>You review that file and commit it. From then on the check compares against it and fails if the format drifts, so a later change to the format shows up in the same pull request as the code that caused it.</p><p>A message under contract goes through one of two checks.</p><p>A <strong>snapshot check</strong> confirms the message still serializes exactly as it did when you last approved it, so nothing reading it downstream breaks. A failure means the format changed: a field renamed, a date format switched, a value newly dropped or added.</p><p>A <strong>compatibility check</strong> is for the version you evolve on purpose, so changing a message doesn&#8217;t leave the ones already in your store or on the wire stranded. Use <code>thenBackwardCompatible()</code> to confirm the newer version still reads a message the older one wrote, the events you stored last year, or a request already sent. Use <code>thenForwardCompatible()</code> to confirm a reader that hasn&#8217;t upgraded yet still reads a message the newer version writes, so you can ship the new shape before everyone reading it has caught up. Both compare the fields the two versions share and fail if a required one is missing or a shared value has changed.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;b4fde5e2-a605-4c6c-9d02-745415dc5a9c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@Test
void ensureOrderPlacedCompatibilityWithNewerVersion() {
    // Strictland specification
    MessageContract.specification(Json.Jackson.of(yourObjectMapper))
        .given(new OrderPlaced(orderId, &#8220;Alice&#8221;))
        .whenDeserializedAs(OrderPlacedWithCoupon.class)
        .thenBackwardCompatible();
}</code></pre></div><p>You review that file and commit it. From then on the check compares against it and fails if the format drifts, so a later change to the format shows up in the same pull request as the code that caused it.</p><p>As you see, in the specification, you pass your serializer.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">var spec = MessageContract.specification(Json.Jackson.of(yourObjectMapper))</code></pre></div><p>I encourage you to use your application's object mapper and pass the same <code>ObjectMapper</code> it uses, so the test checks the exact bytes you ship. That&#8217;ll help you avoid nasty surprises, where tests are using a different format than your framework or tooling.</p><p>Strictland provides an implementation of a sensible Jackson setup: ISO-8601 dates, nulls kept, unknown properties ignored on read. You can use it with <code>Json.Jackson.defaults()</code>. But it&#8217;s more of an accessible thing to get you started quickly.</p><p>You can also define your own serializer if you&#8217;re using an unsupported (yet?) format or serializer type. See basic examples in:</p><ul><li><p><a href="https://github.com/event-driven-io/strictland/blob/main/src/jvm/src/test/java/io/eventdriven/strictland/CsvMessageSerializer.java">CsvMessageSerializer</a> and its <a href="https://github.com/event-driven-io/strictland/blob/main/src/jvm/src/test/java/io/eventdriven/strictland/CsvMessageSerializerTests.java">tests</a> or,</p></li><li><p><a href="https://github.com/event-driven-io/strictland/blob/main/src/jvm/src/test/java/io/eventdriven/strictland/SimpleBinaryMessageSerializer.java">SimpleBinaryMessageSerializer</a> and its <a href="https://github.com/event-driven-io/strictland/blob/main/src/jvm/src/test/java/io/eventdriven/strictland/SimpleBinaryMessageSerializerTests.java">tests</a>.</p></li></ul><h2>Should you use it?</h2><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Lpc7!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faa5c58cb-d80b-47fb-b7af-f00626612303_220x148.gif" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Lpc7!,w_424,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faa5c58cb-d80b-47fb-b7af-f00626612303_220x148.gif 424w, https://substackcdn.com/image/fetch/$s_!Lpc7!,w_848,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faa5c58cb-d80b-47fb-b7af-f00626612303_220x148.gif 848w, https://substackcdn.com/image/fetch/$s_!Lpc7!,w_1272,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faa5c58cb-d80b-47fb-b7af-f00626612303_220x148.gif 1272w, https://substackcdn.com/image/fetch/$s_!Lpc7!,w_1456,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faa5c58cb-d80b-47fb-b7af-f00626612303_220x148.gif 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Lpc7!,w_1456,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faa5c58cb-d80b-47fb-b7af-f00626612303_220x148.gif" width="320" height="215.27272727272728" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/aa5c58cb-d80b-47fb-b7af-f00626612303_220x148.gif&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:148,&quot;width&quot;:220,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;Yes You Do GIFs | Tenor&quot;,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Yes You Do GIFs | Tenor" title="Yes You Do GIFs | Tenor" srcset="https://substackcdn.com/image/fetch/$s_!Lpc7!,w_424,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faa5c58cb-d80b-47fb-b7af-f00626612303_220x148.gif 424w, https://substackcdn.com/image/fetch/$s_!Lpc7!,w_848,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faa5c58cb-d80b-47fb-b7af-f00626612303_220x148.gif 848w, https://substackcdn.com/image/fetch/$s_!Lpc7!,w_1272,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faa5c58cb-d80b-47fb-b7af-f00626612303_220x148.gif 1272w, https://substackcdn.com/image/fetch/$s_!Lpc7!,w_1456,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faa5c58cb-d80b-47fb-b7af-f00626612303_220x148.gif 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><p>Strictland is young and pre-1.0, so the API can still move between versions. The checks themselves are small and well covered, and the snapshots they produce are just files in your repository, so trying it out costs little and commits you to nothing.</p><p>I&#8217;d genuinely like your feedback. If something is missing or awkward, tell me through comments, on <a href="https://discord.gg/fTpqUTMmVa">Discord</a> or open an <a href="https://github.com/event-driven-io/strictland/issues/new">issue</a>.</p><p>Currently, I&#8217;m working on making snapshot location more organised and configurable, not to end up with a bloated snapshots tests folder.</p><p>I also need to rethink the <em>given</em> name; it&#8217;s from Behaviour-Driven Design, but it appears to be a <a href="https://docs.scala-lang.org/scala3/reference/contextual/givens.html">reserved word in Scala 3</a>. Cross-language and cross-stack libs are much fun!</p><p>For now, for JVM, as the Java-based customers I&#8217;m helping also needed that, but soon want to add .NET and TypeScript/Javascript (and maybe more).</p><p>Tell me your honest thoughts, give it a try and a star if you liked it. Play with it, and pass me your feedback. It&#8217;ll motivate me to continue working on it!</p><p><strong>Would you like to learn more about consumer-driven tests and how Strictland can be used for them?</strong></p><p>Cheers!</p><p>Oskar</p><p>p.s. <strong>Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to <a href="https://www.icrc.org/en/donate/ukraine">Red Cross</a>, <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian organisation</a> or <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">donate Ambulances for Ukraine</a>.</p><p></p><p></p>]]></content:encoded></item><item><title><![CDATA[You can fork a package, but can you own it?]]></title><description><![CDATA[On managing software dependencies in a sane way]]></description><link>https://www.architecture-weekly.com/p/you-can-fork-a-package-but-can-you</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/you-can-fork-a-package-but-can-you</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 08 Jun 2026 14:56:15 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!Mrl8!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfa3fc-21e7-4b7d-b11e-8f233dfd2ff3_2048x1370.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Mrl8!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfa3fc-21e7-4b7d-b11e-8f233dfd2ff3_2048x1370.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Mrl8!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfa3fc-21e7-4b7d-b11e-8f233dfd2ff3_2048x1370.jpeg 424w, https://substackcdn.com/image/fetch/$s_!Mrl8!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfa3fc-21e7-4b7d-b11e-8f233dfd2ff3_2048x1370.jpeg 848w, https://substackcdn.com/image/fetch/$s_!Mrl8!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfa3fc-21e7-4b7d-b11e-8f233dfd2ff3_2048x1370.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!Mrl8!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfa3fc-21e7-4b7d-b11e-8f233dfd2ff3_2048x1370.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Mrl8!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfa3fc-21e7-4b7d-b11e-8f233dfd2ff3_2048x1370.jpeg" width="1456" height="974" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/63bfa3fc-21e7-4b7d-b11e-8f233dfd2ff3_2048x1370.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:974,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;A fork is just a split in the track; the real challenge is maintaining the infrastructure down the line.&quot;,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="A fork is just a split in the track; the real challenge is maintaining the infrastructure down the line." title="A fork is just a split in the track; the real challenge is maintaining the infrastructure down the line." srcset="https://substackcdn.com/image/fetch/$s_!Mrl8!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfa3fc-21e7-4b7d-b11e-8f233dfd2ff3_2048x1370.jpeg 424w, https://substackcdn.com/image/fetch/$s_!Mrl8!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfa3fc-21e7-4b7d-b11e-8f233dfd2ff3_2048x1370.jpeg 848w, https://substackcdn.com/image/fetch/$s_!Mrl8!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfa3fc-21e7-4b7d-b11e-8f233dfd2ff3_2048x1370.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!Mrl8!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfa3fc-21e7-4b7d-b11e-8f233dfd2ff3_2048x1370.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><blockquote><p>Fork your dependencies, trim them to only your use case, never update unless it breaks for your users. I&#8217;ve been vocal about this for 10+ years. I&#8217;ve always said that updating is way riskier than latent bugs (which can be tracked and CVEs monitored). </p><p>If you are updating a dependency, it&#8217;s on you to analyze every single commit in the full transitive set of dependencies. If you dont see anything compelling, dont update!</p><p>I remember at HashiCorp once in awhile an engineer would try to update a dep or replace a DIY lib with an external one and id always ask &#8220;show me the commit we need.&#8221; Dont update for the sake of it.</p><p>Feeling pretty swell about this mentality with all the supply chain attacks happening.</p></blockquote><p><strong><a href="https://x.com/mitchellh/status/2057171518027887035">That's from Mitchell Hashimoto</a>. A friend sent it to me, and the first word he reached for was bold.</strong> Mine too. I mostly agree with him, honestly. But the second I read it, my head jumped to a caveat, and the caveat turned out to be the thing I actually wanted to write about.</p><p><strong>You can fork a small library and trim it to what you use. I&#8217;ve done it, it&#8217;s fine. But could you fork React and maintain it?</strong> I couldn&#8217;t. I don&#8217;t think most teams could either. So &#8220;fork your dependencies&#8221; is wonderful advice right up to the point where the dependency is too big to hold in your hands, and then it quietly stops being advice at all.</p><p>And that caveat says something about the advice itself. I think that it was never really about forking. It&#8217;s about knowing exactly what you&#8217;ve taken on and being willing to own it, and that&#8217;s the bit most of us skip. We don&#8217;t <em>decide</em> to take a dependency. We install one. And almost everything that goes wrong with dependencies:</p><ul><li><p>the supply chain attacks, </p></li><li><p>the licence dramas, </p></li><li><p>the half-finished SBOMs, </p></li><li><p>the things you find out about only after they break </p></li></ul><p>comes back to that one move: we took dependence on someone else's product without ever deciding to. Everything else here is a variation on it.</p><h2>To fork or not?</h2><p>I was reminded of this not long ago. I was doing a live Q&amp;A about my TypeScript port of some code, and a whole chunk of it was one question asked five different ways: </p><blockquote><p>Why did you write your own <code>deepEquals</code> instead of just pulling a <a href="https://www.npmjs.com/package/deep-equal">package</a>? </p></blockquote><p>I tried to explain that it wasn't laziness, and it wasn't not-invented-here pride either. I explained that it&#8217;s the code I write once and forget. Of course, it&#8217;ll take some time, but not that long as you might think. The reception was, let's say, mixed. I get it, it&#8217;s much easier to just install a package and outsource maintenance. Especially when you're in a hurry, you don't decide anything. You reach. And this reach can be a decent interim solution, but then you should reflect on yourself on the next step.</p><p><strong>Why do I reach for my own code on the small stuff?</strong> Look at which packages actually get hit in all these supply chain attacks we keep reading about. It&#8217;s almost always the little ones. The one-liners. Remember the <a href="https://en.wikipedia.org/wiki/Npm_left-pad_incident">Left-pad Incident</a>? It&#8217;s usually some tiny or low-level helper nobody thinks about. They&#8217;re everywhere, they&#8217;re trivial, and precisely because they&#8217;re trivial, nobody is watching them. Which is also exactly why they&#8217;re so easy to write yourself. So for me, handwriting a small helper isn&#8217;t paranoia. It&#8217;s the calmer option, and it&#8217;s one of the few cases where I know exactly what&#8217;s in my own system.</p><p>That's also why, in <a href="https://github.com/event-driven-io/emmett">Emmett, my Event Sourcing library,</a> I'm almost stubborn about keeping the core package free of dependencies and limiting whatever I inject elsewhere. Probably too stubborn, if I'm honest. But I'd rather err that way, because when it goes wrong on the user&#8217;s side, it goes wrong far worse than a bit of code I maintain myself. It&#8217;s about responsibility for the outcome of your actions.</p><p>If I had to compress what I believe into one line, it wouldn&#8217;t be &#8220;fork everything&#8221;, and it wouldn&#8217;t be &#8220;write everything yourself&#8221;. It would be something duller than both: </p><div class="callout-block" data-callout="true"><p>Be precise about which dependencies you take on, look at how many dependencies your dependencies pull in, and treat that as part of the decision. </p></div><p>The number itself isn&#8217;t the point. It just tells you how much you&#8217;re agreeing to own without ever seeing it.</p><p>Because your direct dependencies were never the worst part. You chose those. Most of the time, you can read them, and you can follow what they&#8217;re doing. The scary part is the dependencies of your dependencies, and theirs, all the way down, the part you didn&#8217;t choose, can&#8217;t see, and have no say over.</p><p>And I want to be careful here, because it&#8217;s easy to let this slide into another round of JavaScript-bashing, and that&#8217;s not what I mean. Every ecosystem has this. JS and TypeScript just sit at one far end of it, where there&#8217;s a package for absolutely everything. Which is good, in general,  you&#8217;re not rebuilding the wheel every other week, the way you are in some other places. But it&#8217;s also how you end up with a node_modules you couldn&#8217;t fully explain if someone put a gun to your head. </p><p>At the other extreme, there&#8217;s Microsoft and .NET, where the instinct runs so hard the opposite way that it tips into <a href="https://en.wikipedia.org/wiki/Not_invented_here">Not Invented Here Syndrome</a>. Neither end is the &#8220;right&#8221; one. They&#8217;re both defaults people drift into without ever making a call.</p><p><strong>So for me, it&#8217;s not about reaching zero dependencies. But having dependencies that we cautiously agreed upon.</strong></p><p>Which takes me to the part that, in my experience, almost nobody does. You can&#8217;t make a call on what you can&#8217;t see, and if you don&#8217;t even have the basic knowledge (e.g. some list) of what you depend on, then every conversation about supply chain risk is a bit of theatre.</p><h2>Dependency Inventory</h2><p><strong>In most of the environments, <a href="https://openssf.org/technical-initiatives/sbom-tools/">there are tools</a> to generate the <a href="https://github.com/resources/articles/what-is-an-sbom-software-bill-of-materials">Software Bill of Materials</a> - the inventory of your dependency tree.</strong> In some, they&#8217;re even built in. It&#8217;s easy to dunk on NPM, but it&#8217;d be better to do due diligence before doing so. Not many people seem to know this, but recent versions of npm ship an <a href="https://docs.npmjs.com/cli/v11/commands/npm-sbom">npm sbom</a>. So the tooling exists, even in NPM. That isn&#8217;t the problem.</p><p>The problem is that most organisations have never generated one in their life. No SBOM, no inventory, nothing written down anywhere. So the day the next <a href="https://en.wikipedia.org/wiki/Log4Shell">Log4Shell</a> lands, and there will be a next one, they can&#8217;t answer the very first question anyone will ask them: do we run this, and if so, where?</p><p><strong>On the other hand, tools often don't help here, even those built to do so.</strong> <a href="https://overreacted.io/npm-audit-broken-by-design/">NPM audit mostly does the opposite</a>. I honestly can&#8217;t remember the last time I installed something, and the audit didn&#8217;t immediately tell me to bump a stack of packages. Most of it is false positives, with no real attempt to say how dangerous any of it is. And that lands you in the oldest trap going: if it&#8217;s always red, you stop looking at red. So the one signal that was supposed to make you stop and decide ends up training everyone to decide nothing.</p><h2>Bus factor and rug pulls</h2><p>There&#8217;s a related thing I can&#8217;t quite leave alone, so let me wander into it. I think a lot of teams spend their energy on the symptom and never once look at the source.</p><p>Watch what happens in the .NET world whenever a popular package changes the deal. <a href="https://www.infoq.com/news/2025/01/fluent-assertions-v8-license/">Fluent Assertions</a> went commercial. <a href="https://snyk.io/blog/moq-package-exfiltrates-user-emails/">Moq shipped a thing that quietly hashed your git email and phoned it home</a>. MassTransit and <a href="https://www.jimmybogard.com/automapper-and-mediatr-going-commercial/">AutoMapper</a> announced commercial licenses within the same stretch. And nearly every time, the reaction across .NET shops is identical. It&#8217;s a mixture of </p><ul><li><p>let&#8217;s rip it out and write their own,</p></li><li><p>search for a free alternative</p></li><li><p>Cry to Microsoft to buy the lib or provide a replacement,</p></li></ul><p>Essentially: a throw-the-baby-out-with-the-bathwater strategy.</p><p>And for me, that&#8217;s solving the wrong thing entirely. The source isn&#8217;t that the package started charging money, or pulled a rug. <strong>The source is that we took on a critical dependency without ever admitting to ourselves that it was critical, and never once thought about what we&#8217;d do if the terms changed.</strong> We didn&#8217;t consider the bus factor, and we didn&#8217;t do due diligence to ensure the work on it was sustainable and could continue. Pulling it out and hand-rolling a replacement fixes none of that. It just resets the same trap, this time with only the code we maintain.</p><p><a href="https://www.identityserver.com/articles/identityserver-vnext-duende-identityserver">The IdentityServer episode</a> was the clearest version of it I&#8217;ve seen. People were upset that they had to pay suddenly. Then, in the next sentence, calling it a critical security component. Then, in the sentence after that, asking what the free alternatives were. A critical security component that you want for free and are ready to swap out overnight is, to my mind, asking for a security incident.</p><p>And there&#8217;s a bit of maths that quietly settles most of these arguments, if anyone bothers to do it. Take what the licence costs you per year. Then take into account what it would cost to have an engineer build and maintain your own version. Put the two side by side. </p><p>Almost every time, paying the maintainer comes out cheaper, and on top of that, you&#8217;ve lowered the bus factor on something you already lean on, which is its own kind of supply chain security. &#8220;We&#8217;d write it ourselves, but then we&#8217;d have to maintain it&#8221; is true. I just read it as the argument for paying the person who already does, not against it. If you depend on something, its survival is your problem too. That&#8217;s part of owning the decision.</p><p><a href="https://www.architecture-weekly.com/p/why-open-source-isnt-always-fair">I know this case too well</a>.</p><h2>LLM as a fork</h2><p>Getting back to Mitchell&#8217;s thought. The part I find most interesting is because of the moment we&#8217;re in. I keep hearing that LLMs change all of this, that writing your own small things is suddenly trivial, so the whole dependency question softens. I don&#8217;t buy it. It&#8217;s never that easy. Writing the small thing was never the hard part anyway. Owning it, understanding it, maintaining it, being the one on the hook when it breaks at 2 am, that&#8217;s the hard part, and no model takes that off your plate.</p><p>I don&#8217;t see how LLMs can change the cost of owning code. They can (<a href="https://www.architecture-weekly.com/p/the-end-of-coding-wrong-question">maybe</a>) change the cost of producing it. That doesn&#8217;t fix the &#8220;install without deciding&#8221;. The old move was install and move on. The new move is &#8220;vibe it&#8221; and move on. Same missing decision, new flavour. The same lack of responsibility and ownership.</p><p>This trend isn&#8217;t new. It&#8217;s a classic <a href="https://en.wikipedia.org/wiki/Shadow_IT">Shadow IT</a>. If you haven&#8217;t been around long enough to run into the term, Shadow IT refers to the tools and systems people build or adopt within a company without going through whoever is officially meant to approve them. The spreadsheet that quietly runs a whole department. The little script someone wrote on a Friday that half the team now depends on. The integration nobody in the platform group has ever heard of. It has always existed because people route around slow governance to get their job done, and most of the time, nobody notices until it breaks.</p><p>With LLMs, it&#8217;s more tempting than it has ever been. Someone in sales promises a customer a feature the team supposedly needs. The team has no time, so they cobble a tool together from the API and ship it. It doesn&#8217;t work. The customer says they&#8217;re not paying for this. It escalates. The thing was unowned from the moment it was conceived; nobody decided to take it on, it just appeared, and the blame game is starting.</p><p>And here&#8217;s where I think it all settles, because the corporate steamroller flattens everything in the end. Companies will dictate the allowed list, the way they always have. The cautious majority will stick to what&#8217;s known and popular: React, TypeScript, Python, Spring Boot. That&#8217;s what they did last time, and the time before. And the people who want to move faster will do it off the books, with an LLM, as Shadow IT. The declarative, standards-based frameworks that hide their complexity will do well in that world, because that style suits how these tools work, but it&#8217;s the same ceiling as before. You bet on React. You don&#8217;t own it. The small stuff you can hold; the big stuff stays as bet.</p><h2>What to do then?</h2><p>We cannot fix the entire software industry, but we can fix how our own engineering teams operate. Instead of waiting for automated audits to scream at us, or ripping out packages in an emotional panic, I suggest a simple, regular exercise for your organisation.</p><p>Sit down and explicitly define your dependency posture:</p><ol><li><p><strong>Inventory:</strong> List the dependencies you use (even without peer dependencies). Use tools  <code>npm-sbom</code> to actually see what you are pulling in.</p></li><li><p><strong>Criticality:</strong> Identify which of these packages are absolutely critical to your system.</p></li><li><p><strong>Lifecycle:</strong> Define a clear strategy for upgrading and versioning them. Are you updating just for the sake of it, or are you looking for specific commits like Mitchell suggests?</p></li><li><p><strong>The Bus Factor:</strong> Ask yourself: what happens if the author of a critical package gets hit by a bus, burns out, or the tool becomes paid?</p></li><li><p><strong>Mitigation:</strong> Decide on a concrete backup plan for that exact scenario. Do you fork it? Do you pay the license fee? Maybe pay earlier for support or help in another way to maintain it.</p></li><li><p><strong>Response Time:</strong> Estimate how quickly you can upgrade and deploy the application if a major security breach occurs in a dependency. Also, if the strategy is to use replacement, then how fast will you be able to replace this dependency?</p></li></ol><p>Building reliable software requires intent. We don't have to write everything from scratch, but we must be precise about what we bring into our software. Architecture is not just about writing code; it is about choosing which liabilities you are willing to own.</p><p>Cheers,</p><p>Oskar</p><p>p.s.2. <strong>Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to <a href="https://www.icrc.org/en/donate/ukraine">Red Cross</a>, <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian organisation</a> or <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">donate Ambulances for Ukraine</a>.</p>]]></content:encoded></item><item><title><![CDATA[How soon is now in PostgreSQL?]]></title><description><![CDATA[On troubleshooting nasty bug in PostgreSQL and on good tests vs proper tests]]></description><link>https://www.architecture-weekly.com/p/how-soon-is-now-in-postgresql</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/how-soon-is-now-in-postgresql</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 25 May 2026 12:29:58 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!4LmN!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Feabeb60e-b28d-46a8-b1bd-c88c5c811cb8_800x436.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!4LmN!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Feabeb60e-b28d-46a8-b1bd-c88c5c811cb8_800x436.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!4LmN!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Feabeb60e-b28d-46a8-b1bd-c88c5c811cb8_800x436.png 424w, https://substackcdn.com/image/fetch/$s_!4LmN!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Feabeb60e-b28d-46a8-b1bd-c88c5c811cb8_800x436.png 848w, https://substackcdn.com/image/fetch/$s_!4LmN!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Feabeb60e-b28d-46a8-b1bd-c88c5c811cb8_800x436.png 1272w, https://substackcdn.com/image/fetch/$s_!4LmN!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Feabeb60e-b28d-46a8-b1bd-c88c5c811cb8_800x436.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!4LmN!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Feabeb60e-b28d-46a8-b1bd-c88c5c811cb8_800x436.png" width="800" height="436" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/eabeb60e-b28d-46a8-b1bd-c88c5c811cb8_800x436.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:436,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;cover&quot;,&quot;title&quot;:&quot;cover&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="cover" title="cover" srcset="https://substackcdn.com/image/fetch/$s_!4LmN!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Feabeb60e-b28d-46a8-b1bd-c88c5c811cb8_800x436.png 424w, https://substackcdn.com/image/fetch/$s_!4LmN!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Feabeb60e-b28d-46a8-b1bd-c88c5c811cb8_800x436.png 848w, https://substackcdn.com/image/fetch/$s_!4LmN!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Feabeb60e-b28d-46a8-b1bd-c88c5c811cb8_800x436.png 1272w, https://substackcdn.com/image/fetch/$s_!4LmN!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Feabeb60e-b28d-46a8-b1bd-c88c5c811cb8_800x436.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><strong>How soon is now? In PostgreSQL, it&#8217;s not always as soon as you&#8217;d think.</strong> I learned that the hard way recently, so you don&#8217;t have to.</p><p>It took me hours and wasn&#8217;t easy to reproduce, even though the fix is one line. I found it in <a href="https://www.cybertec-postgresql.com/en/postgresql-now-vs-nowtimestamp-vs-clock_timestamp/">a Cybertec post</a>, as I quite often do when I&#8217;m staring at something odd in PostgreSQL. I&#8217;m supposed to know my way around the database, but I missed it, which is another reason I want to write this down.</p><p>I was working on distributed locking in <a href="https://github.com/event-driven-io/emmett">Emmett</a>. When you scale a service horizontally, you can easily end up with two instances of the same message processor running at once. That&#8217;s bad. Both instances would pull the same events, both would write to the same projection storage, and we&#8217;d get duplicated side effects, overwritten state and broken checkpoints. So we need to guarantee that exactly one instance of each processor is active at any time. Emmett does that using two things working hand in glove: PostgreSQL advisory locks and a row in the <code>emt_processors</code> table. The row keeps the durable side of ownership: which instance currently holds the processor (<code>processor_instance_id</code>), when it last checked in (<code>last_updated</code>), and what state it&#8217;s in (<code>status</code>). I described the full design in <a href="https://event-driven.io/en/rebuilding_event_driven_read_models/">Rebuilding Event-Driven Read Models in a safe and resilient way</a>, so I won&#8217;t bore you with the whole picture here.</p><p>For this story, the part that matters is what happens when an instance crashes. The crashed processor&#8217;s connection is gone, so its advisory lock has already been released. A new instance can grab the advisory lock without resistance. But the row in <code>emt_processors</code> still says <code>status = 'running'</code> and still points to the previous owner, because the crash didn&#8217;t give anyone a chance to clean it up.</p><p>From the outside, we can&#8217;t tell whether the previous owner has crashed or is just between heartbeats. So we wait. If the row&#8217;s <code>last_updated</code> is older than a configurable timeout, the new instance is allowed to claim ownership anyway. Anyone quiet for that long is treated as gone. To make this graceful, the lock acquisition runs inside a retry policy. A fresh instance starting just after a crash doesn&#8217;t fail straight away; it retries until the timeout window expires.</p><h2>The bug</h2><p>The takeover decision lives in the upsert against <code>emt_processors</code>. In the real function, that upsert sits inside a Common Table Expression (CTE) alongside a <code>pg_try_advisory_xact_lock</code> call.</p><p>For the record: the snippets below skip that wrapping (and trim a couple of unused parameters) to keep the focus on the upsert, where the bug lives. The full version is in <a href="https://github.com/event-driven-io/emmett/blob/4c5909982313654f7df383a44b02a14a04f30b50/src/packages/emmett-postgresql/src/eventStore/schema/processors/processorsLocks.ts">the source</a>.</p><pre><code><code>CREATE OR REPLACE FUNCTION emt_try_acquire_processor_lock(
    p_processor_id           TEXT,
    p_processor_instance_id  TEXT,
    p_lock_timeout_seconds   INT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
AS $$
BEGIN
  INSERT INTO emt_processors (processor_id, processor_instance_id, status, last_updated)
  VALUES (p_processor_id, p_processor_instance_id, 'running', now())
  ON CONFLICT (processor_id) DO UPDATE
  SET processor_instance_id = p_processor_instance_id,
      status                = 'running',
      last_updated          = now()
  WHERE   
     -- same instance reconnecting
     emt_processors.processor_instance_id = p_processor_instance_id                      
     -- previous owner stopped cleanly
     OR emt_processors.status = 'stopped'     
     -- previous owner timed out    
     OR emt_processors.last_updated
        &lt; now() - (p_lock_timeout_seconds || ' seconds')::interval;
  RETURN FOUND;
END;
$$;</code></code></pre><p>The last branch is the takeover. It reads naturally: if the previous owner hasn&#8217;t checked in for longer than the timeout, the new instance can replace them. All tests were green. Stop me if you think you&#8217;ve heard this one before. The problem surfaced through user feedback (thanks, <a href="https://www.linkedin.com/in/martindilger/">Martin</a>!), and it took me a long time to reproduce; none of the existing tests covered the scenario that triggered it. <a href="https://github.com/event-driven-io/emmett/pull/339/changes#diff-c790c5d796e8e155d3e621f9b5bc2843503eda7c121c7715c232dbe9608a8964R741">Once I had a new end-to-end test that pinpointed the symptom</a>, the rest was the usual, long, boring, debugging loop.</p><p>To see why, open <code>psql</code> and run this:</p><pre><code><code>BEGIN;
SELECT now() AS tx_now, clock_timestamp() AS wall_clock;
SELECT pg_sleep(2);
SELECT now() AS tx_now, clock_timestamp() AS wall_clock;
COMMIT;</code></code></pre><p>You&#8217;ll get something like:</p><pre><code><code>            tx_now             |          wall_clock
-------------------------------+-------------------------------
 2026-05-25 10:00:00.123456+00 | 2026-05-25 10:00:00.124012+00

            tx_now             |          wall_clock
-------------------------------+-------------------------------
 2026-05-25 10:00:00.123456+00 | 2026-05-25 10:00:02.131845+00</code></code></pre><p>The first column is the same in both rows. The second one is two seconds apart. As it turns out, <code>now()</code> is a synonym for <code>transaction_timestamp()</code>: it returns the time the transaction began, and keeps returning that value for every statement inside the same transaction. A light that never goes out, in other words. <code>clock_timestamp()</code> reads the wall clock each time it&#8217;s called, so it advances as time does. Cybertec wrote <a href="https://www.cybertec-postgresql.com/en/postgresql-now-vs-nowtimestamp-vs-clock_timestamp/">a good walkthrough</a> of the whole family of timestamp functions if you want the full picture.</p><p>What difference does it make? For a column like <code>last_updated</code>, the constancy of <code>now()</code> is usually what you want: every row touched in the same transaction shares a single timestamp, which keeps audit logs and write batches coherent. For asking &#8220;has enough time passed?&#8221; inside the same transaction, the same constancy works against us.</p><p>Now back to the retry. The consumer that calls <code>tryAcquire</code> looks roughly like this:</p><pre><code><code>pool.withTransaction((tx) =&gt;
  asyncRetry(
    () =&gt; tryAcquireProcessorLock(tx.execute, options),
    { retries: 10, minTimeout: 200, maxTimeout: 1000 },
  ),
);</code></code></pre><p><code>pool.withTransaction</code> opens a database transaction and passes its executor to the body. <code>asyncRetry</code> then repeatedly calls the stored procedure on that same executor, with a backoff between attempts. So even though the retries are spread out in real time, every call runs inside the same database transaction:</p><pre><code><code>withTransaction        (transaction starts at T)
  &#9492;&#9472;&#9472; asyncRetry
       &#9500;&#9472;&#9472; call lock function    &#8594; now() = T
       &#9500;&#9472;&#9472; call lock function    &#8594; now() = T   (200 ms later)
       &#9500;&#9472;&#9472; call lock function    &#8594; now() = T   (400 ms later)
       &#9492;&#9472;&#9472; ...</code></code></pre><p><code>last_updated &lt; now() - timeout</code> evaluates the same way every iteration. The predicate is effectively constant for the lifetime of that transaction. From the database&#8217;s perspective, no time was passing between attempts, even though the retries were spread across real seconds. (of course, the valid question is whether retries should happen inside a transaction, but let&#8217;s say that this is out of scope of today&#8217;s article, deal?).</p><p>So what was the fix? Change the time source inside the function. PL/pgSQL lets you declare local variables, so I added one at the top, initialised from <code>clock_timestamp()</code>, and used it everywhere the function previously called <code>now()</code>:</p><pre><code><code>CREATE OR REPLACE FUNCTION emt_try_acquire_processor_lock(
    p_processor_id           TEXT,
    p_processor_instance_id  TEXT,
    p_lock_timeout_seconds   INT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
AS $$
DECLARE
  v_current_time TIMESTAMPTZ := clock_timestamp();
BEGIN
  INSERT INTO emt_processors (processor_id, processor_instance_id, status, last_updated)
  VALUES (p_processor_id, p_processor_instance_id, 'running', v_current_time)
  ON CONFLICT (processor_id) DO UPDATE
  SET processor_instance_id = p_processor_instance_id,
      status                = 'running',
      last_updated          = v_current_time
  WHERE emt_processors.processor_instance_id = p_processor_instance_id
     OR emt_processors.status = 'stopped'
     OR emt_processors.last_updated
        &lt; v_current_time - (p_lock_timeout_seconds || ' seconds')::interval;
  RETURN FOUND;
END;
$$;</code></code></pre><p><code>clock_timestamp()</code> ignores transaction boundaries. Every call to the stored procedure reads the wall clock fresh, so each retry sees a slightly later value than the last. After enough retries inside the wrapping transaction, the takeover predicate flips, and the new instance wins.</p><p>The function uses the timestamp in two places: when setting <code>last_updated</code> on the new owner, and when comparing the previous owner&#8217;s <code>last_updated</code> to the timeout. I switched both to <code>v_current_time</code>, so the write and the check read from the same wall clock. Mixing <code>clock_timestamp()</code> on one side and <code>now()</code> on the other would leave a subtler version of the same bug.</p><p>A cleaner option for the future is to move the retry one layer up, so each attempt opens its own transaction. That would remove the trap entirely and let the function go back to plain <code>now()</code>. For now, the local variable does the job.</p><h2>Why my tests didn&#8217;t catch it</h2><p>Now to the testing side. I had a careful suite of integration tests for the stored procedure. Two instances racing for the lock. The same instance reconnects after a crash. Takeover after a custom timeout. They all passed, and they could not have caught this bug. Here&#8217;s the shape of a typical one:</p><pre><code><code>await pool.withTransaction((connection) =&gt;
  lock.tryAcquire({ execute: connection.execute }),
);</code></code></pre><p>That&#8217;s the shape: set up some state, call <code>tryAcquire</code> once, check the result. A single call to the stored procedure works fine with <code>now()</code> in the WHERE clause. There is only one timestamp involved per call, and the predicate evaluates correctly against it. The bug only shows up when several calls share one transaction, which happens when the consumer&#8217;s <code>withTransaction</code> wraps a retry policy. The stored-procedure tests never put those two together.</p><p>The end-to-end consumer tests do go through the consumer&#8217;s <code>withTransaction</code> wrapper, but they covered the happy paths: clean start, clean stop, two consumers competing, and an instance reclaiming its own stale lock. None of them combined the three conditions that together expose the bug:</p><ol><li><p>a previous owner whose row still says <code>status = 'running'</code> (a crash, not a graceful stop),</p></li><li><p>a new instance arriving with a different instance ID,</p></li><li><p>a retry acquisition policy with a timeout short enough for the retries to outlast it inside the test&#8217;s deadline.</p></li></ol><p>Any one of those missing and the takeover predicate either succeeded on the first attempt (so the retry never fired), or the test finished before the retry&#8217;s failure to make progress was visible.</p><p>So why did this slip through both layers? The stored-procedure tests never combined a retry policy with the stale-row state, so they never produced multiple calls inside one transaction. The end-to-end tests did exercise the retry path, but none of them happened to combine all three conditions above at once. Both layers had blind spots, and the bug lived exactly where they overlapped.</p><p>The Pull Request that fixes the bug also adds a new family of tests that mount <code>tryAcquire</code> under the same transactional wrapper the real consumer uses, with the crash + new-instance + retry combination wired up on purpose. That&#8217;s the kind of test I should have had from the start.</p><h2>What I&#8217;m taking away</h2><p>Two things about <code>now()</code>:</p><ul><li><p><code>now()</code><strong> is the right tool when you want every row touched in the same transaction to share a timestamp.</strong> <code>created_at</code>, <code>last_updated</code>, audit columns. That stability is a feature.</p></li><li><p><code>now()</code><strong> is the wrong tool when you want to ask &#8220;has time moved on?&#8221; from inside a transaction.</strong> Use <code>clock_timestamp()</code> when you genuinely mean the wall clock.</p></li></ul><p>And one harder thing about tests. Inner tests for stored procedures give me a tight feedback loop and pinpoint failures, but only inside the scaffolding I build for them. End-to-end tests run the real wiring, but I can&#8217;t enumerate every combination of timeouts, instance IDs and crash states without the suite collapsing under its own weight. The combination where this bug lived wasn&#8217;t reachable from either side by accident; it needed a deliberate setup.</p><p>I don&#8217;t have a clean rule for where to draw that line, and honestly, I don&#8217;t think there is one. My takeaway is to look at the seam: the spot where the inner test invokes the code differently from how the production caller invokes it. Here, it was a single-call test against a retry loop sharing one transaction. Wherever that gap sits, write a test there. Not at the unit level, not at the full end-to-end level, but in a setup that mirrors how the real caller actually drives the code and exercises the path you care about.</p><h2>TLDR</h2><p><code>now()</code> returns the start of the current transaction, not the current moment. Inside a transaction it doesn&#8217;t change between statements. If you wrap a retry loop around a function that uses <code>now()</code> in a <code>WHERE</code> clause, and the retry loop runs inside one transaction, the predicate is frozen and the retries do nothing. Use <code>clock_timestamp()</code> when you mean &#8220;right now&#8221;. And pay extra attention to the seam between inner and end-to-end tests, because that&#8217;s where mismatches between how tests drive the code and how production drives it tend to hide.</p><p>The fix and the new tests live in <a href="https://github.com/event-driven-io/emmett/pull/339">Emmett Pull Request #339</a>.</p><p>Uff. That bug was nasty.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Wns-!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0eb23d4f-a0bf-499b-9857-f36f64ae223a_889x500.gif" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Wns-!,w_424,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0eb23d4f-a0bf-499b-9857-f36f64ae223a_889x500.gif 424w, https://substackcdn.com/image/fetch/$s_!Wns-!,w_848,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0eb23d4f-a0bf-499b-9857-f36f64ae223a_889x500.gif 848w, https://substackcdn.com/image/fetch/$s_!Wns-!,w_1272,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0eb23d4f-a0bf-499b-9857-f36f64ae223a_889x500.gif 1272w, https://substackcdn.com/image/fetch/$s_!Wns-!,w_1456,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0eb23d4f-a0bf-499b-9857-f36f64ae223a_889x500.gif 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Wns-!,w_1456,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0eb23d4f-a0bf-499b-9857-f36f64ae223a_889x500.gif" width="889" height="500" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/0eb23d4f-a0bf-499b-9857-f36f64ae223a_889x500.gif&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:500,&quot;width&quot;:889,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:12212665,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/gif&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.architecture-weekly.com/i/199180926?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0eb23d4f-a0bf-499b-9857-f36f64ae223a_889x500.gif&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!Wns-!,w_424,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0eb23d4f-a0bf-499b-9857-f36f64ae223a_889x500.gif 424w, https://substackcdn.com/image/fetch/$s_!Wns-!,w_848,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0eb23d4f-a0bf-499b-9857-f36f64ae223a_889x500.gif 848w, https://substackcdn.com/image/fetch/$s_!Wns-!,w_1272,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0eb23d4f-a0bf-499b-9857-f36f64ae223a_889x500.gif 1272w, https://substackcdn.com/image/fetch/$s_!Wns-!,w_1456,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0eb23d4f-a0bf-499b-9857-f36f64ae223a_889x500.gif 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Read also:</p><ul><li><p><a href="https://event-driven.io/en/rebuilding_read_models_skipping_events/">Rebuilding Event-Driven Read Models in a safe and resilient way</a>, with the locking design this bug lives inside,</p></li><li><p><a href="https://www.architecture-weekly.com/p/distributed-locking-a-practical-guide">Distributed Locking: A Practical Guide</a>,</p></li><li><p><a href="https://event-driven.io/en/consumers_processors_in_emmett/">Consumers, projectors, reactors and all that messaging jazz in Emmett</a>,</p></li><li><p><a href="https://event-driven.io/en/checkpointing_message_processing/">Checkpointing the message processing</a>,</p></li><li><p><a href="https://www.cybertec-postgresql.com/en/postgresql-now-vs-nowtimestamp-vs-clock_timestamp/">Cybertec: PostgreSQL now() vs now()::timestamp vs clock_timestamp()</a>,</p></li><li><p><a href="https://event-driven.io/en/is_keeping_utc_dates_best_solution/">Is keeping dates in UTC really the best solution?</a>.</p></li></ul><p>Or my other articles about PostgreSQL:</p><ul><li><p><a href="https://event-driven.io/en/postgres_superpowers/">Postgres Superpowers in Practice</a>,</p></li><li><p><a href="https://www.architecture-weekly.com/p/postgresql-partitioning-logical-replication">PostgreSQL partitioning, logical replication and other Q&amp;A</a></p></li><li><p><a href="https://www.architecture-weekly.com/p/postgresql-jsonb-powerful-storage">PostgreSQL JSONB - Powerful Storage for Semi-Structured Data</a></p></li><li><p><a href="https://event-driven.io/en/push_based_outbox_pattern_with_postgres_logical_replication/">Push-based Outbox Pattern with Postgres Logical Replication</a></p></li><li><p><a href="https://event-driven.io/en/how_to_get_all_messages_through_postgres_logical_replication/">How to get all messages through Postgres logical replication</a></p></li><li><p><a href="https://www.architecture-weekly.com/p/the-write-ahead-log-a-foundation">The Write-Ahead Log: The underrated Reliability Foundation for Databases and Distributed systems</a></p></li><li><p><a href="https://event-driven.io/en/ordering_in_postgres_outbox/">How Postgres sequences issues can impact your messaging guarantees</a></p></li></ul><p>Cheers!</p><p>Oskar</p><p>p.s. <strong>Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to <a href="https://www.icrc.org/en/donate/ukraine">Red Cross</a>, <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian organisation</a> or <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">donate Ambulances for Ukraine</a>.</p>]]></content:encoded></item><item><title><![CDATA[On mashing up modelling techniques for fun and profit]]></title><description><![CDATA[Many people believe there should be one, and only one, way to model software.]]></description><link>https://www.architecture-weekly.com/p/on-mashing-up-modelling-techniques</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/on-mashing-up-modelling-techniques</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 18 May 2026 13:54:31 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!9FDq!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd28b4bca-2bce-43af-b895-adbd4d8e4e79_8788x5575.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Many people believe there should be one, and only one, way to model software. I think differently, I like to mix different techniques.</p><p>For instance, I believe there&#8217;s a strong synergy between <a href="https://c4model.com/">C4 Model</a>, <a href="https://github.com/ddd-crew/context-mapping">Context Maps</a>, and EventStorming. They all allow us to look at the system from a different angle and act as simulations, providing different feedback on whether our model will fly.</p><p>Look below for the diagram I prepared for my upcoming <a href="https://event-driven.io/en/training/">workshop</a>.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!9FDq!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd28b4bca-2bce-43af-b895-adbd4d8e4e79_8788x5575.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!9FDq!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd28b4bca-2bce-43af-b895-adbd4d8e4e79_8788x5575.jpeg 424w, https://substackcdn.com/image/fetch/$s_!9FDq!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd28b4bca-2bce-43af-b895-adbd4d8e4e79_8788x5575.jpeg 848w, https://substackcdn.com/image/fetch/$s_!9FDq!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd28b4bca-2bce-43af-b895-adbd4d8e4e79_8788x5575.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!9FDq!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd28b4bca-2bce-43af-b895-adbd4d8e4e79_8788x5575.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!9FDq!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd28b4bca-2bce-43af-b895-adbd4d8e4e79_8788x5575.jpeg" width="1456" height="924" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/d28b4bca-2bce-43af-b895-adbd4d8e4e79_8788x5575.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:924,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:3664237,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/jpeg&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://www.architecture-weekly.com/i/198260672?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd28b4bca-2bce-43af-b895-adbd4d8e4e79_8788x5575.jpeg&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!9FDq!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd28b4bca-2bce-43af-b895-adbd4d8e4e79_8788x5575.jpeg 424w, https://substackcdn.com/image/fetch/$s_!9FDq!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd28b4bca-2bce-43af-b895-adbd4d8e4e79_8788x5575.jpeg 848w, https://substackcdn.com/image/fetch/$s_!9FDq!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd28b4bca-2bce-43af-b895-adbd4d8e4e79_8788x5575.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!9FDq!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd28b4bca-2bce-43af-b895-adbd4d8e4e79_8788x5575.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>It&#8217;s a C4 diagram showing containers in a hospitality system (so-called Property Management, where property means hotel).</p><p>A container gives quick feedback on how many pieces I&#8217;ll potentially need to manage and deploy, and it also shows me boundaries.</p><p><a href="https://github.com/ddd-crew/context-mapping">Context Maps</a> help in understanding how they relate to each other, which module or team has bigger leverage, and can force more on others. It can also show me the information flow and simulate which module will expose the API.</p><p>I can then take some business process and have a look at how the message flow will look. This can happen as an early simulation or after EventStorming sessions.</p><p>Then C4 allow me to also look inside the container, show components if I need to understand more details. I can group containers if my bounded context has more of them. </p><p>How to start with it? Let me show you an example.</p><p>The starting point could be understanding the system's overall view; integrations with external systems usually introduce the most complexity. C4 System Diagram works well for that.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!MrsA!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8035a31a-1bc4-47d2-9e31-da6a92814873_4021x3978.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!MrsA!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8035a31a-1bc4-47d2-9e31-da6a92814873_4021x3978.jpeg 424w, https://substackcdn.com/image/fetch/$s_!MrsA!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8035a31a-1bc4-47d2-9e31-da6a92814873_4021x3978.jpeg 848w, https://substackcdn.com/image/fetch/$s_!MrsA!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8035a31a-1bc4-47d2-9e31-da6a92814873_4021x3978.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!MrsA!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8035a31a-1bc4-47d2-9e31-da6a92814873_4021x3978.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!MrsA!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8035a31a-1bc4-47d2-9e31-da6a92814873_4021x3978.jpeg" width="1456" height="1440" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8035a31a-1bc4-47d2-9e31-da6a92814873_4021x3978.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1440,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1106998,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/jpeg&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.architecture-weekly.com/i/198260672?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8035a31a-1bc4-47d2-9e31-da6a92814873_4021x3978.jpeg&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!MrsA!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8035a31a-1bc4-47d2-9e31-da6a92814873_4021x3978.jpeg 424w, https://substackcdn.com/image/fetch/$s_!MrsA!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8035a31a-1bc4-47d2-9e31-da6a92814873_4021x3978.jpeg 848w, https://substackcdn.com/image/fetch/$s_!MrsA!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8035a31a-1bc4-47d2-9e31-da6a92814873_4021x3978.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!MrsA!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8035a31a-1bc4-47d2-9e31-da6a92814873_4021x3978.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>We see actors representing our system users and interactions with external systems. We can decide what integration is out of scope for now, and may come as the second step, but not for now. We can also show the current state<strong> as is,</strong> then in the second diagram show our vision of what we want it <strong>to be</strong>. Be creative, this is not a relational database, we don&#8217;t need to normalise it.</p><p>Then we may decide to dive into what we need to build to facilitate that, make a bet based on our current knowledge, zoom in and check the C4 Container view.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!R0so!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8db1c289-b7ab-4d80-ba9e-d411b2314300_4669x3426.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!R0so!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8db1c289-b7ab-4d80-ba9e-d411b2314300_4669x3426.jpeg 424w, https://substackcdn.com/image/fetch/$s_!R0so!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8db1c289-b7ab-4d80-ba9e-d411b2314300_4669x3426.jpeg 848w, https://substackcdn.com/image/fetch/$s_!R0so!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8db1c289-b7ab-4d80-ba9e-d411b2314300_4669x3426.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!R0so!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8db1c289-b7ab-4d80-ba9e-d411b2314300_4669x3426.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!R0so!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8db1c289-b7ab-4d80-ba9e-d411b2314300_4669x3426.jpeg" width="1456" height="1068" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8db1c289-b7ab-4d80-ba9e-d411b2314300_4669x3426.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1068,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1191960,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/jpeg&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.architecture-weekly.com/i/198260672?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8db1c289-b7ab-4d80-ba9e-d411b2314300_4669x3426.jpeg&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!R0so!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8db1c289-b7ab-4d80-ba9e-d411b2314300_4669x3426.jpeg 424w, https://substackcdn.com/image/fetch/$s_!R0so!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8db1c289-b7ab-4d80-ba9e-d411b2314300_4669x3426.jpeg 848w, https://substackcdn.com/image/fetch/$s_!R0so!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8db1c289-b7ab-4d80-ba9e-d411b2314300_4669x3426.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!R0so!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8db1c289-b7ab-4d80-ba9e-d411b2314300_4669x3426.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>As mentioned, this can already be used both for static documentation and for discussions during a workshop, simulating the potential complexity and boundaries. Important to note: until it&#8217;s settled, it&#8217;s more than fine to show different versions, discuss them, and add notes and questions.</p><p>We may have concluded that C4 is fine, but it doesn&#8217;t capture all the important reasons we modelled it this way. Or during the workshop, it may not be clear how to discuss precisely how to break it down. <strong><a href="https://github.com/ddd-crew/context-mapping">Context maps can help in that</a></strong>. </p><p>They can give us information on which module is generic (Open Host), which module (or team) is more important (upstream), and which module or team has less leverage (Downstream). Which provides api or data (Supplier) and which one is using it (Consumer).</p><p>We could have a dedicated model only focusing, on that, but we could also capture it in the same one. It could look as follows:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Jonk!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F587648a3-773f-47eb-8e47-85aa81f7e3e4_4656x3435.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Jonk!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F587648a3-773f-47eb-8e47-85aa81f7e3e4_4656x3435.jpeg 424w, https://substackcdn.com/image/fetch/$s_!Jonk!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F587648a3-773f-47eb-8e47-85aa81f7e3e4_4656x3435.jpeg 848w, https://substackcdn.com/image/fetch/$s_!Jonk!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F587648a3-773f-47eb-8e47-85aa81f7e3e4_4656x3435.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!Jonk!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F587648a3-773f-47eb-8e47-85aa81f7e3e4_4656x3435.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Jonk!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F587648a3-773f-47eb-8e47-85aa81f7e3e4_4656x3435.jpeg" width="1456" height="1074" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/587648a3-773f-47eb-8e47-85aa81f7e3e4_4656x3435.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1074,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1377321,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/jpeg&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.architecture-weekly.com/i/198260672?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F587648a3-773f-47eb-8e47-85aa81f7e3e4_4656x3435.jpeg&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!Jonk!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F587648a3-773f-47eb-8e47-85aa81f7e3e4_4656x3435.jpeg 424w, https://substackcdn.com/image/fetch/$s_!Jonk!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F587648a3-773f-47eb-8e47-85aa81f7e3e4_4656x3435.jpeg 848w, https://substackcdn.com/image/fetch/$s_!Jonk!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F587648a3-773f-47eb-8e47-85aa81f7e3e4_4656x3435.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!Jonk!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F587648a3-773f-47eb-8e47-85aa81f7e3e4_4656x3435.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>We can even use colours, e.g., upstream as green and downstream as red. This can already give us feedback, e.g.,  that Cashiering is an important module for financial processing and that it is downstream of multiple modules. Maybe we can change it and redefine boundaries, or maybe live with it, but take some corrective actions.</p><p>We can also put more data and use a specific example of a process and have a look at the specific process, as shown in the original diagram:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!ckqS!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09040e7b-6085-4b14-aea5-facac44f3289_8788x5575.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!ckqS!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09040e7b-6085-4b14-aea5-facac44f3289_8788x5575.jpeg 424w, https://substackcdn.com/image/fetch/$s_!ckqS!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09040e7b-6085-4b14-aea5-facac44f3289_8788x5575.jpeg 848w, https://substackcdn.com/image/fetch/$s_!ckqS!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09040e7b-6085-4b14-aea5-facac44f3289_8788x5575.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!ckqS!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09040e7b-6085-4b14-aea5-facac44f3289_8788x5575.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!ckqS!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09040e7b-6085-4b14-aea5-facac44f3289_8788x5575.jpeg" width="1456" height="924" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/09040e7b-6085-4b14-aea5-facac44f3289_8788x5575.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:924,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:3664237,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/jpeg&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.architecture-weekly.com/i/198260672?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09040e7b-6085-4b14-aea5-facac44f3289_8788x5575.jpeg&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!ckqS!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09040e7b-6085-4b14-aea5-facac44f3289_8788x5575.jpeg 424w, https://substackcdn.com/image/fetch/$s_!ckqS!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09040e7b-6085-4b14-aea5-facac44f3289_8788x5575.jpeg 848w, https://substackcdn.com/image/fetch/$s_!ckqS!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09040e7b-6085-4b14-aea5-facac44f3289_8788x5575.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!ckqS!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09040e7b-6085-4b14-aea5-facac44f3289_8788x5575.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>This can also pinpoint some issues, e.g., a generic module, which is more likely to expose commands (see more on why in my article on <a href="https://event-driven.io/en/passive_aggressive_events/">Passive-Aggressive Events</a>). We can see if we have all the needed ACLs defined, or which module acts as a coordinator.</p><p>Of course, keeping it all on the same diagram is not perfect; it&#8217;d be great if we had a tool that would enable zoom-in/zoom-out, link and enable/disable some of the details and notation. Then we can even better play with what we have. Maybe I should vibe such? </p><p>Nevertheless, I encourage you to try different techniques, experiment, and combine them. For instance, Example Mapping would, imho, play great here as an extension. I know that some use Wardley Maps or Domain Storytelling. </p><p>Start small, use existing techniques and tools, and try to see how you could benefit from mixing them. Show it to your friends and try to collaborate and have fun together.</p><p><strong><a href="https://www.linkedin.com/feed/update/urn:li:activity:7461032293072195585?commentUrn=urn%3Ali%3Acomment%3A%28activity%3A7461032293072195585%2C7461050717479346176%29&amp;dashCommentUrn=urn%3Ali%3Afsd_comment%3A%287461050717479346176%2Curn%3Ali%3Aactivity%3A7461032293072195585%29">I even learned from Richard Gross that this has an even name: Model Storming</a>.</strong> So, crunching your design with different modelling techniques and coming up with some useful variations.</p><p>I have even more mashups like that. Tell me if you&#8217;d like me to expand on it or this part in the follow-up articles!</p><p>Also, if you did such experiments, please share them with me and others. Happy to see what you came up with.</p><p>Cheers!</p><p>Oskar</p><p>p.s. <strong>Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to <a href="https://www.icrc.org/en/donate/ukraine">Red Cross</a>, <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian organisation</a> or <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">donate Ambulances for Ukraine</a>.</p>]]></content:encoded></item><item><title><![CDATA[Don't overestimate domain expertise]]></title><description><![CDATA[On why domain experts can be wrong but LLMs can't replace them. And why we shouldn't drop collaboration]]></description><link>https://www.architecture-weekly.com/p/dont-overestimate-domain-expertise</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/dont-overestimate-domain-expertise</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 11 May 2026 10:43:52 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!uwHK!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a3805e1-79e0-4a97-8304-8970aef69774_1408x768.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!uwHK!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a3805e1-79e0-4a97-8304-8970aef69774_1408x768.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!uwHK!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a3805e1-79e0-4a97-8304-8970aef69774_1408x768.png 424w, https://substackcdn.com/image/fetch/$s_!uwHK!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a3805e1-79e0-4a97-8304-8970aef69774_1408x768.png 848w, https://substackcdn.com/image/fetch/$s_!uwHK!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a3805e1-79e0-4a97-8304-8970aef69774_1408x768.png 1272w, https://substackcdn.com/image/fetch/$s_!uwHK!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a3805e1-79e0-4a97-8304-8970aef69774_1408x768.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!uwHK!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a3805e1-79e0-4a97-8304-8970aef69774_1408x768.png" width="1408" height="768" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8a3805e1-79e0-4a97-8304-8970aef69774_1408x768.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:768,&quot;width&quot;:1408,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:2480532,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://www.architecture-weekly.com/i/197198877?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a3805e1-79e0-4a97-8304-8970aef69774_1408x768.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!uwHK!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a3805e1-79e0-4a97-8304-8970aef69774_1408x768.png 424w, https://substackcdn.com/image/fetch/$s_!uwHK!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a3805e1-79e0-4a97-8304-8970aef69774_1408x768.png 848w, https://substackcdn.com/image/fetch/$s_!uwHK!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a3805e1-79e0-4a97-8304-8970aef69774_1408x768.png 1272w, https://substackcdn.com/image/fetch/$s_!uwHK!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a3805e1-79e0-4a97-8304-8970aef69774_1408x768.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p><strong>I was toying with LLM-based domain research. It reminded me of the common mistake we make when we try to practice DDD: overreliance on what domain experts are telling us.</strong></p><p>I wanted to remind myself of the domain I used to know: hospitality management, to adjust my upcoming <a href="https://event-driven.io/en/training/#event-driven-architecture-the-light-and-the-dark-side">workshop</a>. I selected LLM (Claude Opus) as a sparing partner. And boy, I got a loooot of details. I was swamped by them. The LLM modelled everything: marketing consent, loyalty timing, inventory management, revenue posting, regulatory submissions, data retention policies, all at the same level of detail, which buried the core checkout flow under noise, the thing I asked about.</p><p>That&#8217;s not much different from initial work with domain experts. When we&#8217;re starting discovery, people tend to start by explaining everything they do and feel is important. Quite often, that means you&#8217;ll get more domain-expert pet peeves in the surroundings than in the process descriptions.</p><p>You also get a lot of jargon, words that sound familiar but mean something different.</p><p>That&#8217;s also what I got from LLM, I asked it to review reference sources like:</p><ul><li><p>popular tools documentation,</p></li><li><p>open specifications from the hospitality organisations,</p></li><li><p>some laws like GDPR related stuff,</p></li></ul><p>I researched how different tools handle the guest checkout process. I knew it, and implemented it in past; it&#8217;s a surprisingly complex process, as you need to:</p><ul><li><p>verify if the stay is fully paid and close the financial account,</p></li><li><p>generate invoice</p></li><li><p>mark guest&#8217;s stay as completed,</p></li><li><p>schedule full room cleanup,</p></li><li><p>mark in the inventory that the room will be available soon (unless the maid finds that it&#8217;s broken),</p></li><li><p>cleanup GDPR-related data that&#8217;s not needed to keep,</p></li><li><p>adjust loyalty points and update CRM,</p></li><li><p>etc.</p></li></ul><p>I remembered how it works, but not all the details, and I would like to double-check whether anything in the industry has changed.</p><p>I wanted to get the overall vision first, then gradually dive deeper. As mentioned, I was overwhelmed with naming like:</p><ul><li><p>Folio,</p></li><li><p>Drain Pending postings,</p></li><li><p>Property,</p></li><li><p>Account Receivables,</p></li></ul><p>etc.</p><p>Don&#8217;t get me wrong, those were valid names in these domains. Sounds plausible if you&#8217;ve never worked in hospitality. Sounds plausible if you have, too. So what&#8217;s wrong with it?</p><p>They&#8217;re valid terms, as people actually use them, but weird, as they don&#8217;t tell &#8220;how the process works&#8221; but &#8220;how people do stuff,&#8221; and that&#8217;s not the same thing.</p><p>For instance, Property is a hotel, kind of makes sense. But Folio?</p><p>This name comes from the Oracle Opera. Yes, Oracle has a system in this domain, a dominating one. At some point, authors decided to name things this way, and now 30 years later, that vocabulary is baked into how thousands of hoteliers talk about their work. &#8220;Drain pending postings&#8221; is a phrase real cashiers say.</p><p>&#8220;Settle the folio&#8221; is a thing real cashiers do.</p><p>The problem: none of it explains how our system should work. It only tells what current systems do.</p><p>Also, when I asked about the business rules and policies, I got stuff like:</p><blockquote><p>The system immediately posts room and tax charges for day-use reservations upon opening the Billing screen.</p></blockquote><p>Why are transactions such as night stay charges or taxes added when we open the billing screen? That sounds counterintuitive, but maybe it was a fair tradeoff 30 or 20 years ago for an on-premises system installed in the specific hotel (ekhm &#8220;property|). Yes, Opera had (and maybe still had) consultants, similar to SAP. They&#8217;d go to the hotel, go to the back office, log in to the server and hack stored procedures in the Oracle database to fine-tune Opera behaviour.</p><p>Also, is there really a &#8220;Draining Pending Postings&#8221; option before doing checkout nowadays? Maybe it is, but in modern systems, we shouldn&#8217;t manually check all bills, etc., and explicitly pull them from integrated payment solutions. Nowadays, all the accommodation charges should already be recorded on the bill. The financial module continuously collects charges from payment gateways throughout the stay. There&#8217;s no separate moment of &#8220;draining&#8221;. The LLM invented a coordination command to match a phrase (&#8220;drain the interfaces&#8221;) that real cashiers use as shorthand for &#8220;let me check that nothing&#8217;s outstanding.&#8221;</p><p>As I asked, LLMs researched systems in this space: OPERA, Mews, Apaleo, and Cloudbeds. Each has its own vocabulary and mechanics, and the training data heavily favours OPERA because its documentation is everywhere. When I pushed back on terminology, the model would just swap in different OPERA jargon instead of actually thinking about what&#8217;s modern. The blending happened invisibly, mixing vocabulary from one system with mechanics from another, all delivered with equal confidence.</p><p><strong>To be fair, the same happens quite often when talking to domain experts. They explain how it works now and what people do.</strong> Quite often, they bring us <a href="https://event-driven.io/en/bring_me_problems_not_solutions/">solutions instead of problems</a>. Usually, solutions are based on their experience and how they see the updated version. This can be fine as a brain dump, but it&#8217;s not enough to translate it directly into the software design.</p><p>In Domain-Driven Design, finding and understanding the ubiquitous language is considered the most important aspect. <strong>Yet, Ubiquitous Language is not the source of truth.</strong> It&#8217;s the way to keep our heads from exploding due to the constant split-brain situation. It&#8217;s a tool to reduce cognitive load and the need for additional translation. That&#8217;s why we separate domain contexts and bind them to specific departments, people, and the language they use.</p><p>It&#8217;s fine to start with the current state of the art. Understanding how people do their job. We need to understand, though, that what we get is a mixture of habits (both good and bad), tribal knowledge, jargon, etc. If you ask different tribes, each will tell you something different.</p><p>Domain language is a cognitive tool, not gospel. LLMs compound this problem by reiterating competitor vocabulary without understanding the reasoning behind those systems, and they tend to align with whatever the prompter already believes.</p><p>Domain experts also struggle to define what they want and how it should work. That&#8217;s also why they hire us. It&#8217;s our job to help them and to transfer those sometimes contradicting visions into working software. That&#8217;s what we&#8217;re learning and what we do when modelling and step-by-step shaping the working software. That&#8217;s our work as engineers. We should work together to have a proper outcome. Collaborate.</p><p>And I&#8217;m not making the bold statement here that we&#8217;re smarter or we know better. We&#8217;re not; we have different expertise and different roles in the software development process.</p><p><strong>Contrary to what many people believe in the DDD community, in my opinion, we&#8217;re not here to become domain experts; we&#8217;re here to build software.</strong></p><p>The model we built doesn&#8217;t have to reflect the whole universe; it&#8217;s a way to take part of the business, understand, and automate it in the form of a software system. A software system is a tool for the business make more money. So software needs to be useful in a certain, defined way. Not all possible ways.</p><p>We need to learn <a href="https://event-driven.io/en/a_few_words_on_communication/">how to communicate</a> with business people, for instance:</p><ul><li><p>Don&#8217;t use jargon or acronyms or assume someone should know something.</p></li><li><p>Understand that what someone wants to tell us might not be what they hear.</p></li><li><p>Do not take others&#8217; behaviour personally.</p></li><li><p>Be assertive, critical and sceptical. Also, to our own judgments.</p></li><li><p>Be curious about the business domain. Don&#8217;t assume too much.</p></li><li><p><a href="https://www.architecture-weekly.com/p/business-wont-let-me-and-other-lies">Don&#8217;t use &#8220;business won&#8217;t let me&#8221; as an easy excuse</a>.</p></li></ul><p>For some people, that&#8217;s too much. That&#8217;s probably why they try to ask LLM instead of Domain Experts, hoping that we won&#8217;t need to learn that, and we&#8217;ll get a solution for free.</p><p>We&#8217;re sometimes annoyed by being pushed hard by business people, I get that, but if we want to build something useful, that&#8217;s also where LLM will fail, as they will just agree eventually to what we believe. And that&#8217;ll probably be even worse than the skewed reality shown by the domain expert, since this will be Artificial Reality.</p><p><strong>I keep hearing blank statements that &#8220;LLMs are great for research&#8221;. Blank because they usually are not followed up with what the author means by &#8220;great&#8221;.</strong> LLMs have many inherent limitations here. We&#8217;re anthropomorphising them too much. They don&#8217;t reason; they&#8217;re statistical machines. We should constantly remind ourselves of that.</p><p>I think people who claim that &#8220;LLMs are great at research&#8221; are just conflating their own skills and projecting them onto LLMs.</p><p>Hence, in my opinion, that&#8217;s why we&#8217;re getting those hot takes. Might be that they just have the skills to drill down, organise research, evaluate it, and model system design.</p><p>And I think this narrative is dangerous.</p><p>Because I, too, could sit down and say that I iteratively arrived at a solution thanks to LLMs. Write this article in a much different narrative, praising LLMs. I could say that if someone couldn&#8217;t get the same result, then that&#8217;s a skill issue.</p><p>But that wouldn&#8217;t be true, because someone without my experience in the domain and in modelling probably wouldn&#8217;t pick up on it.</p><p>Furthermore, it&#8217;s not necessarily true that I did it well; that would just be my perspective.</p><p>In practice, in this context, &#8220;LLM does it well&#8221; means &#8220;LLM does it the way I wanted it to.&#8221; And if the outcome is right, it is more likely that the &#8220;operator&#8221; had the necessary skill and used an LLM as a tool to speed it up.</p><p>The research I did looked at what our competition does and how they name things, but without internal knowledge of why those systems got to where they are. If our goal is to provide additional value to users, then just doing a blind copy won&#8217;t take us far. Instead of doing Lift and Shift, building something that has the same features but does better, we&#8217;ll get Lift and Shift with silent f.</p><p>Yes, LLMS can gather and compile multiple sources into a summary. That&#8217;s actually impressive and can spare us a lot of time. They&#8217;re also taught in the public documents, so some of their knowledge is built in. They look impressive on the surface, but not so if you look deeper. In the mentioned research, I got disconnected pieces of information, the techniques were randomly assembled with muddled technical and business language, and the whole thing didn&#8217;t tell a coherent story. Because of the randomness, you never know where the knowledge came from, what was omitted and what was skewed. With domain experts, you can at least understand the origin of their biases.</p><p>LLM also has limitations. The biggest is the context size, and being a yes-man. If we ask for the big picture, usually, we&#8217;ll end up with a swamp of information instead. If we don&#8217;t know how to sort this knowledge, what we&#8217;re looking for, and do a proper drill-down, then we won&#8217;t be able to untangle it. If we don&#8217;t have domain experts and don&#8217;t know the domain, how will we challenge issues like the ones I gave above?</p><p>I also keep hearing that LLMs are really useful in modelling. Sure, I tried that. I also asked LLM to try to model, and well, even after numerous iterations and using modelling tools, the results were mediocre. Mostly cliches and bad modelling practices.</p><p>The output muddied what we had before. The context was lost, and the process was oversimplified. When asked to apply specific tools like the C4 model, EventStorming, and Context Maps, it was forcing DDD patterns instead of describing actual processes. Even with precise instructions, it was building models with broken notation where rules floated outside the command-event chains, leaning on clich&#233;d solutions, losing context between iterations. The domain was also presented as a big ball of mud. Concepts from room reservation bleed into the cashiering module. It was a recurring pattern across different versions of the same mistakes.</p><p>If you give too much context, they&#8217;ll mix and blend multiple conflicting vocabularies from different operational tribes trying to satisfy you. If you give it too little, it will come with simplistic hallucinations.</p><p>Is it hopeless then? Not at all, we can get help from LLM, but when we use it as a tool, not a replacement. We should not outsource thinking.</p><p>LLMs are useful for grasping the big picture and identifying known unknowns that we may miss as we get into the domain. We can learn about the language specifics, as mentioned in the Folio, Postings, Account Receivables, etc. LLMs can help us drill down into interesting aspects. We can get a brief understanding of domains we don&#8217;t know at all. But then we need to dive deeper and collaborate with our domain experts, real stakeholders. We need to understand what we want to build, organise our findings and focus on a certain context.</p><p>If we want to model our system, then LLMs can help us do boring work. It can organise our findings and create rapid text-based transitions with a defined format (including modelling practices). This can work. Yet it&#8217;s still our job to do domain discovery, refine the knowledge, evaluate and test it. We still need to focus on the <a href="https://event-driven.io/en/vibing_harness_and_ooda_loops/">feedback loop with real world</a>.</p><p>We won&#8217;t hide from gathering modelling skills, we won&#8217;t skip the process of translating the domain into technical design, and we&#8217;ll still need to drive how and when to drill down. Those are engineering skills that were needed and will still be needed. And that&#8217;s fine, as if they wouldn&#8217;t, why would someone want to hire us?</p><p>I see that (for unknown reasons) collaboration is not discussed anymore. Working collaboratively with fellow humans has become something people dread rather than embrace. Maybe some people hope that they won&#8217;t need to talk to &#8220;domain experts&#8221; and &#8220;fight them&#8221;, because they &#8220;have everything here in LLM&#8221;.</p><p>And yes, they have all.</p><p>All the same issues they would have with domain experts.</p><p>As much as we shouldn&#8217;t trust domain experts blindly, but work with them, we shouldn&#8217;t blindly trust LLMs. We shouldn&#8217;t drop our engineering and design skills and outsource them.</p><p>If we want our software products to be better than the competition, simply blending their experience isn&#8217;t enough. We should focus on finding our special sauce, and I don&#8217;t see any other way to make it right than to work together and collaborate. We can use LLMs to help us do it faster, but as tools, not as solutions or replacements.</p><p>Check also:</p><ul><li><p><a href="https://event-driven.io/pl/bring_me_problems_not_solutions/">Bring me problems, not solutions!</a>,</p></li><li><p><a href="https://event-driven.io/en/a_few_words_on_communication/">A few words on communication</a>,</p></li><li><p><a href="https://event-driven.io/en/how_to_design_software_architecture_pragmatically/">How to design software architecture pragmatically</a>,</p></li><li><p><a href="https://www.architecture-weekly.com/p/business-wont-let-me-and-other-lies">Business Won&#8217;t Let Me and other lies we tell to ourselves</a></p></li><li><p><a href="https://event-driven.io/en/vibing_harness_and_ooda_loops/">Vibing, Harness and OODA loop</a>,</p></li><li><p><a href="https://event-driven.io/en/interactive_rubber_ducking_with_gen_ai/">Interactive Rubber Ducking with GenAI</a>,</p></li><li><p><a href="https://event-driven.io/en/the_end_of_coding_wrong_question/">The End of Coding? Wrong Question</a>,</p></li><li><p><a href="https://www.architecture-weekly.com/p/requiem-for-a-10x-engineer-dream">Requiem for a 10x Engineer Dream</a>,</p></li><li><p><a href="https://www.architecture-weekly.com/p/tech-debt-doesnt-exist-but-trade">Tech Debt doesn&#8217;t exist, but trade-offs do</a></p></li></ul><p>Cheers!</p><p>Oskar</p><p>p.s. <strong>Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to <a href="https://www.icrc.org/en/donate/ukraine">Red Cross</a>, <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian organisation</a> or <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">donate Ambulances for Ukraine</a>.</p>]]></content:encoded></item><item><title><![CDATA[Borys had the best dribbling]]></title><description><![CDATA[A nostalgic essay on (not) setting and (not) achieving personal goals]]></description><link>https://www.architecture-weekly.com/p/borys-had-the-best-dribbling</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/borys-had-the-best-dribbling</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 04 May 2026 14:47:28 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!pXCo!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff4229c22-d0b6-49ee-bfc1-f7a39e41fcbf_805x452.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!pXCo!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff4229c22-d0b6-49ee-bfc1-f7a39e41fcbf_805x452.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!pXCo!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff4229c22-d0b6-49ee-bfc1-f7a39e41fcbf_805x452.png 424w, https://substackcdn.com/image/fetch/$s_!pXCo!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff4229c22-d0b6-49ee-bfc1-f7a39e41fcbf_805x452.png 848w, https://substackcdn.com/image/fetch/$s_!pXCo!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff4229c22-d0b6-49ee-bfc1-f7a39e41fcbf_805x452.png 1272w, https://substackcdn.com/image/fetch/$s_!pXCo!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff4229c22-d0b6-49ee-bfc1-f7a39e41fcbf_805x452.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!pXCo!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff4229c22-d0b6-49ee-bfc1-f7a39e41fcbf_805x452.png" width="805" height="452" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/f4229c22-d0b6-49ee-bfc1-f7a39e41fcbf_805x452.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:452,&quot;width&quot;:805,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:669816,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://www.architecture-weekly.com/i/196427798?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff4229c22-d0b6-49ee-bfc1-f7a39e41fcbf_805x452.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!pXCo!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff4229c22-d0b6-49ee-bfc1-f7a39e41fcbf_805x452.png 424w, https://substackcdn.com/image/fetch/$s_!pXCo!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff4229c22-d0b6-49ee-bfc1-f7a39e41fcbf_805x452.png 848w, https://substackcdn.com/image/fetch/$s_!pXCo!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff4229c22-d0b6-49ee-bfc1-f7a39e41fcbf_805x452.png 1272w, https://substackcdn.com/image/fetch/$s_!pXCo!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff4229c22-d0b6-49ee-bfc1-f7a39e41fcbf_805x452.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Borys had the best dribbling, Cybor bent it like Beckham, there were a few little kids who could pull off 200 keepie-uppies. My class 5B always gave 5A a hiding after a tough match. For a while now, we&#8217;d only been playing by the new rules, no back-passing to the keeper. I didn&#8217;t do hundreds of tricks, I&#8217;d cap my keepie-upiess at 20&#8211;30. It didn&#8217;t entertain me. I didn&#8217;t need excessive ornaments. I thought that, at the end of the day, fundamentals are what matter most. Kicking the ball where you want it to go. What&#8217;s the difference if it slams into the top corner, curves in like a Brazilian wonder-goal, or barely rolls over the line? What matters is that it ends up in the net. That&#8217;s what counts in the end. Art for art&#8217;s sake? Not for me.</p><p>In FIFA, I never picked Brazil, not even in &#8216;98 did I play as France. That would&#8217;ve been too easy. Better to go against the grain. What&#8217;s the challenge in playing a guitar that&#8217;s already in tune? Better to lose to better players than to win against worse ones. Better to be a benchwarmer at Manchester United than the captain at Mied&#378; Legnica.</p><p>I always read the instructions. You&#8217;ve got to prepare. You&#8217;ve got to assemble it the right way.</p><p>I don&#8217;t buy the first thing I see; I check price-comparison sites, read forums, and set up auction snipers on auction platforms.</p><p>Yesterday, after two years, I finally fixed the broken blinds.</p><ul><li><p>&#8220;What&#8217;s your biggest weakness?&#8221;</p></li><li><p>&#8220;I&#8217;m a perfectionist.&#8221;</p></li></ul><p>Sometimes I call myself the master of unfinished ideas. Like most of us, I dream of having my own idea, my own product, my own startup. I regularly throw myself at the next brilliant (in intent) idea, and I regularly fail to finish it. Every attempt ends the same way - &#8220;the same place, different girlfriend.&#8221;</p><p>Of course, every project of mine has to have solid foundations. Architecture first, then the framework. Everything has to be top-notch, the latest tech, best practices, and design patterns. I&#8217;ve finally got a free hand, after all, no nagging customer to worry about. Honestly, our industry is often a lot like Polish State Railways: everything would run brilliantly if it weren&#8217;t for the customers. We constantly laugh at customers: what do they know, idiots, don&#8217;t teach me how to do my job. Who cares about colours, a button shifted three pixels to the left, the wrong shade? What matters is that it&#8217;s SOLID. The most important thing is good architecture and beautifully functioning internals. Our projects are like icebergs, massive underneath, but somehow not much visible to show for it.</p><p>Last week, a book landed in my hands, well, an ebook: <a href="https://shop.stackingthebricks.com/just-fucking-ship">&#8220;Just Fucking Ship&#8221;</a> by Amy Hoy. It hit my mood of brooding over what I&#8217;m doing wrong perfectly. Why is Borys the schoolyard footballer everyone remembers, and not me? Why does my dad say I&#8217;m all gas, no follow-through, that I burn hot and burn out fast? Why are Xamarin and SignalR thriving, while my unfinished framework with similar ambitions sits at the back of my hard drive, untouched for years? You&#8217;d think I&#8217;m doing everything right. Reason. Solid foundations. Knowledge. Experience. Mr. Prim and Proper.</p><p>Maybe it&#8217;s exactly because I make the same mistakes everyone (well, almost everyone) makes:</p><ul><li><p>I focus on chasing the rabbit, not catching it. I start a project from the framework, a website from the layout, and before any of that, from picking the domain name. So I burn through my peak motivation on bullshit that&#8217;s least important from a product perspective.</p></li><li><p>I don&#8217;t take notes, I don&#8217;t write down plans, &#8220;why would I? I don&#8217;t need to, I&#8217;m exercising my memory.&#8221;</p></li><li><p>I don&#8217;t focus on the goal, I don&#8217;t set deadlines.</p></li><li><p>I don&#8217;t follow the &#8220;start small, grow big&#8221; principle.</p></li></ul><div class="twitter-embed" data-attrs="{&quot;url&quot;:&quot;https://twitter.com/abt_programming/status/561488797440176128&quot;,&quot;full_text&quot;:&quot;\&quot;How programmers spend time on a project. This one's funny because it's true\&quot;\nvia <span class=\&quot;tweet-fake-link\&quot;>@ErlangSolutions</span> &quot;,&quot;username&quot;:&quot;abt_programming&quot;,&quot;name&quot;:&quot;About Programming&quot;,&quot;profile_image_url&quot;:&quot;https://pbs.substack.com/profile_images/378800000232458460/b5d097e08c63a6530e9751bcb0a13a57_normal.png&quot;,&quot;date&quot;:&quot;2015-01-31T11:38:55.000Z&quot;,&quot;photos&quot;:[{&quot;img_url&quot;:&quot;https://pbs.substack.com/media/B8rPFhpIIAAwn7s.jpg&quot;,&quot;link_url&quot;:&quot;http://t.co/qYJpPsQLFS&quot;}],&quot;quoted_tweet&quot;:{},&quot;reply_count&quot;:4,&quot;retweet_count&quot;:73,&quot;like_count&quot;:49,&quot;impression_count&quot;:0,&quot;expanded_url&quot;:null,&quot;video_url&quot;:null,&quot;video_preview_media_key&quot;:null,&quot;belowTheFold&quot;:true}" data-component-name="Twitter2ToDOM"></div><p>Amy&#8217;s book isn&#8217;t groundbreaking; it doesn&#8217;t hand you a golden recipe &#8212; it doesn&#8217;t even aspire to. What it does give you is a breath of fresh air in your head, a few mental hatches popping open, and a handful of valuable, concrete, real-life advice. <strong>The most important: set yourself a goal, a deadline, and just fucking do it.</strong> First, the goal and the time, then the scope and the methods. Of course, we&#8217;ll all say, &#8220;yeah, but it doesn&#8217;t always work that way&#8221;. But look at it differently: when we want to invite friends over for dinner, do we plan, &#8220;well, maybe in two weeks instead, because I won&#8217;t have time to make a three-course meal, a cake, and homemade liqueur&#8221;? We don&#8217;t do that. We adjust the menu or buy something pre-made. Why can&#8217;t we apply the same approach to our projects?</p><p>Amy notices that we focus too much on the project as a whole. We don&#8217;t set ourselves small goals &#8212; the kind that, when achieved, would give us satisfaction and motivation to keep going. We get so fixated on the grand vision that we end up abandoning the idea without doing anything at all. We&#8217;re afraid it isn&#8217;t cool enough, it isn&#8217;t innovative enough. Why don&#8217;t we let other people judge that? We stay frozen at the starting line instead of just running and seeing how far we get. We forget that to get to the upper floor, you take the stairs one step at a time. Standing on the ground floor, wondering if we can make it up, gets us nowhere. We can even take the elevator, as long as we just fucking do it.</p><p>After this read, I&#8217;m not going to turn my life upside down, I&#8217;m not suddenly going to become a different person. I&#8217;m just going to try, this time, to finally fucking deliver on my plans.</p><p>A week ago, at almost 30 years old, I picked up my first football medal. There&#8217;s still hope.</p><div class="callout-block" data-callout="true"><p>My dear reader, as you got here, besides giving you KUDOS, let me confess to you something: I&#8217;m not 30 anymore, but 40. Which means that I wrote it over <a href="https://event-driven.io/pl/borys_najlepiej_dryblowal/">10 years ago</a>.<br><br>Why did I send it to you now?</p><p><br>Because selfishly, that&#8217;s, for some reason, my favourite piece I wrote ever. Which may mean that I have bad taste, but I hope that you enjoyed this nostalgic essay. And I like that vibe. I wrote it in Polish and decided to translate it today.</p><p><br>I recently got back to it, thinking about my new goals, also that I learned to swim in the meantime, and I signed up for a swimming competition last week.<br><br>Also, because I believe that the message and Amy&#8217;s book are still up to date, I wouldn&#8217;t change much to it. Especially with the GenAI era, we&#8217;re overfocused on doing rather than shipping stuff that matters.<br><br>Did I fucking ship what I planned? Not all; I&#8217;m still in the making. That was also why I got back to it and decided to do the translation.<br><br>If you liked it, tell me, I&#8217;ll appreciate that!</p></div><p>Cheers!<br><br>Oskar</p><p></p>]]></content:encoded></item><item><title><![CDATA[Vibing, Harness and OODA loop]]></title><description><![CDATA[On why Vibing and Harness are not new and why feedback loops are important]]></description><link>https://www.architecture-weekly.com/p/vibing-harness-and-ooda-loop</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/vibing-harness-and-ooda-loop</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 27 Apr 2026 05:27:27 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!8Ufw!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3c9101dc-f367-4ec3-99a5-98a1ebaab933_626x391.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!8Ufw!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3c9101dc-f367-4ec3-99a5-98a1ebaab933_626x391.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!8Ufw!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3c9101dc-f367-4ec3-99a5-98a1ebaab933_626x391.png 424w, https://substackcdn.com/image/fetch/$s_!8Ufw!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3c9101dc-f367-4ec3-99a5-98a1ebaab933_626x391.png 848w, https://substackcdn.com/image/fetch/$s_!8Ufw!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3c9101dc-f367-4ec3-99a5-98a1ebaab933_626x391.png 1272w, https://substackcdn.com/image/fetch/$s_!8Ufw!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3c9101dc-f367-4ec3-99a5-98a1ebaab933_626x391.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!8Ufw!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3c9101dc-f367-4ec3-99a5-98a1ebaab933_626x391.png" width="626" height="391" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/3c9101dc-f367-4ec3-99a5-98a1ebaab933_626x391.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:391,&quot;width&quot;:626,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;cover&quot;,&quot;title&quot;:&quot;cover&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="cover" title="cover" srcset="https://substackcdn.com/image/fetch/$s_!8Ufw!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3c9101dc-f367-4ec3-99a5-98a1ebaab933_626x391.png 424w, https://substackcdn.com/image/fetch/$s_!8Ufw!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3c9101dc-f367-4ec3-99a5-98a1ebaab933_626x391.png 848w, https://substackcdn.com/image/fetch/$s_!8Ufw!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3c9101dc-f367-4ec3-99a5-98a1ebaab933_626x391.png 1272w, https://substackcdn.com/image/fetch/$s_!8Ufw!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3c9101dc-f367-4ec3-99a5-98a1ebaab933_626x391.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><blockquote><p>Hey, have a look at what I made during the weekend. I had some time, grabbed a beer, turned on the computer and tried to code this feature. If I could do so much during the weekend, how much could you and your team do with it in 2 weeks?</p></blockquote><p>It&#8217;s almost a 1:1 quote of what I heard from the startup founder I worked with over 10 years ago. I&#8217;m sure that you&#8217;ve heard similar phrases from people you worked with. We all know the annoying type of person who doesn&#8217;t code anymore but thinks, <em>&#8220;I still got it!&#8221;</em>. Then they threw a piece of stuff at you to <em>&#8220;just fine-tune it a bit and do final touches&#8221;</em>. Then they&#8217;re the first ones to ask &#8220;Why so long?&#8220;.</p><p>Nowadays, the Internet is full of such people. They shout about what they did with Claude or how much progress LLM tools have made. Some even predict the end of coding. I already wrote that <a href="https://event-driven.io/en/the_end_of_coding_wrong_question/">this is wrong perspective</a>. I won&#8217;t repeat that, but I want to say that&#8230;</p><p><strong>Vibing isn&#8217;t new and isn&#8217;t always an issue.</strong></p><p>I&#8217;m saying that LLM tools are an appraisal for ignorance. The more ignorant we are of the topic we&#8217;re working with, the better we see the outcomes. And that, by itself, is not always bad, as there&#8217;s <a href="https://event-driven.io/en/power_of_ignorance/">power in ignorance</a> if we focus on getting it done with the simplest tools we have.</p><p>Still, this can be terrible if we fall in love too much with what we&#8217;ve vibed.</p><p>To understand why that &#8220;weekend beer&#8221; energy is both a superpower and a liability, we need to look at the OODA Loop.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!PCEe!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c8fad63-3571-4523-8385-8f1ec279f98e_800x436.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!PCEe!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c8fad63-3571-4523-8385-8f1ec279f98e_800x436.png 424w, https://substackcdn.com/image/fetch/$s_!PCEe!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c8fad63-3571-4523-8385-8f1ec279f98e_800x436.png 848w, https://substackcdn.com/image/fetch/$s_!PCEe!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c8fad63-3571-4523-8385-8f1ec279f98e_800x436.png 1272w, https://substackcdn.com/image/fetch/$s_!PCEe!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c8fad63-3571-4523-8385-8f1ec279f98e_800x436.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!PCEe!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c8fad63-3571-4523-8385-8f1ec279f98e_800x436.png" width="800" height="436" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/5c8fad63-3571-4523-8385-8f1ec279f98e_800x436.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:436,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;OODA loop&quot;,&quot;title&quot;:&quot;OODA loop&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="OODA loop" title="OODA loop" srcset="https://substackcdn.com/image/fetch/$s_!PCEe!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c8fad63-3571-4523-8385-8f1ec279f98e_800x436.png 424w, https://substackcdn.com/image/fetch/$s_!PCEe!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c8fad63-3571-4523-8385-8f1ec279f98e_800x436.png 848w, https://substackcdn.com/image/fetch/$s_!PCEe!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c8fad63-3571-4523-8385-8f1ec279f98e_800x436.png 1272w, https://substackcdn.com/image/fetch/$s_!PCEe!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c8fad63-3571-4523-8385-8f1ec279f98e_800x436.png 1456w" sizes="100vw"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Disclaimer, it&#8217;s not a competition for Ralph Wiggum Loop. It&#8217;s much older and generic.</p><p>Military strategist John Boyd developed the OODA loop (Observe, Orient, Decide, Act) for fighter pilots. In a dogfight, the pilot who cycles through these four stages the fastest and most accurately survives.</p><p>In software, the &#8220;dogfight&#8221; is the gap between your intent and the production-ready feature.</p><p><strong>OODA loop is built from four steps:</strong></p><ol><li><p><strong>Observe</strong> - This is the intake of raw, unfiltered information. In our world, this means looking at the state of the system.</p></li><li><p><strong>Orient</strong> - This is the most critical and difficult stage. It&#8217;s where you filter your observations through your experience, culture, and technical knowledge.</p></li><li><p><strong>Decide</strong> - Based on your orientation, you formulate a hypothesis.</p></li><li><p><strong>Act</strong> - You execute.</p></li></ol><p>Getting back to my favourite founder and LLM-based tools.</p><p><strong>The reason founder could build a PoC in a weekend while the team needed more than two weeks is that he bypassed the Observe and Orient phases. He went straight from a vague idea to Act.</strong></p><p>If we skip or brush past the observation step, it feels like lightning speed. If the fancy UI grid is there and it does something we wanted, we move on. We&#8217;ve outsourced Orientation to our own ego. It&#8217;s too easy to assume that because we wrote it, it works.</p><p><strong>Observation is the intake of raw data.</strong> In a professional environment, our eyes aren&#8217;t enough. We need a Harness. If we don&#8217;t have automations, tests, integration tests, and pristine traces, we aren&#8217;t observing the system; we&#8217;re just looking at it. If the inputs are messy, our observation is clouded.</p><p>But real engineering, the kind that takes those &#8220;two weeks&#8221;, is about closing the loop properly. That&#8217;s also where we need different perspectives and knowledge sharing.</p><p><strong>Orientation is where you process those observations.</strong> This is the part where LLMs make us feel smarter than we are. If we don&#8217;t understand how a database handles concurrent connections, our &#8220;orientation&#8221; of a generated script will be shallow. We&#8217;ll see code that &#8220;looks&#8221; right, decide it&#8217;s fine, and act by deploying it.</p><p>The &#8220;I still got it&#8221; crowd loves the Decide and Act phases because that&#8217;s where the visible progress happens. LLM tools have made these phases nearly instantaneous. We can decide to build a feature and have the code for it in ten seconds.</p><p>The problem is that the faster we Act, the faster we need to Observe. If our &#8220;Act&#8221; phase takes seconds but our &#8220;Observe&#8221; phase requires a manual weekend of clicking around and drinking beer, our OODA loop is broken. We&#8217;re just generating a pile of stuff that we haven&#8217;t actually verified.</p><p><strong>That&#8217;s why the team usually needs more than an imaginary &#8220;two weeks&#8221;.</strong> They are not &#8220;fine-tuning&#8221; the single-brilliant-dude masterpiece. They are building the infrastructure required to make the OODA loop sustainable.</p><p>And to make that possible, they need to run the full loop: Observe, Orient, Decide, Act. And do it multiple times. That takes time, but it&#8217;s required to assess the direction, automate what needs to be automated, and ensure they can iterate further and run this loop sustainably. That&#8217;s critical for delivering the outcome at the expected pace.</p><p>Of course, there&#8217;s a danger here, overfocusing on the Orient and Decide can lead to overengineering, building stuff we don&#8217;t need. That&#8217;s where ignorance can be blissful, especially when we connect it with humility. Being humble about what we don&#8217;t know and trying things the easiest way, then learning and making enhancements. Still, humility fails under deadline pressure. The harness doesn&#8217;t.</p><p>Let me give you&#8230;</p><h2>The example</h2><p>I&#8217;m adding proper Observability and Open Telemetry to <a href="https://github.com/event-driven-io/emmett">Emmett</a> right now. I spent some time working on it and instrumented the first component: <a href="https://event-driven-io.github.io/emmett/getting-started.html#command-handling">Command Handling</a>.</p><p>Of course, I had tests to prove it works, but I don&#8217;t trust them enough, and I wanted to try it on a real sample, since you never know until you run it. Even the best test suite won&#8217;t tell you all.</p><p>So I decided to plug it into the <a href="https://github.com/event-driven-io/emmett/tree/main/samples/webApi/expressjs-with-postgresql">sample</a>. See if it works, how ergonomic the API is and how it fits conventions in this area.</p><p>To do it, I decided to use <a href="https://grafana.com/">Grafana stack</a> and set it up with Docker Compose. So, stable, boring stack. Not going to lie, I vibed the config. Not that there are no docs, but I intentionally wanted to see the typical config people use.</p><p>If someone says LLM-based tools are great at proof of concepts, they don&#8217;t run the stuff they vibed. If I made the observation based on the initial config, then an oriented decision would be that it won&#8217;t work. Of course, then I did the typical back-and-forth, with the LLM tool doing some Linux command Voodoo to make it work. Once. Then, if you try to repeat it, you won&#8217;t know how to do it without doing Voodoo again.</p><p>Again, that&#8217;s not much different from the other stuff we do. I&#8217;m sure that you had multiple cases, when someone didn&#8217;t use Continuous Deployment tools, but clicked through Azure, AWS, GCP portal, deployed the stack, and then there was no trace on how to set it up again (e.g. to have a different environment for testing or demos for customers).</p><p><strong>So, we need a harness, we need a leash to keep our process on track.</strong></p><p>How to do the harness? My advice is to start simple. We may ask LLMs to give us shell scripts, and we may ask them to run them multiple times. We also need experience and knowledge of what we want to achieve and the tools we use. It&#8217;s fine not to remember all the YAML config to set up the Grafana stack, but it&#8217;s not fine not to understand why you even use it, how it relates, and how to set it up.</p><p>Still, our first loop can close on the first working solution, even a manually vibed one. But that&#8217;s not even a PoC. We need to automate them.</p><p>I asked LLM to take notes on what issues it had, and it solved them. Then, based on that, I asked to research how to code it in TypeScript. And to use tools I know, used in past, validating if there are no new more modern ones. For instance, I was a big fan of <a href="https://gulpjs.com/">Gulp.js</a> and <a href="https://github.com/adamralph/bullseye">Bullseye</a> in the past, but they&#8217;re mostly dead. I wanted to have something in the same spirit, using native, maintained tooling.</p><p>I ended up with the following tools:</p><ul><li><p><a href="https://github.com/sindresorhus/execa">execa</a> for running shell scripts,</p></li><li><p><a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API">native fetch</a> for calling http endpoints,</p></li><li><p><a href="https://nodejs.org/api/test.html">native Node.js test tools</a> for checking if the stack works as expected.</p></li></ul><p>Then I asked it to create the script to automate the shell Voodoo they did to make Grafana stack and Docker Compose work.</p><p><strong>Essentially, it should:</strong></p><ol><li><p>Run Docker Compose script starting up services (Grafana, Prometheus, Loki, Tempo, PostgreSQL, etc.).</p></li><li><p>Wait for them to check when they&#8217;re ready (it usually takes some time).</p></li><li><p>Start the application and make a request.</p></li><li><p>Check if the predefined dashboard with Emmett metrics appears, and shows expected traces and metrics.</p></li></ol><p>Initial diagnostic tools looked like that</p><pre><code><code>async function fetchWithDiag(label: string, url: string, init?: RequestInit) {
  const res = await fetch(url, init);
  if (!res.ok) {
    const body = await res.text().catch(() =&gt; '(could not read body)');
    console.error(`\n  &#10007; ${label} &#8594; HTTP ${res.status}\n  body: ${body}\n`);
  }
  return res;
}

async function diagnoseCollector() {
  const text = await fetch(URLS.otelCollectorMetrics)
    .then((r) =&gt; r.text())
    .catch(() =&gt; 'unreachable');
  const emmett = text
    .split('\n')
    .filter((l) =&gt; l.startsWith('emmett_') &amp;&amp; !l.startsWith('#'))
    .slice(0, 5);
  console.log(
    emmett.length
      ? `\n  collector /metrics (emmett lines):\n  ${emmett.join('\n  ')}`
      : '\n  collector /metrics: no emmett_* lines found',
  );
}

async function diagnosePrometheus() {
  const json = await fetch(
    `${URLS.prometheus}/api/v1/label/__name__/values`,
  )
    .then((r) =&gt; r.json() as Promise&lt;{ data: string[] }&gt;)
    .catch(() =&gt; ({ data: [] as string[] }));
  const emmett = json.data.filter((n) =&gt; n.startsWith('emmett_'));
  console.log(
    emmett.length
      ? `\n  Prometheus emmett_* metrics: ${emmett.join(', ')}`
      : '\n  Prometheus: no emmett_* metrics found yet',
  );
}

async function diagnoseLoki() {
  const labels = await fetch(`${URLS.loki}/loki/api/v1/labels`)
    .then((r) =&gt; r.json() as Promise&lt;{ data?: string[] }&gt;)
    .catch(() =&gt; ({ data: [] as string[] }));
  console.log(`\n  Loki labels: ${(labels.data ?? []).join(', ') || '(none)'}`);
}

async function diagnoseDockerLogs(service: string, lines = 10) {
  const { stdout } = await execa('docker', [
    ...COMPOSE,
    'logs',
    '--tail',
    String(lines),
    service,
  ]).catch(() =&gt; ({ stdout: '(could not get logs)' }));
  console.log(`\n  docker logs ${service} (last ${lines}):\n  ${stdout.split('\n').join('\n  ')}`);
}</code></code></pre><p>Are they pretty? No. Can they be improved? Yes. Do they have to be improved at this specific moment? No.</p><p>The setup uses test infrastructure</p><pre><code><code>
const CLEANUP = process.env['CLEANUP'] === '1' || process.env['CLEANUP'] === 'true';
const CLEANUP_AFTER = process.env['CLEANUP_AFTER'] === '1' || process.env['CLEANUP_AFTER'] === 'true';
const NO_START = process.env['NO_START'] === '1' || process.env['NO_START'] === 'true';

// &#9472;&#9472;&#9472; configuration &#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;

const COMPOSE = ['compose', '-f', 'docker-compose.yml', '--profile', 'observability'];

const URLS = {
  app: 'http://localhost:3000',
  prometheus: 'http://localhost:9090',
  tempo: 'http://localhost:3200',
  loki: 'http://localhost:3100',
  grafana: 'http://localhost:3001',
  otelCollectorMetrics: 'http://localhost:8889/metrics',
};

// Fresh client per run &#8212; avoids stale cart state from previous runs.
const SERVICE_NAME = 'expressjs-with-postgresql';
const CLIENT_ID = randomUUID();
const CART_ENDPOINT = `${URLS.app}/clients/${CLIENT_ID}/shopping-carts/current/product-items`;
const CONFIRM_ENDPOINT = `${URLS.app}/clients/${CLIENT_ID}/shopping-carts/current/confirm`;

// Matches the .http file &#8212; unitPrice is resolved server-side.
const ADD_PRODUCT_BODY = JSON.stringify({ productId: randomUUID(), quantity: 10 });


before(async () =&gt; {
  console.log(`\n&#9654; client ID for this run: ${CLIENT_ID}\n`);

  if (NO_START) {
    console.log('&#9654; --no-start: skipping docker compose and app startup');
    return;
  }

  if (CLEANUP) {
    console.log('&#9654; --cleanup: killing port 3000 and tearing down stack (down -v)&#8230;');
    await execa('bash', ['-c', 'fuser -k 3000/tcp 2&gt;/dev/null || true']).catch(() =&gt; {});
    await new Promise((r) =&gt; setTimeout(r, 500));
    await execa('docker', [...COMPOSE, 'down', '-v', '--remove-orphans'], {
      stdio: 'inherit',
    });
  }

  const stackReady = await fetch(`${URLS.prometheus}/-/ready`)
    .then((r) =&gt; r.ok)
    .catch(() =&gt; false);

  if (stackReady) {
    console.log('&#9654; observability stack already up &#8212; skipping docker compose up');
  } else {
    console.log('&#9654; starting observability stack&#8230;');
    await execa('docker', [...COMPOSE, 'up', '-d'], { stdio: 'inherit' });
  }

  console.log('&#9654; waiting for backends&#8230;');
  await waitFor(() =&gt; checkUrl('Prometheus', `${URLS.prometheus}/-/ready`), {
    timeout: 90_000, label: 'Prometheus',
  });
  await waitFor(() =&gt; checkUrl('Grafana', `${URLS.grafana}/api/health`), {
    timeout: 90_000, label: 'Grafana',
  });
  await waitFor(() =&gt; checkUrl('Tempo', `${URLS.tempo}/ready`), {
    timeout: 90_000, label: 'Tempo',
  });
  await waitFor(() =&gt; checkUrl('Loki', `${URLS.loki}/ready`), {
    timeout: 90_000, label: 'Loki',
  });

  // /health returns { status: 'ok', service: 'expressjs-with-postgresql' } &#8212;
  // checking service name lets us distinguish our app from other processes on :3000.
  const checkOurApp = () =&gt;
    checkUrl('app /health', `${URLS.app}/health`, async (res) =&gt; {
      const json = (await res.json().catch(() =&gt; ({}))) as { service?: string };
      if (json.service !== SERVICE_NAME) {
        console.log(
          `    app /health: service="${json.service ?? '(missing)'}", expected="${SERVICE_NAME}"`,
        );
        return false;
      }
      return true;
    });

  const appIsOurs = stackReady &amp;&amp; (await checkOurApp());

  if (appIsOurs) {
    console.log('&#9654; app already running and healthy &#8212; skipping npm start');
  } else {
    const portTaken = await fetch(URLS.app).then(() =&gt; true).catch(() =&gt; false);
    if (portTaken) {
      // Port is occupied but not by our app &#8212; stale process or unrelated service.
      console.error(
        '\n  &#10007; Port 3000 is occupied by a process that is not this app.\n' +
          '  It may be a stale version of this app (connected to a wiped database)\n' +
          '  or a completely different service.\n' +
          '  Fix: run  npm run verify:observability:cleanup  to kill it and restart,\n' +
          '  or manually free port 3000.\n',
      );
      process.exit(1);
    }

    console.log('&#9654; starting app&#8230;');
    app = execa('npm', ['start'], { stdio: 'inherit' });

    await waitFor(checkOurApp, { timeout: 60_000, label: 'app /health' });
  }

  console.log('&#9654; setup complete\n');
});</code></code></pre><p>As you see, nothing fancy, the cleanup is even simpler</p><pre><code><code>after(async () =&gt; {
  if (app) {
    console.log('\n&#9654; stopping app&#8230;');
    app.kill('SIGTERM');
    await app.catch(() =&gt; {});
    console.log('&#9654; app stopped');
  }

  if (CLEANUP_AFTER) {
    console.log('&#9654; tearing down stack (down -v)&#8230;');
    await execa('docker', [...COMPOSE, 'down', '-v', '--remove-orphans'], {
      stdio: 'inherit',
    });
    console.log('&#9654; stack torn down');
  } else {
    console.log('&#9654; stack is still running');
    console.log('&#9654; to clean up: npm run verify:observability:cleanup');
  }
});</code></code></pre><p>Having that we can run tests:</p><pre><code><code>
test('successful command returns x-trace-id header', async () =&gt; {
  const res = await fetchWithDiag('POST add product', CART_ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: ADD_PRODUCT_BODY,
  });

  assert.equal(res.status, 204, `Expected 204 &#8212; body logged above`);

  const header = res.headers.get('x-trace-id');
  if (!header) {
    console.error(
      '  &#10007; x-trace-id missing &#8212; verify the wrapper app in src/index.ts ' +
        'adds it via @opentelemetry/api before mounting the emmett app',
    );
  }
  assert.ok(header, 'x-trace-id header missing');
  assert.match(header, /^[0-9a-f]{32}$/, `"${header}" is not a 32-hex trace ID`);

  traceId = header;
  console.log(`  trace ID: ${traceId}`);
});

test('OTel collector exposes Emmett metrics on port 8889', async () =&gt; {
  // Send a few more requests so metrics are definitely recorded.
  for (let i = 0; i &lt; 5; i++) {
    await fetch(CART_ENDPOINT, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: ADD_PRODUCT_BODY,
    });
  }

  try {
    await waitFor(
      async () =&gt; {
        let text: string;
        try {
          const res = await fetch(URLS.otelCollectorMetrics);
          text = await res.text();
        } catch {
          console.log('    collector :8889: connection refused');
          return false;
        }
        const emmettLines = text.split('\n').filter((l) =&gt; l.startsWith('emmett_') &amp;&amp; !l.startsWith('#'));
        if (emmettLines.length === 0) {
          const allFamilies = [...new Set(text.split('\n').filter((l) =&gt; !l.startsWith('#') &amp;&amp; l).map((l) =&gt; l.split('{')[0]))].slice(0, 5);
          console.log(`    collector :8889: no emmett_* metrics yet. Present: ${allFamilies.join(', ') || '(none)'}`);
          return false;
        }
        return true;
      },
      { timeout: 90_000, interval: 5_000, label: 'emmett metrics on collector :8889' },
    );
  } catch (err) {
    await diagnoseCollector();
    await diagnoseDockerLogs('otel-collector');
    throw err;
  }
});</code></code></pre><p>I put it into a <a href="https://github.com/event-driven-io/emmett/blob/a937ff98ba39d3e504540886d8cd918843b28149/samples/webApi/expressjs-with-postgresql/src/observability.spec.ts">single file</a> that can be run as a regular Node.js script.</p><p>It already showed me (and Claude) that what they initially did wasn&#8217;t working if you try to run it multiple times. It also showed that doing a full cleanup and rebuild, and making it reproducible, needs more work.</p><p>Is it done? Not yet; it takes too much time and resources to run it continuously throughout the pipeline. The code is a bit messy, so it needs to be organised. It&#8217;s segmented into blocks, includes basic automation and tests, and has already gone through some failures to get it done.</p><p>Could I do it better? Sure, and I will improve it, but that&#8217;s not the point. I wanted to show you my findings during weekend vibing (without beer tho), the real, not polished iteration, before I run the next one.</p><p><strong>The main idea behind OODA loops is not to be perfect, but to iterate quickly, gather feedback as soon as possible, learn from it, develop another theory, and verify it through action.</strong></p><p>It&#8217;s not about vibing, but it&#8217;s also not about analysis paralysis.</p><p>I hope you&#8217;re now better equipped to think about when vibing, with beer or without, with LLMs or without, actually helps, and when it doesn&#8217;t.</p><p>Vibe coding is just high-frequency steering. It only works if you have a Harness: a mechanical way to observe and orient, so you don&#8217;t steer the whole project into a wall.</p><p>Act takes seconds now. Observe takes as long as it always did. Without a harness, you&#8217;re not going faster; you&#8217;re just making more stuff you haven&#8217;t checked.</p><p>Harness is not magic, a new discipline, or the next buzzword; I hope I showed you that a bit in this article on what it may look like.</p><p><strong>So iterate fast, but wisely remembering to do the full loop.</strong> It&#8217;s great that LLMs can help us make Acting faster, but we should not skip other steps. We should aim for a fast feedback loop to iterate in the right direction and achieve continuous improvement, to deliver proper value.</p><p>Just like Vibing isn&#8217;t new, we shouldn&#8217;t abandon <em>&#8220;old&#8221;</em> engineering practices. We should also not replace collaboration with solitary self-high fives.</p><p>Check also:</p><ul><li><p><a href="https://github.com/event-driven-io/emmett/pull/335">Emmett Pull Request with mentioned changes</a></p></li><li><p><a href="https://event-driven.io/en/interactive_rubber_ducking_with_gen_ai/">Interactive Rubber Ducking with GenAI</a></p></li><li><p><a href="https://event-driven.io/en/the_end_of_coding_wrong_question/">The End of Coding? Wrong Question</a></p></li><li><p><a href="https://event-driven.io/en/tricks_on_how_to_set_up_related_docker_images/">A few tricks on how to set up related Docker images with docker-compose</a></p></li><li><p><a href="https://event-driven.io/en/docker_compose_profiles/">Docker Compose Profiles, one the most useful and underrated features</a></p></li></ul><p>Cheers!</p><p>Oskar</p><p>p.s. <strong>Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to <a href="https://www.icrc.org/en/donate/ukraine">Red Cross</a>, <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian organisation</a> or <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">donate Ambulances for Ukraine</a>.</p>]]></content:encoded></item><item><title><![CDATA[Yoda Principle for better integrations]]></title><description><![CDATA[On why doing is better than trying for api and workflows design]]></description><link>https://www.architecture-weekly.com/p/yoda-principle-for-better-integrations</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/yoda-principle-for-better-integrations</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 20 Apr 2026 13:17:33 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!H0d4!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F946c3f85-a03a-40e9-90ad-95a44231c9fa_626x391.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!H0d4!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F946c3f85-a03a-40e9-90ad-95a44231c9fa_626x391.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!H0d4!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F946c3f85-a03a-40e9-90ad-95a44231c9fa_626x391.png 424w, https://substackcdn.com/image/fetch/$s_!H0d4!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F946c3f85-a03a-40e9-90ad-95a44231c9fa_626x391.png 848w, https://substackcdn.com/image/fetch/$s_!H0d4!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F946c3f85-a03a-40e9-90ad-95a44231c9fa_626x391.png 1272w, https://substackcdn.com/image/fetch/$s_!H0d4!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F946c3f85-a03a-40e9-90ad-95a44231c9fa_626x391.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!H0d4!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F946c3f85-a03a-40e9-90ad-95a44231c9fa_626x391.png" width="626" height="391" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/946c3f85-a03a-40e9-90ad-95a44231c9fa_626x391.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:391,&quot;width&quot;:626,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;cover&quot;,&quot;title&quot;:&quot;cover&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="cover" title="cover" srcset="https://substackcdn.com/image/fetch/$s_!H0d4!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F946c3f85-a03a-40e9-90ad-95a44231c9fa_626x391.png 424w, https://substackcdn.com/image/fetch/$s_!H0d4!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F946c3f85-a03a-40e9-90ad-95a44231c9fa_626x391.png 848w, https://substackcdn.com/image/fetch/$s_!H0d4!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F946c3f85-a03a-40e9-90ad-95a44231c9fa_626x391.png 1272w, https://substackcdn.com/image/fetch/$s_!H0d4!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F946c3f85-a03a-40e9-90ad-95a44231c9fa_626x391.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><blockquote><p>Try not. Do. Or do not. There is no try!</p></blockquote><p>I&#8217;m calling this the Yoda Principle.</p><p><a href="https://www.youtube.com/watch?v=BQ4yd2W50No">Master Yoda said that to Luke Skywalker a long time ago in a galaxy far, far away</a>. He was teaching Luke how to name commands properly while trying to untangle some legacy enterprise mess.</p><p>I&#8217;m sure you&#8217;ve also seen a death star of weirdly-named stuff. Some of them have already tripped you hours of thinking whether someone named this thing badly, or there&#8217;s some hidden truth behind it.</p><p><strong>Let&#8217;s discuss that by the example: E-Commerce order fulfilment.</strong></p><p>The order is placed automatically once the customer confirms the items in the shopping cart. We&#8217;re not making any product reservations before the shopping cart is confirmed, as this would lock it for other customers, and, as you know, they tend to drop items from their carts.</p><p>Once we receive the event notification that the cart has been confirmed, we&#8217;ll start the order fulfilment process. It starts (as mentioned) with the order initiation, which acknowledges and initiates the multi-step fulfilment process.</p><p>The first step is checking product availability before confirming the order. We need to determine whether we can proceed with completing the shipment and initiating product payment. If the product is unavailable, we need to either ask the customer to wait until we have it again or cancel the order.</p><p>We have dedicated modules for order fulfilment and for inventory. Fulfilment is the orchestrator, and inventory is responsible for tracking the state in warehouses.</p><p>The ordering module would need to call the inventory module to verify product availability. We could send a command (through the messaging system or web api). How would we name it?</p><p>What about <em><strong>VerifyProductExists</strong></em>? We&#8217;d send the product id and quantity from the order information, and return true if we have enough products, false otherwise. Sounds fair, right?</p><p>Well, it may seem nice at first glance, but what happens if more than one order verifies the same product availability, and we&#8217;re running short?</p><p>Then we&#8217;re vulnerable to race conditions I described in <a href="https://event-driven.io/en/tell_dont_ask_how_to_keep_an_eye_on_boiling_milk/">Tell, don&#8217;t ask! Or, how to keep an eye on boiling milk</a>. The information we get is only valid at the time of querying. If we don&#8217;t lock the product quantity, it can change before we get a response (think: Black Friday-like demand).</p><p><strong>Naming our command like </strong><em><strong>VerifyProductExists</strong></em><strong> is a mistake</strong>.</p><p><em>VerifyProductExists</em> is not even a command; it&#8217;s a query. Command is a request (intention) to run business logic. Query is a request to return data.</p><p>Of course, pragmatically, our <a href="https://event-driven.io/en/can_command_return_a_value/">command can return status information about the result of our operation</a>. But the intention is different.</p><p><strong>What&#8217;s our real intention here?</strong></p><p>The real intention is that we&#8217;d like to reserve products so we can ship them and get payment for them, not to verify if they exist. It&#8217;d be better to name our command as ReserveProducts or LockProducts.</p><p>Why does it matter?</p><p>If we&#8217;re naming our commands with Verify/Validate/Check prefixes, we&#8217;re putting ourselves into the wrong mindset. We&#8217;re not focused on actions and integrations, but just brief checking. If we&#8217;re in such a mode, it&#8217;s easy to handwave the integration complexity.</p><p>Locking for shipment may require sending someone to double-check that the product is in the warehouse and hasn&#8217;t been stolen, damaged, or other steps. It may be an async process on its own. Still from the ordering module, we should not care, as we&#8217;re telling what our intention is and expect to get events informing us whether the reservation succeeded, failed, or timed out (we don&#8217;t want to lock products forever in case of order fulfilment issues, but only for some time).</p><p><strong>Prefixes like Verify/Validate/Check, etc., are just synonyms for trying.</strong> And well, commands are always a form of trying. The handling module can reject the command, as its business rules and state are the source of truth.</p><p>We should always assume that the command processing can fail. We should not be discouraged by that, and we should double-check everything. We should not be intimidated by the potential failure, but prepared for it.</p><p><strong>We should try not. Do or do not. There is no try.</strong></p><p>What if we have both? So <em>VerifyProductExists</em> and <em>LockProducts</em>? It can work if the first one is a query used by the Shopping Cart module, without any guarantee that the data isn&#8217;t stale, on a best-effort basis.</p><p>If we&#8217;re always requiring VerifyProductExists from the handling module before LockProducts, we&#8217;re making our communication chatty. I described that in <a href="https://event-driven.io/en/what_does_mr_bean_opening_the_car_have_to_do_with_programming/">What does Mr Bean opening the car have to do with programming?</a> that this is not only a bad developer experience, but also just redundancy. Locking should already verify whether the product exists, so why require someone to memorise those scenarios instead of checking it internally?</p><p>The same goes for cases like:</p><ul><li><p>verify payment has been made,</p></li><li><p>check if the order wasn&#8217;t already fulfilled,</p></li><li><p>validate if the shipment has been completed,</p></li><li><p>etc.</p></li></ul><p>All of them either hide a missing business concept or should be a business rule verified within the specific action (e.g., confirming an order).</p><p>I recognise this may seem nitpicky, but big things are built on small details.</p><p>If we don&#8217;t think about such things, we&#8217;ll not only end up with misnamed integrations but also fight <a href="https://event-driven.io/en/dealing_with_race_conditions_in_eda_using_read_models/">race conditions</a> and incorrect boundaries.</p><p>Then <a href="https://www.youtube.com/watch?v=cTwZZz0HV8I">we&#8217;ll be doomed</a>.</p><p>So better think twice and do or do not.</p><p>May the force be with you!</p><p>Oskar</p><p>p.s. <strong>Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to <a href="https://www.icrc.org/en/donate/ukraine">Red Cross</a>, <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian organisation</a> or <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">donate Ambulances for Ukraine</a>.</p>]]></content:encoded></item><item><title><![CDATA[Anti-patterns in event modelling - Passive-Aggressive Events]]></title><description><![CDATA[On why event-driven communication should not be only about events.]]></description><link>https://www.architecture-weekly.com/p/passive-aggresive-event</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/passive-aggresive-event</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 13 Apr 2026 11:00:45 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!UjMX!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19ccb285-3907-4ebb-86fe-663cb917f31f_1408x768.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!UjMX!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19ccb285-3907-4ebb-86fe-663cb917f31f_1408x768.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!UjMX!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19ccb285-3907-4ebb-86fe-663cb917f31f_1408x768.png 424w, https://substackcdn.com/image/fetch/$s_!UjMX!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19ccb285-3907-4ebb-86fe-663cb917f31f_1408x768.png 848w, https://substackcdn.com/image/fetch/$s_!UjMX!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19ccb285-3907-4ebb-86fe-663cb917f31f_1408x768.png 1272w, https://substackcdn.com/image/fetch/$s_!UjMX!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19ccb285-3907-4ebb-86fe-663cb917f31f_1408x768.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!UjMX!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19ccb285-3907-4ebb-86fe-663cb917f31f_1408x768.png" width="1408" height="768" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/19ccb285-3907-4ebb-86fe-663cb917f31f_1408x768.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:768,&quot;width&quot;:1408,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1853124,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://www.architecture-weekly.com/i/194057953?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19ccb285-3907-4ebb-86fe-663cb917f31f_1408x768.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!UjMX!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19ccb285-3907-4ebb-86fe-663cb917f31f_1408x768.png 424w, https://substackcdn.com/image/fetch/$s_!UjMX!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19ccb285-3907-4ebb-86fe-663cb917f31f_1408x768.png 848w, https://substackcdn.com/image/fetch/$s_!UjMX!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19ccb285-3907-4ebb-86fe-663cb917f31f_1408x768.png 1272w, https://substackcdn.com/image/fetch/$s_!UjMX!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19ccb285-3907-4ebb-86fe-663cb917f31f_1408x768.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Have you ever heard phrases like.</p><blockquote><p>Just an update, the milk ran out. Someone finished it and put the empty carton back.</p></blockquote><p>Or</p><blockquote><p>So everyone is aware, the meeting started 15 minutes ago.</p></blockquote><p>Or</p><blockquote><p>Heads up: the coffee machine is empty again.</p></blockquote><p>I&#8217;m sure you either heard or used such phrases.</p><p>We all know that there&#8217;s some hidden intention behind it.</p><p>The intention is not to inform, but to trigger a certain action.</p><p>Formally, we&#8217;re reporting on events to announce the facts, but in practice, we&#8217;re using passive-aggressive words. The real intention is to command someone.</p><p>We don&#8217;t want to inform that the trash bin is full, but we want someone to take it out. We don&#8217;t want to inform that the coffee machine requires coffee beans refill, but we want someone to do it.</p><p>Passive-aggressive tone is the worst. It&#8217;s toxic for both sides of the communication. Usually, it&#8217;s just better to ask someone to do it.</p><p>The same rule applies in event-driven modelling. We should avoid passive-aggressive communication at all costs.</p><p><strong>We should watch out for Passive-Agressive Events. So events that should be commands.</strong></p><p>I already warned you in past <a href="https://event-driven.io/pl/dont_let_event_driven_architecture_buzzwords_fool_you/">not to let Event-Driven Architecture buzzwords fool us</a>.</p><p>Event-Driven Architecture is an integration architecture style. We&#8217;re trying to model our business processes to run smoothly. To achieve that, we prefer a non-blocking communication flow, with things happening in parallel at their own pace. The goal is to achieve autonomous components, reducing the time needed to understand them. That helps <a href="https://event-driven.io/pl/removability_over_maintainability/">maintain, or even replace them</a> as your business evolves.</p><p>And events are enablers for that. They notify of what has happened, allowing other components to interpret facts and take the next steps.</p><p>But&#8230; Let me show one more photo.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!sdC9!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F66ed3c6a-e9ab-42af-a170-8d452d66db09_800x440.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!sdC9!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F66ed3c6a-e9ab-42af-a170-8d452d66db09_800x440.jpeg 424w, https://substackcdn.com/image/fetch/$s_!sdC9!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F66ed3c6a-e9ab-42af-a170-8d452d66db09_800x440.jpeg 848w, https://substackcdn.com/image/fetch/$s_!sdC9!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F66ed3c6a-e9ab-42af-a170-8d452d66db09_800x440.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!sdC9!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F66ed3c6a-e9ab-42af-a170-8d452d66db09_800x440.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!sdC9!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F66ed3c6a-e9ab-42af-a170-8d452d66db09_800x440.jpeg" width="800" height="440" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/66ed3c6a-e9ab-42af-a170-8d452d66db09_800x440.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:440,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;parliament&quot;,&quot;title&quot;:&quot;parliament&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="parliament" title="parliament" srcset="https://substackcdn.com/image/fetch/$s_!sdC9!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F66ed3c6a-e9ab-42af-a170-8d452d66db09_800x440.jpeg 424w, https://substackcdn.com/image/fetch/$s_!sdC9!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F66ed3c6a-e9ab-42af-a170-8d452d66db09_800x440.jpeg 848w, https://substackcdn.com/image/fetch/$s_!sdC9!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F66ed3c6a-e9ab-42af-a170-8d452d66db09_800x440.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!sdC9!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F66ed3c6a-e9ab-42af-a170-8d452d66db09_800x440.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>It&#8217;s parliament, per the official definition: a room full of angry, shouting people.</p><p><strong>If we model our communication only in terms of events, our system will look just like that.</strong> We&#8217;d just announce new facts in a passive-aggressive style and not be interested in what happens next. Oh, wait, are we really not interested? Actually, we are. If someone won&#8217;t do what we expect with our information, we&#8217;ll be even angrier.</p><p><a href="https://event-driven.io/pl/whats_the_difference_between_event_and_command/">What&#8217;s the difference between a command and an event?</a> Both are messages. They convey specific information: a command indicating intent to do something, an event describes what has happened. From the computer&#8217;s point of view, they are no different. Only the business logic and the interpretation of the message can distinguish between an event and a command.</p><p>And that&#8217;s the main difference: commands can be rejected by the command handler. Events can only be ignored.</p><p>If we publish an event, we expect one or more consumers to be interested in it. Yet, we don&#8217;t know which components will do it. We just broadcast information.</p><p>This can easily change into passive-aggressive:</p><blockquote><p>I did my work, now it&#8217;s your turn.</p></blockquote><p>And here&#8217;s the crucial part. If we always have a single consumer for an event that needs to run the specific logic and expect to get the particular event back, then it should be a command. It&#8217;s not an event, we don&#8217;t inform. We want some component to take the next specific step and let us know when it&#8217;s finished.</p><p>Aren&#8217;t we making our communication synchronous?</p><p>What does it even mean, synchronous or asynchronous?</p><p>That&#8217;s what <a href="https://www.youtube.com/watch?v=2LMEJ-WGFTk">Sam Newman discussed in his great talk</a>. The main conclusion is that synchronous vs asynchronous discussion is actually about blocking or non-blocking processing. And that&#8217;s much broader topic than the technical solution (so whether we call something in-process via an HTTP endpoint or a messaging system).</p><p>It&#8217;s a common misconception that events are published asynchronously through a messaging system (e.g. Kafka, RabbitMQ, SQS, WhateverQueue) and commands are sent through synchronous WebAPI. That can be true for a specific solution, but not as the general rule. As said, both events and commands are messages; we can send them through a messaging system or via HTTP (e.g. events via webhooks).</p><p>This misleading split came out from our expectation about handling. We expected the command handler to give us the result. For event handler, we don&#8217;t expect a specific result. At least in theory.</p><p><strong>If we publish a specific event to the messaging system and expect a specific critical path of follow-up events, then we&#8217;re not making our communication non-blocking. It&#8217;s still sequential.</strong> We cannot proceed until the expected sequence occurs.</p><p>Whether something is blocking or not is not established by the tools we use, but by how our business process looks.</p><p>Speaking about it.</p><p>Let&#8217;s get back to our favourite E-Commerce Order scenario (read more in <a href="https://www.architecture-weekly.com/p/predictable-identifiers-enabling">Predictable Identifiers: Enabling True Module Autonomy in Distributed Systems</a>).</p><p>We could model it so we just publish the <em>OrderConfirmed</em> event and passively-aggressively expect that others will take it from there. So:</p><ul><li><p>The payment module will initiate the payment.</p></li><li><p>Inventory will start completing shipments.</p></li><li><p>The notification module will send a confirmation e-mail.</p></li><li><p>Fraud detection module will check if the order is not rigged.</p></li></ul><p>Once we receive information about a successful shipment or payment registration, we can complete the order.</p><p><strong>You may notice two paths for order processing:</strong></p><ol><li><p><strong>Blocking</strong> - We need to wait for information about payments and shipments. This is our critical path.</p></li><li><p><strong>Non-blocking</strong>- Order process shouldn&#8217;t stop if the notification wasn&#8217;t sent or the data warehouse wasn&#8217;t able to process events. We&#8217;d like that to happen, but it&#8217;s expected rather than critical.</p></li></ol><p>Now, both payments may fail (if our customer doesn&#8217;t have enough money), and the shipment may not be completed (if it&#8217;s Black Friday, and multiple people are competing for the same product).</p><p>If that happens, ordering module needs to take action, for instance, do reimbursement if the shipment wasn&#8217;t completed, and eventually cancel the order.</p><p>If we don&#8217;t foresee that and stay in passive-aggressive mode, we tend to forget about <em>&#8220;negative&#8221;</em> scenarios. it&#8217;s too easy to stay in I-Alread-Did-My-Job mode. This will have severe consequences: blocked orders, missed communication, and dissatisfied customers.</p><p>We may lear too late that another module can actually say no:</p><ol><li><p>Payment module can say: <em>Man, that&#8217;s not going to happen, you&#8217;ve already run out of money</em>.</p></li><li><p>Shipment module can say: <em>Man, I&#8217;m sorry, but you weren&#8217;t fast enough and we&#8217;ve run out of product</em>.</p></li></ol><p>And both of those scenarios will block successful order completion.</p><p>How to find such cases? <a href="https://event-driven.io/pl/intro_to_example_mapping/">Doing Example Mapping during modelling can be a good option for that</a>.</p><p>Most importantly, we need to embrace the fact that some processes require direct, blocking communication, and others don&#8217;t. Just like in real life, sometimes it&#8217;s just more effective to tell someone to do something. We should avoid micromanagement and aim for autonomy, but not end up with anarchy.</p><p>In our case, it&#8217;d be better to have a coordinator (<a href="https://event-driven.io/pl/how_to_have_fun_with_typescript_and_workflow/">workflow</a>, <a href="https://event-driven.io/pl/saga_process_manager_distributed_transactions/">saga, process manager</a>, <a href="https://event-driven.io/pl/to_do_list_and_passage_of_time_patterns_combined/">To-Do List</a> etc.) that publishes the <em>OrderConfirmed</em> event for modules not on the critical path and sends commands like <em>RecordPayment</em> and <em>InitiateShipment</em>.</p><p>By that, we&#8217;re separating responsibilities and making explicit what should be explicit. This also helps in understanding the business process, as you have a central place to see the critical flow and get proper observability.</p><p>Lacking tracing and observability of the business process is one of the most common issues <a href="https://event-driven.io/pl/training/">I see in my clients&#8217; projects</a>. As said, if we don&#8217;t want to end up with parliament instead of proper communication in our system, we need to be explicit about our intention.</p><p>Is that all? Not quite, there&#8217;s one more message type we model as events that should not be events.</p><p><strong>Gregor Hohpe, in <a href="https://www.enterpriseintegrationpatterns.com/patterns/messaging/Message.html">&#8220;Enterprise Integration Patterns&#8221;</a>, besides <a href="https://www.enterpriseintegrationpatterns.com/patterns/messaging/EventMessage.html">Event</a> and <a href="https://www.enterpriseintegrationpatterns.com/patterns/messaging/CommandMessage.html">Command</a> defines one more message type: <a href="https://www.enterpriseintegrationpatterns.com/patterns/messaging/DocumentMessage.html">Document</a>.</strong></p><p>What&#8217;s the Document? It&#8217;s a state. Or to be precise: self-contained data we have at a certain point in time. We can store it, but we can also publish information about its new value.</p><p>That&#8217;s probably why Martin Fowler frames it as <a href="https://martinfowler.com/articles/201701-event-driven.html">Event-Carried State Transfer</a>, and I don&#8217;t like that term. For me, it&#8217;s extremely misleading as, it doesn&#8217;t tell what has happened, but what has changed. It just gathers the new version of the state (or the diff).</p><p>In my opinion, it&#8217;s a variation of <a href="https://event-driven.io/pl/state-obsession/">State Obsession anti-pattern</a>. Many people fell into that and believe it&#8217;s fine to connect the messaging system to the database, use tools like <a href="https://en.wikipedia.org/wiki/Change_data_capture">Change Data Capture</a>, and publish it automatically to others. They end up with passive-aggressive communication style:</p><blockquote><p>You have all you need. The whole state is in the <em>events</em>, just interpret it.</p></blockquote><p>How can you reason about what has happened if instead of <em>OrderConfirmed</em> you get <em>OrderCreated</em>, <em>OrderUpdated</em>, <em>OrderDeleted</em>? You&#8217;d need to do the diff, compare with previous values, and do the guess about the reason of the specific change.</p><p>You deal with <a href="https://event-driven.io/pl/clickbait_event/">Clickbait Events</a> and have a leaking business abstraction. All consumers need to understand the internals of your processing to detect a specific type of change. I wrote about it in detail in <a href="https://event-driven.io/pl/internal_external_events/">Internal and external events, or how to design event-driven API</a>.</p><p>Again, the loose coupling of the event-driven processing is only loose for producers; consumers need to adapt. This can lead to hidden coupling, where a change in the producer breaks consumer flows. And that&#8217;s the worst type of coupling you can get.</p><p><strong>If we&#8217;re making commands explicit, we&#8217;re also making an explicit relationship between components.</strong> It&#8217;s no longer flattened to producer &lt;=&gt; consumer, where the producer always shapes the communication. Now, if the other component exposes a command, that&#8217;s the driving force behind its behaviour. This helps to shape autonomy. In our case, we could make the Payment Module a generic module with a stable public API for registering payments, and an ordering module that requests them, in accordance with the Shipment Module. Fraud Detection could continue subscribing to events, as it already does. <a href="https://github.com/ddd-crew/context-mapping">Context Mapping</a> can greatly help in finding those relationships.</p><h2>TLDR</h2><p>We tend to be all about events these days, but they&#8217;re not the only message types. In our systems, messages take various forms: Events, Commands, and Documents, each serving distinct purposes:</p><ul><li><p><strong>Documents are all about state transitions</strong>, which are essential for syncing data across services but missing deeper business insights.</p></li><li><p><strong>Commands represent a clear intent to act</strong>, directed with an expectation of execution, and can be accepted or rejected.</p></li><li><p><strong>Events are immutable facts</strong>, announced without waiting for a response. They&#8217;re like broadcasting news, hoping it catches the right ears.</p></li></ul><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!kihU!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F052a764b-cf75-4728-a914-9512c78242e0_800x452.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!kihU!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F052a764b-cf75-4728-a914-9512c78242e0_800x452.png 424w, https://substackcdn.com/image/fetch/$s_!kihU!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F052a764b-cf75-4728-a914-9512c78242e0_800x452.png 848w, https://substackcdn.com/image/fetch/$s_!kihU!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F052a764b-cf75-4728-a914-9512c78242e0_800x452.png 1272w, https://substackcdn.com/image/fetch/$s_!kihU!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F052a764b-cf75-4728-a914-9512c78242e0_800x452.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!kihU!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F052a764b-cf75-4728-a914-9512c78242e0_800x452.png" width="800" height="452" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/052a764b-cf75-4728-a914-9512c78242e0_800x452.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:452,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;message types&quot;,&quot;title&quot;:&quot;message types&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="message types" title="message types" srcset="https://substackcdn.com/image/fetch/$s_!kihU!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F052a764b-cf75-4728-a914-9512c78242e0_800x452.png 424w, https://substackcdn.com/image/fetch/$s_!kihU!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F052a764b-cf75-4728-a914-9512c78242e0_800x452.png 848w, https://substackcdn.com/image/fetch/$s_!kihU!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F052a764b-cf75-4728-a914-9512c78242e0_800x452.png 1272w, https://substackcdn.com/image/fetch/$s_!kihU!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F052a764b-cf75-4728-a914-9512c78242e0_800x452.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Event-Driven Architectures enable loose coupling, but only for producers. To make consumers loosely coupled, we need to take extra steps, embrace different message types, and have them participate in modelling business processes.</p><p>If we go too far with an event-all-the-things communication style, we&#8217;ll make our system a room full of shouting people, with a passive-aggressive communication style. Or just aggressive.</p><p>In consequence, we won&#8217;t know what&#8217;s happening in our system, will see only noise, and will have a hard time making it reliable, observable and predictable. We should treat our messages as a communication contract, API and model their flow in a way that shapes our regular communication.</p><p><strong>So next time, ask yourself if your event shouldn&#8217;t be a command. If it has always had a single consumer and you expect a specific event back, then it&#8217;s probably so.</strong> It&#8217;s all about being clear about the intention, not lying to yourself and others.</p><p>I hope this article will equip you with the knowledge to fix that.</p><p><strong>If you&#8217;re dealing with such issues, I&#8217;m happy to help you through consulting, <a href="https://event-driven.io/en/training">training</a> or mentoring. <a href="mailto:oskar@event-driven.io">Contact me</a> and we&#8217;ll find a way to unblock you!</strong></p><p><strong>See also more in series about <a href="https://event-driven.io/en/anti-patterns/">event modelling anti-patterns</a>:</strong></p><ul><li><p><a href="https://event-driven.io/en/state-obsession/">State Obsession</a>,</p></li><li><p><a href="https://event-driven.io/en/property-sourcing/">Property Sourcing</a>,</p></li><li><p><a href="https://event-driven.io/en/i_will_just_add_one_more_field/">I&#8217;ll just add one more field</a>.</p></li><li><p><a href="https://event-driven.io/en/clickbait_event/">Clickbait event</a>,</p></li><li><p><a href="https://event-driven.io/en/passive_aggressive_events">Passive Aggressive Events</a>,</p></li><li><p><a href="https://event-driven.io/en/one_or_more_event_that_is_the_question/">Should you record multiple events from business logic?</a>,</p></li><li><p><a href="https://event-driven.io/en/on_putting_stream_id_in_event_data/">Stream ids, event types prefixes and other event data you might not want to slice off</a>.</p></li></ul><p><strong>Check also more general considerations:</strong></p><ul><li><p><a href="https://event-driven.io/en/events_should_be_as_small_as_possible/">Events should be as small as possible, right?</a>,</p></li><li><p><a href="https://event-driven.io/en/whats_the_difference_between_event_and_command/">What&#8217;s the difference between a command and an event?</a>,</p></li><li><p><a href="https://event-driven.io/en/internal_external_events/">Internal and external events, or how to design event-driven API</a>,</p></li><li><p><a href="https://event-driven.io/en/event_streaming_is_not_event_sourcing/">Event Streaming is not Event Sourcing!</a>,</p></li><li><p><a href="https://event-driven.io/en/dont_let_event_driven_architecture_buzzwords_fool_you/">Don&#8217;t let Event-Driven Architecture buzzwords fool you</a>,</p></li><li><p><a href="https://event-driven.io/en/how_to_design_software_architecture_pragmatically/">How to design software architecture pragmatically</a>,</p></li><li><p><a href="https://event-driven.io/en/gdpr_in_event_driven_architecture/">How to deal with privacy and GDPR in Event-Driven systems</a>.</p></li></ul><p>Cheers!</p><p>Oskar</p><p>p.s. <strong>Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to <a href="https://www.icrc.org/en/donate/ukraine">Red Cross</a>, <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian organisation</a> or <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">donate Ambulances for Ukraine</a>.</p>]]></content:encoded></item><item><title><![CDATA[The one where Oskar explains Example Mapping]]></title><description><![CDATA[Learn one of the simplest and actionable modelling technique]]></description><link>https://www.architecture-weekly.com/p/the-one-where-oskar-explains-example</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/the-one-where-oskar-explains-example</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 30 Mar 2026 16:02:25 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!wiRE!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd37784ae-b61e-458f-bb5d-62b57dfdfc59_800x671.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!wiRE!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd37784ae-b61e-458f-bb5d-62b57dfdfc59_800x671.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!wiRE!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd37784ae-b61e-458f-bb5d-62b57dfdfc59_800x671.png 424w, https://substackcdn.com/image/fetch/$s_!wiRE!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd37784ae-b61e-458f-bb5d-62b57dfdfc59_800x671.png 848w, https://substackcdn.com/image/fetch/$s_!wiRE!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd37784ae-b61e-458f-bb5d-62b57dfdfc59_800x671.png 1272w, https://substackcdn.com/image/fetch/$s_!wiRE!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd37784ae-b61e-458f-bb5d-62b57dfdfc59_800x671.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!wiRE!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd37784ae-b61e-458f-bb5d-62b57dfdfc59_800x671.png" width="800" height="671" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/d37784ae-b61e-458f-bb5d-62b57dfdfc59_800x671.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:671,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;cover&quot;,&quot;title&quot;:&quot;cover&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="cover" title="cover" srcset="https://substackcdn.com/image/fetch/$s_!wiRE!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd37784ae-b61e-458f-bb5d-62b57dfdfc59_800x671.png 424w, https://substackcdn.com/image/fetch/$s_!wiRE!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd37784ae-b61e-458f-bb5d-62b57dfdfc59_800x671.png 848w, https://substackcdn.com/image/fetch/$s_!wiRE!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd37784ae-b61e-458f-bb5d-62b57dfdfc59_800x671.png 1272w, https://substackcdn.com/image/fetch/$s_!wiRE!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd37784ae-b61e-458f-bb5d-62b57dfdfc59_800x671.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>One of the first indications of getting old(er) is when people stop getting your movie or music references. Of course, based on this rule, some people are always old. That happens.</p><p>Recently, I realised during <a href="https://event-driven.io/en/training/">my workshops</a> that referencing Friends is not so cool anymore. It started to happen when I was explaining the Example Mapping technique.</p><blockquote><p>It always starts with &#8220;The one where&#8221;.</p><p>Just like in Friends.</p></blockquote><p>I started to notice a bit slower head nodding and a bit more awkward smiles from the attendees. I repeated</p><blockquote><p>You know, like in titles of Friends episodes.</p></blockquote><p>And head-nodding stopped; only totally awkwardly polite smiles remained. Definitely, it wasn&#8217;t The One Where Everybody Finds Out. So I finally asked:</p><blockquote><p>You weren&#8217;t watching Friends, didn&#8217;t you?</p></blockquote><p>Obviously, the answer was:</p><blockquote><p>Emmm. No&#8230;</p></blockquote><p><strong>Okay, then, if you don&#8217;t know Friends or the Example Mapping technique, this will be The One Where You Find Out.</strong></p><p>Let&#8217;s say that we&#8217;re working on the guest checkout feature for a hotel management system.</p><p>We could start by asking the business how it works. We could get an answer that:</p><blockquote><p>The guest approaches the desk and requests checkout. The clerk inquires about the quality of the products and services, and after receiving an answer, requests the room key. After gathering, the key clerk checks whether the balance is settled. If it&#8217;s settled, then proceed with the checkout. Marking the stay as completed.</p></blockquote><p>Sounds straightforward, but we should already have several questions popping up, e.g. what does it mean that &#8220;balance is settled&#8221;? We could get quick feedback that:</p><blockquote><p>This means that the difference between the sums of all charges and payments is equal to zero.</p></blockquote><p>Then we could try to come up with an example:</p><blockquote><p>Ah, so for instance, when guests haven&#8217;t paid upfront for their stay, right?</p></blockquote><p>Right.</p><blockquote><p>Oh, then we need to charge them, right?</p></blockquote><p>Right.</p><p>We could visualise what we discovered in the following way:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!9AoP!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc990952-1b69-4e5c-a86e-ca2e6f47fe2a_800x871.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!9AoP!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc990952-1b69-4e5c-a86e-ca2e6f47fe2a_800x871.jpeg 424w, https://substackcdn.com/image/fetch/$s_!9AoP!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc990952-1b69-4e5c-a86e-ca2e6f47fe2a_800x871.jpeg 848w, https://substackcdn.com/image/fetch/$s_!9AoP!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc990952-1b69-4e5c-a86e-ca2e6f47fe2a_800x871.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!9AoP!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc990952-1b69-4e5c-a86e-ca2e6f47fe2a_800x871.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!9AoP!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc990952-1b69-4e5c-a86e-ca2e6f47fe2a_800x871.jpeg" width="800" height="871" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/dc990952-1b69-4e5c-a86e-ca2e6f47fe2a_800x871.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:871,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;em01&quot;,&quot;title&quot;:&quot;em01&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="em01" title="em01" srcset="https://substackcdn.com/image/fetch/$s_!9AoP!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc990952-1b69-4e5c-a86e-ca2e6f47fe2a_800x871.jpeg 424w, https://substackcdn.com/image/fetch/$s_!9AoP!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc990952-1b69-4e5c-a86e-ca2e6f47fe2a_800x871.jpeg 848w, https://substackcdn.com/image/fetch/$s_!9AoP!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc990952-1b69-4e5c-a86e-ca2e6f47fe2a_800x871.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!9AoP!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc990952-1b69-4e5c-a86e-ca2e6f47fe2a_800x871.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Now, this generated another flow for us. We have a new feature we weren&#8217;t aware of: the guest&#8217;s stay payment registration. Let&#8217;s try to start this time from the visualisation.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!gG9G!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F81974702-c0c2-4703-b415-a417e97294a8_800x727.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!gG9G!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F81974702-c0c2-4703-b415-a417e97294a8_800x727.jpeg 424w, https://substackcdn.com/image/fetch/$s_!gG9G!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F81974702-c0c2-4703-b415-a417e97294a8_800x727.jpeg 848w, https://substackcdn.com/image/fetch/$s_!gG9G!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F81974702-c0c2-4703-b415-a417e97294a8_800x727.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!gG9G!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F81974702-c0c2-4703-b415-a417e97294a8_800x727.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!gG9G!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F81974702-c0c2-4703-b415-a417e97294a8_800x727.jpeg" width="800" height="727" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/81974702-c0c2-4703-b415-a417e97294a8_800x727.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:727,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;em02&quot;,&quot;title&quot;:&quot;em02&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="em02" title="em02" srcset="https://substackcdn.com/image/fetch/$s_!gG9G!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F81974702-c0c2-4703-b415-a417e97294a8_800x727.jpeg 424w, https://substackcdn.com/image/fetch/$s_!gG9G!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F81974702-c0c2-4703-b415-a417e97294a8_800x727.jpeg 848w, https://substackcdn.com/image/fetch/$s_!gG9G!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F81974702-c0c2-4703-b415-a417e97294a8_800x727.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!gG9G!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F81974702-c0c2-4703-b415-a417e97294a8_800x727.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>It&#8217;s the one Oskar pays for his stay, because he wants to check out but didn&#8217;t pay upfront. The payment is registered, and we can try checking out again. Sounds fine, but we should ask whether there are any rules for payments. It may appear that:</p><blockquote><p>Yes, there are some, for instance:</p><ul><li><p>Only guests with a valid credit card can pay with it for their stay,</p></li><li><p>Guests paying in cash need to hand it over before accepting the payment.</p></li></ul></blockquote><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!V8g4!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb9e395-718e-4076-b22e-bbfc273b8885_800x1024.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!V8g4!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb9e395-718e-4076-b22e-bbfc273b8885_800x1024.jpeg 424w, https://substackcdn.com/image/fetch/$s_!V8g4!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb9e395-718e-4076-b22e-bbfc273b8885_800x1024.jpeg 848w, https://substackcdn.com/image/fetch/$s_!V8g4!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb9e395-718e-4076-b22e-bbfc273b8885_800x1024.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!V8g4!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb9e395-718e-4076-b22e-bbfc273b8885_800x1024.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!V8g4!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb9e395-718e-4076-b22e-bbfc273b8885_800x1024.jpeg" width="800" height="1024" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/fcb9e395-718e-4076-b22e-bbfc273b8885_800x1024.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1024,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;em03&quot;,&quot;title&quot;:&quot;em03&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="em03" title="em03" srcset="https://substackcdn.com/image/fetch/$s_!V8g4!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb9e395-718e-4076-b22e-bbfc273b8885_800x1024.jpeg 424w, https://substackcdn.com/image/fetch/$s_!V8g4!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb9e395-718e-4076-b22e-bbfc273b8885_800x1024.jpeg 848w, https://substackcdn.com/image/fetch/$s_!V8g4!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb9e395-718e-4076-b22e-bbfc273b8885_800x1024.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!V8g4!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb9e395-718e-4076-b22e-bbfc273b8885_800x1024.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>And hey, we just found out new business rules, let&#8217;s put them on the board and update our flow to be more precise and reflect our scenario by adding a note that this scenario represents a guest paying with a credit card.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!YAJv!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7b76a54-8f85-45ec-b5cf-979e9f003cb1_800x1070.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!YAJv!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7b76a54-8f85-45ec-b5cf-979e9f003cb1_800x1070.jpeg 424w, https://substackcdn.com/image/fetch/$s_!YAJv!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7b76a54-8f85-45ec-b5cf-979e9f003cb1_800x1070.jpeg 848w, https://substackcdn.com/image/fetch/$s_!YAJv!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7b76a54-8f85-45ec-b5cf-979e9f003cb1_800x1070.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!YAJv!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7b76a54-8f85-45ec-b5cf-979e9f003cb1_800x1070.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!YAJv!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7b76a54-8f85-45ec-b5cf-979e9f003cb1_800x1070.jpeg" width="800" height="1070" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/c7b76a54-8f85-45ec-b5cf-979e9f003cb1_800x1070.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1070,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;em05&quot;,&quot;title&quot;:&quot;em05&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="em05" title="em05" srcset="https://substackcdn.com/image/fetch/$s_!YAJv!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7b76a54-8f85-45ec-b5cf-979e9f003cb1_800x1070.jpeg 424w, https://substackcdn.com/image/fetch/$s_!YAJv!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7b76a54-8f85-45ec-b5cf-979e9f003cb1_800x1070.jpeg 848w, https://substackcdn.com/image/fetch/$s_!YAJv!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7b76a54-8f85-45ec-b5cf-979e9f003cb1_800x1070.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!YAJv!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7b76a54-8f85-45ec-b5cf-979e9f003cb1_800x1070.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Now, what if the payment fails? Can it fail? Let&#8217;s ask the business!</p><blockquote><p>Payment may fail if the payment gateway is unavailable or the issuer rejects it.</p></blockquote><p>What if our internet connection is down for the whole day?</p><blockquote><p>The clerk should ask the guest for cash.</p></blockquote><p>What if the guest doesn&#8217;t have cash? &#129300;</p><blockquote><p>Then the shift manager can authorise unsettled balance checkout and register a charge with a delayed due date.</p></blockquote><p>Here&#8217;s the updated flow. The one where Oskar pays for his entire stay with a credit card, but the Internet is down, and he doesn&#8217;t have cash.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!fIY9!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b8acd24-7828-40d9-9d08-aa9d1f2622e2_800x1059.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!fIY9!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b8acd24-7828-40d9-9d08-aa9d1f2622e2_800x1059.jpeg 424w, https://substackcdn.com/image/fetch/$s_!fIY9!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b8acd24-7828-40d9-9d08-aa9d1f2622e2_800x1059.jpeg 848w, https://substackcdn.com/image/fetch/$s_!fIY9!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b8acd24-7828-40d9-9d08-aa9d1f2622e2_800x1059.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!fIY9!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b8acd24-7828-40d9-9d08-aa9d1f2622e2_800x1059.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!fIY9!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b8acd24-7828-40d9-9d08-aa9d1f2622e2_800x1059.jpeg" width="800" height="1059" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/2b8acd24-7828-40d9-9d08-aa9d1f2622e2_800x1059.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1059,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;em04&quot;,&quot;title&quot;:&quot;em04&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="em04" title="em04" srcset="https://substackcdn.com/image/fetch/$s_!fIY9!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b8acd24-7828-40d9-9d08-aa9d1f2622e2_800x1059.jpeg 424w, https://substackcdn.com/image/fetch/$s_!fIY9!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b8acd24-7828-40d9-9d08-aa9d1f2622e2_800x1059.jpeg 848w, https://substackcdn.com/image/fetch/$s_!fIY9!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b8acd24-7828-40d9-9d08-aa9d1f2622e2_800x1059.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!fIY9!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b8acd24-7828-40d9-9d08-aa9d1f2622e2_800x1059.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Now, we found out:</p><ul><li><p><strong>A new outcome</strong>, failed payment,</p></li><li><p><strong>A new rule</strong>, that we need Internet access to authorise credit card payment,</p></li><li><p><strong>A new feature</strong>, the shift manager can authorise unsettled balance checkout and register a charge with a delayed due date.</p></li></ul><p>How would the authorisation look? How should we register a delayed charge?</p><blockquote><p>We should follow double-entry bookkeeping and register the authorisation (type of payment) with the unbalanced amount to settle the balance for today, and register an additional delayed charge for the same amount</p></blockquote><p>The flow will look like:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Iilt!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb80f7897-1f9c-4f38-9148-dce8270beb51_800x1065.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Iilt!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb80f7897-1f9c-4f38-9148-dce8270beb51_800x1065.jpeg 424w, https://substackcdn.com/image/fetch/$s_!Iilt!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb80f7897-1f9c-4f38-9148-dce8270beb51_800x1065.jpeg 848w, https://substackcdn.com/image/fetch/$s_!Iilt!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb80f7897-1f9c-4f38-9148-dce8270beb51_800x1065.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!Iilt!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb80f7897-1f9c-4f38-9148-dce8270beb51_800x1065.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Iilt!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb80f7897-1f9c-4f38-9148-dce8270beb51_800x1065.jpeg" width="800" height="1065" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b80f7897-1f9c-4f38-9148-dce8270beb51_800x1065.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1065,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;em06&quot;,&quot;title&quot;:&quot;em06&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="em06" title="em06" srcset="https://substackcdn.com/image/fetch/$s_!Iilt!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb80f7897-1f9c-4f38-9148-dce8270beb51_800x1065.jpeg 424w, https://substackcdn.com/image/fetch/$s_!Iilt!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb80f7897-1f9c-4f38-9148-dce8270beb51_800x1065.jpeg 848w, https://substackcdn.com/image/fetch/$s_!Iilt!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb80f7897-1f9c-4f38-9148-dce8270beb51_800x1065.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!Iilt!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb80f7897-1f9c-4f38-9148-dce8270beb51_800x1065.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>And that&#8217;s precisely how the Example Mapping session looks like. It&#8217;s a structured conversation format created by <a href="https://mattwynne.net/about">Matt Wynne</a>. You take a user story, gather a small group (usually a developer, tester, and someone from the business side), and spend around 25-30 minutes breaking it down together.</p><p>You don&#8217;t need a big setup, a huge ceremon, you don&#8217;t need sticky notes, you can just use plain text like:</p><p><strong>Given:</strong> Example</p><p><strong>When:</strong> We use specific features</p><p><strong>Then:</strong> Based on business rules, we get a specific outcome.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!brqo!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc56c7142-5150-4ef8-9a36-46a0b5fd9854_800x576.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!brqo!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc56c7142-5150-4ef8-9a36-46a0b5fd9854_800x576.jpeg 424w, https://substackcdn.com/image/fetch/$s_!brqo!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc56c7142-5150-4ef8-9a36-46a0b5fd9854_800x576.jpeg 848w, https://substackcdn.com/image/fetch/$s_!brqo!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc56c7142-5150-4ef8-9a36-46a0b5fd9854_800x576.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!brqo!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc56c7142-5150-4ef8-9a36-46a0b5fd9854_800x576.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!brqo!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc56c7142-5150-4ef8-9a36-46a0b5fd9854_800x576.jpeg" width="800" height="576" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/c56c7142-5150-4ef8-9a36-46a0b5fd9854_800x576.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:576,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;em07&quot;,&quot;title&quot;:&quot;em07&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="em07" title="em07" srcset="https://substackcdn.com/image/fetch/$s_!brqo!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc56c7142-5150-4ef8-9a36-46a0b5fd9854_800x576.jpeg 424w, https://substackcdn.com/image/fetch/$s_!brqo!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc56c7142-5150-4ef8-9a36-46a0b5fd9854_800x576.jpeg 848w, https://substackcdn.com/image/fetch/$s_!brqo!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc56c7142-5150-4ef8-9a36-46a0b5fd9854_800x576.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!brqo!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc56c7142-5150-4ef8-9a36-46a0b5fd9854_800x576.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Business people don&#8217;t need to give them to you in such form. You can use the interview as I showed above and note it on your own, while you&#8217;re discussing stuff. It&#8217;s also a nice way to collaborate and visualise your discussions.</p><p>You don&#8217;t even need to start with an interview; you can use the Example Mapping as a brainstorming tool to generate as many examples of your (part of the) system. Then, try to model it as you see fit and ask the business for clarifications in the preferred form. It can help facilitate discussion with your team, not only with business stakeholders.</p><p>It&#8217;s super helpful, as misunderstandings are expensive. When multiple people try to describe the same rule using real examples, you&#8217;ll quickly see where assumptions don&#8217;t match. Better to find that out in a 30-minute chat than after two weeks of coding the wrong thing.</p><p>It also works as a readiness check. Too many red cards? The story isn&#8217;t ready. Too many blue cards? The story is probably too big. If examples come easily and everyone nods along - you&#8217;re good to go.</p><p>There&#8217;s also more, as you can try to distil business rules based on the examples and outcomes the business describes.</p><p>It&#8217;s worth noting that I cheated you in colours. The original looks like this:</p><p>Why did I change them?</p><p>Example Mapping plays nicely with other collaboration techniques like Event Storming. And if you&#8217;re familiar with the Event Storming colour scheme, that&#8217;s also the reason why I used it. I aligned them. See:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!laHZ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa59f21c1-131b-41ce-b9c5-9ebc4285fc6c_800x535.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!laHZ!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa59f21c1-131b-41ce-b9c5-9ebc4285fc6c_800x535.png 424w, https://substackcdn.com/image/fetch/$s_!laHZ!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa59f21c1-131b-41ce-b9c5-9ebc4285fc6c_800x535.png 848w, https://substackcdn.com/image/fetch/$s_!laHZ!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa59f21c1-131b-41ce-b9c5-9ebc4285fc6c_800x535.png 1272w, https://substackcdn.com/image/fetch/$s_!laHZ!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa59f21c1-131b-41ce-b9c5-9ebc4285fc6c_800x535.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!laHZ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa59f21c1-131b-41ce-b9c5-9ebc4285fc6c_800x535.png" width="800" height="535" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/a59f21c1-131b-41ce-b9c5-9ebc4285fc6c_800x535.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:535,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;source: https://cucumber.io/blog/bdd/example-mapping-introduction/&quot;,&quot;title&quot;:&quot;source: https://cucumber.io/blog/bdd/example-mapping-introduction/&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="source: https://cucumber.io/blog/bdd/example-mapping-introduction/" title="source: https://cucumber.io/blog/bdd/example-mapping-introduction/" srcset="https://substackcdn.com/image/fetch/$s_!laHZ!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa59f21c1-131b-41ce-b9c5-9ebc4285fc6c_800x535.png 424w, https://substackcdn.com/image/fetch/$s_!laHZ!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa59f21c1-131b-41ce-b9c5-9ebc4285fc6c_800x535.png 848w, https://substackcdn.com/image/fetch/$s_!laHZ!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa59f21c1-131b-41ce-b9c5-9ebc4285fc6c_800x535.png 1272w, https://substackcdn.com/image/fetch/$s_!laHZ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa59f21c1-131b-41ce-b9c5-9ebc4285fc6c_800x535.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>I&#8217;m typically using it during modelling sessions to:</p><ul><li><p>brainstorm (read more in <a href="https://www.architecture-weekly.com/p/start-alone-then-together-why-software">Start Alone, Then Together: Why Software Modelling Needs Solitary Brainstorming</a>),</p></li><li><p>challenging existing models with real-world examples,</p></li><li><p>expanding the model with uncovered (through examples) use cases,</p></li><li><p>finding business rules,</p></li><li><p>helping with facilitation by looking at the model from a different perspective.</p></li></ul><p>And hot spots and notes, as known from EventStorming, are super helpful here. Read also more in <a href="https://www.architecture-weekly.com/p/the-underestimated-power-of-hot-spots">The Underestimated Power of Hot Spots and Notes in EventStorming</a>.</p><p>What&#8217;s more, if you look at the Given/When/Then pattern, you may notice that it works nicely with Behaviour-Driven Design. I already wrote that <a href="https://event-driven.io/en/behaviour_driven_design_is_not_about_tests/">Behaviour-Driven Design is more than tests</a>. How to do it? Check <a href="https://event-driven.io/en/testing_event_sourcing_emmett_edition/">here</a>.</p><p>I&#8217;ll also expand on it in the next articles. I&#8217;m doing the extreme Example Mapping with events, so stay tuned, the more will come.</p><p>For now, check also those materials:</p><ul><li><p><a href="https://www.youtube.com/watch?v=EtoTML8cuko">Seb Rose - short, practical and actionable intro to Example Mapping</a></p></li><li><p><a href="https://www.youtube.com/watch?v=WvkBKvMnyuc">Kenny Baas-Schwegler - showing how to use Example Mapping with EventStorming</a></p></li><li><p><a href="https://cucumber.io/blog/bdd/example-mapping-introduction/">An introduction by Matt Wynne himself</a>,</p></li><li><p><a href="https://draft.io/example/example-mapping">Other quick intro by Gojko Adzic</a>.</p></li></ul><p>And most importantly, try it. Take one feature from your system, try to crunch it, or start your design session with this technique.</p><p><a href="https://event-driven.io/en/training/">Most of the teams I&#8217;m working with</a> are enjoying this technique, as it&#8217;s a fun way to get quick, actionable outcomes.</p><p>Cheers!</p><p>Oskar</p><p>p.s. <strong>Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to <a href="https://www.icrc.org/en/donate/ukraine">Red Cross</a>, <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian organisation</a> or <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">donate Ambulances for Ukraine</a>.</p>]]></content:encoded></item><item><title><![CDATA[Interactive Rubber Ducking with GenAI]]></title><description><![CDATA[On how to work on the design with GenAI, without getting mad at you're absolutely right!]]></description><link>https://www.architecture-weekly.com/p/interactive-rubber-ducking-with-genai</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/interactive-rubber-ducking-with-genai</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 16 Mar 2026 13:26:11 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!jfcr!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0c6f36c-9c5b-44bb-9768-beb5da788b1d_1408x768.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!jfcr!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0c6f36c-9c5b-44bb-9768-beb5da788b1d_1408x768.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!jfcr!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0c6f36c-9c5b-44bb-9768-beb5da788b1d_1408x768.png 424w, https://substackcdn.com/image/fetch/$s_!jfcr!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0c6f36c-9c5b-44bb-9768-beb5da788b1d_1408x768.png 848w, https://substackcdn.com/image/fetch/$s_!jfcr!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0c6f36c-9c5b-44bb-9768-beb5da788b1d_1408x768.png 1272w, https://substackcdn.com/image/fetch/$s_!jfcr!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0c6f36c-9c5b-44bb-9768-beb5da788b1d_1408x768.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!jfcr!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0c6f36c-9c5b-44bb-9768-beb5da788b1d_1408x768.png" width="1408" height="768" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/f0c6f36c-9c5b-44bb-9768-beb5da788b1d_1408x768.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:768,&quot;width&quot;:1408,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:2240324,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://www.architecture-weekly.com/i/191127846?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0c6f36c-9c5b-44bb-9768-beb5da788b1d_1408x768.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!jfcr!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0c6f36c-9c5b-44bb-9768-beb5da788b1d_1408x768.png 424w, https://substackcdn.com/image/fetch/$s_!jfcr!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0c6f36c-9c5b-44bb-9768-beb5da788b1d_1408x768.png 848w, https://substackcdn.com/image/fetch/$s_!jfcr!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0c6f36c-9c5b-44bb-9768-beb5da788b1d_1408x768.png 1272w, https://substackcdn.com/image/fetch/$s_!jfcr!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0c6f36c-9c5b-44bb-9768-beb5da788b1d_1408x768.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p>You may already know that <a href="https://event-driven.io/en/the_end_of_coding_wrong_question/">I&#8217;m a GenAI sceptic</a>. And a general sceptic.</p><p>Do you know that scepticism comes from the Greek <em>&#963;&#954;&#941;&#960;&#964;&#959;&#956;&#945;&#953;</em> (<em>skeptomai</em>), meaning &#8216;to search, to think about, or look for&#8217;? So my intention is not to say no to everything new, but more to think about it first, and understand before I say yes.</p><p>There&#8217;s a lot of stuff about GenAI that makes me smile, but I still understand that my way is my way, and I won&#8217;t stop the world. I won&#8217;t even try. Thus, I want to research and consider how those tools can help me. I already wrote that I don&#8217;t feel like <a href="https://www.architecture-weekly.com/p/requiem-for-a-10x-engineer-dream">10x Dev</a>, but I&#8217;m finding more ways to get help from it.</p><p><strong>One of the ways that helped me is something I call </strong><em><strong>&#8220;Interactive Rubber-Ducking&#8221;</strong></em><strong>.</strong></p><p>Initially, I called it just <a href="https://www.architecture-weekly.com/p/start-alone-then-together-why-software">brainstorming</a>, but that wouldn&#8217;t be precise, as I&#8217;m not using it to brainstorm ideas, more to challenge and clarify them.</p><p>Most of the code I write nowadays is done in <a href="https://github.com/oskardudycz/">my OSS projects</a>. I&#8217;m grateful to have a <a href="https://discord.gg/fTpqUTMmVa">great community</a> with people actively contributing in different ways; still, the canonical design and code work is on my side. As I work in an event-driven niche, I&#8217;m often alone with my own thoughts. I try to use the <a href="https://www.architecture-weekly.com/p/workflow-engine-design-proposal-tell">RFC process</a> and discuss it with other fellow humans, but they are not always available. Even if they do, to avoid wasting their time, I need to know what to tell or ask them. I need to give some proposals (with alternatives) to have an <a href="https://event-driven.io/en/fifteen_tips_on_how_to_run_meetings_effectively/">effective discussion</a>. I may seem organised, but that&#8217;s not always the thing. Sitting in your own head is not a great place to be in general. If you&#8217;re a technical leader or an architect, I&#8217;m sure that you know that solitude too well.</p><p>GenAI tools are not great sparing partners. They&#8217;re <em>Yes men</em>. If they read this article, they&#8217;d for sure confirm it. They&#8217;d probably do it even without reading it. Of course, you can ask them not to be <a href="https://www.merriam-webster.com/dictionary/sycophant">sycophant</a>. You can ask numerous MUSTS with capital letters and bolded <strong>NEVER</strong> here and there, and it can help, but it won&#8217;t fully beat the way they were trained.</p><p>And talking to yourself is a similar experience: you&#8217;ll find numerous ways to justify your own decisions, and looking at the same place for too long will make you miss obvious blind spots.</p><p>Ok, so why would we take those two blind &#8220;people&#8221; and try to make them help each other?</p><p><strong>That&#8217;s kinda what &#8220;Interactive Rubber-Ducking&#8221; is.</strong> It takes a blind human with an idea, and another blind not-so-human asking questions. It starts with such a prompt:</p><blockquote><p>Ask me one question at a time so we can develop a thorough, step-by-step spec for this idea. Each question should build on my previous answers, and our end goal is to have a detailed specification I can hand off to a developer. Let&#8217;s do this iteratively and dig into every relevant detail. Remember, only one question at a time.</p><p>Once we are done, save the spec as spec.md</p><p>Before asking another question, store the previous one with the answer in qa.md. Write literally the question and answer, not just a summary.</p><p>Here&#8217;s the idea:</p></blockquote><p>Don&#8217;t treat it as <em>&#8220;one magical prompt that will change your life&#8221;</em>. Most important is why we&#8217;re doing it, what happens next, and who&#8217;s actually doing the work. Spoiler alert: it&#8217;s not LLM.</p><p>I&#8217;m using it as a command in Claude Code, and, most importantly, with beefier models like Opus, which can better reason and ask better questions. Doing it with lower-level models always gave me much worse results.</p><p>I&#8217;m using it with Claude Code, not Claude Chat, because I want the model to scan my codebase. I can ask it to look in certain areas or to reference my answers. I can even ask to search the web or MCPs like <a href="https://context7.com/">Context 7</a> to check documentation and APIs for popular libraries. Then it&#8217;s getting more into brainstorming sometimes, than rubber-ducking, but that&#8217;s fine.</p><p>As a result, we&#8217;ll get two artefacts:</p><ul><li><p><strong>qa.md</strong> - with the log of the back-and-forth discussion,</p></li><li><p><strong>spec.md</strong> - in theory spec built by LLM, but imho it&#8217;s more of a concise summary.</p></li></ul><p>It may look like Specification-Driven Design, but it&#8217;s not.</p><p>My goal for this exercise is not to get an actionable specification.</p><p><strong>The goal is to get our LLM-based Rubber Duck to ask us hard questions and make us think, not to make the LLM think for us. Find blind spots, and challenge our thinking.</strong></p><p>But we&#8217;re drivers, we need to know what we want to do, we need to know all the WHYs, and we also need to know HOW. LLM is here to help, but not to do creative work for us. It just pulls it out from our heads.</p><p>It also helps to see how our design may be seen by others, especially such mediocre thinkers as LLMs.</p><p>I don&#8217;t expect the Agent to be able to start implementing the spec. I expect it to reflect all considerations and summarise findings. I&#8217;m always double-checking to make sure it includes all the important points. If not, I&#8217;ll keep doing Q&amp;A until I&#8217;m satisfied.</p><p>Having both of those files will allow us to keep a full discussion without losing important details, and a shorter version. We can feed that to another model for review, or try to work on tasks and develop a more detailed plan. Sometimes I plan on my own; for simpler tasks, I may ask the LLM to do it fully. Usually, I&#8217;m driving the LLM step by step, passing just specific asks.</p><p>The example? Why not.</p><p><strong>I recently did such an exercise, trying to narrow down how to introduce the </strong><em><strong>Second-level cache</strong></em><strong> to <a href="https://github.com/event-driven-io/pongo">Pongo</a> and <a href="https://github.com/event-driven-io/emmett">Emmett</a>.</strong> What&#8217;s Second-level cache? A second-level cache is a local store of data managed by the persistence provider to improve application performance.</p><p><strong>Why do I want to introduce it?</strong> Because I got an <a href="https://github.com/event-driven-io/emmett/issues/322">issue from the user</a> that <a href="https://event-driven.io/en/rebuilding_event_driven_read_models/">rebuilding projections</a> with a lot events can take too long. One of the reasons is that applying an event on a projection takes:</p><ul><li><p>loading the current state,</p></li><li><p>updating it,</p></li><li><p>storing the result.</p></li></ul><p>Those are two operations per event. If we have a batch of 100 events, that&#8217;d mean 200 operations; for 1000 events, this would be 2000, so the classical N+1 problem. We could do it differently, and within a batch:</p><ul><li><p>group events by the target documents,</p></li><li><p>load all of them in one operation by finding all documents within an array of ids (taken from events),</p></li><li><p>caching them,</p></li><li><p>applying events in memory,</p></li><li><p>storing updated documents in one operation.</p></li></ul><p>Then we&#8217;ll get at worst 102 operations for 100 events and 1002 for 1000 events, so a linear increase.</p><p>Still, I decided to add batching and introduce 2nd-level cache as a solution. I and my experience were a driving force behind that. I could, of course, rubber duck it, but I had that already planned, not just detailed. I also did some initial research before doing the session.</p><p>Let me show you the result below. Not too bad if you ask me. Of course, the task wasn&#8217;t very innovative; it&#8217;s a standard way for mature solutions. Still questions were surprisingly good and helped me.</p><p>I hope this log will show you (besides how bad I&#8217;m at typos) some insight into how I think, talk, and consider the design.</p><p>Tell me your thoughts, if that matches your way, if you haven&#8217;t done it yet, try it and tell me how it went!</p><div><hr></div><h2>Interactive Rubber Ducking example</h2><h3><strong>My idea</strong></h3><p>I&#8217;d like to provide 2nd-level cache to Pongo. I want to either use keyv (https://www.npmjs.com/package/keyv) or lru-cache (https://www.npmjs.com/package/lru-cache) packages.</p><p>For now, I&#8217;d like to do it by id.</p><p>I probably need to either extend pongoCollection src/packages/pongo/src/core/collection/pongoCollection.ts with findeOneById and findManyByIds methods, or check if _id is passed in and then try to access the cache.</p><p>Cache should be set up either on the pongo collection, pongo db or pongoClient.</p><p>Optimistic Concurrency errors should invalidate cache (as that means someone else updated the record in db, e.g. through a different node). </p><p>InsertMany and insertOne should put it into the cache.</p><p>I&#8217;d also like to expand the handle method to allow passing either an id or an array of IDs to support batch loading of entities and improve performance, and to support batch update/insert/delete.</p><p>Thoughts?</p><p>Which defaults should I set? (Of course, allowing users to override it).</p><p>Check the related code I pasted. I want to keep it aligned and as non-invasive as possible.</p><h3>Q1: Cache library choice &#8212; lru-cache, keyv, or custom interface?</h3><p><strong>Question:</strong> You mentioned both <code>keyv</code> and <code>lru-cache</code>. They pull in different directions. Which trade-off do you prefer?</p><ul><li><p>A) Start with <code>lru-cache</code>, optionally let users swap adapters later.</p></li><li><p>B) Start with <code>keyv</code> so cross-node sharing works out of the box.</p></li><li><p>C) Define a thin cache interface (<code>get</code>/<code>set</code>/<code>delete</code>/<code>clear</code>) and ship <code>lru-cache</code> as the default adapter.</p></li></ul><p><strong>Answer:</strong> Custom cache interface (option C). No dependency on external APIs &#8212; keep it pluggable. Start with an in-memory provider only. Considered writing a custom LRU with me, but wants to ship fast. Key point: don&#8217;t stick to any third-party API surface, allow pluggability.</p><h3>Q2: Where should the cache live in Pongo&#8217;s hierarchy?</h3><p><strong>Question:</strong> Three attachment points: A) PongoClient level (shared across all), B) PongoDb level (per database), C) PongoCollection level (per collection). Or configurable at multiple levels?</p><p><strong>Answer:</strong> Allow defining it at different levels &#8212; cascading configuration. Set a default higher up, override per collection if needed.</p><h3>Q3: Inheritance/override behavior for cascading cache config?</h3><p><strong>Question:</strong> For the cascading config &#8212; what should the inheritance/override behavior be?</p><p>Two sub-questions:</p><ol><li><p>Should a lower level be able to <em>disable</em> caching even if a parent enables it?</p></li><li><p>Should cache <em>settings</em> (like max size, TTL) also cascade, or only the cache provider instance itself?</p></li></ol><p><strong>Answer:</strong></p><ol><li><p>Yes. Cache should be an object with type, general params and specific for this type, or <code>'disabled'</code>. That&#8217;s a proper devex.</p></li><li><p>Yes, as above. But if one selects a different cache type then general params should be inherited but type-specific should not be inherited and should fallback to defaults.</p></li></ol><p><strong>Claude&#8217;s thoughts:</strong> The <code>CacheConfig | 'disabled'</code> pattern is clean &#8212; discriminated unions play nicely with TypeScript. The inheritance rule makes sense: general params (TTL, max size) cascade, but type-specific params reset to defaults when you switch providers, avoiding leaking nonsensical config (e.g., a Redis connection string bleeding into an in-memory cache). Also worth distinguishing between &#8220;I didn&#8217;t specify anything&#8221; (inherit from parent via <code>undefined</code>) and &#8220;I explicitly want defaults&#8221; (reset) &#8212; <code>undefined</code> vs <code>'disabled'</code> vs <code>CacheConfig</code> gives a clean three-state: inherit / off / explicit.</p><h3>Q4: Cache interface shape &#8212; async, batch ops, scoping?</h3><p><strong>Question:</strong> Settling the cache interface details:</p><ol><li><p>Async or sync? Making the interface <code>Promise</code>-based from the start keeps it pluggable. Sound right?</p></li><li><p>Batch methods (<code>getMany</code>, <code>setMany</code>, <code>deleteMany</code>) &#8212; should the cache interface support batch ops natively, or loop internally?</p></li><li><p>Should <code>clear()</code> be scoped per collection, or always clear the entire cache?</p></li></ol><p><strong>Answer:</strong></p><ol><li><p>Maybe we could return either promise or no promise, then if we await it, it would support both? Also ensure that settings are close to what lru-cache and keyv are having.</p></li><li><p>Having them (even as syntactic sugar) would be a good devex.</p></li><li><p>We could pass down not only settings, but also the cache provider instance, then if settings are the same we can reuse cache without recreating it. If set had ttl provided, then we could do it by overriding those TTLs on item level. ClearAll probably would be something we should expose only for advanced, typically user should not be aware of cache.</p></li></ol><p><strong>Claude&#8217;s thoughts:</strong> <code>MaybePromise&lt;T&gt;</code> (i.e. <code>T | PromiseLike&lt;T&gt;</code>) works &#8212; <code>await</code> on a non-Promise just resolves immediately. No overhead for sync providers, no breaking change for async ones. Passing a cache instance is smart: config becomes either settings (Pongo creates the cache) or a pre-built instance (user brings their own). Multiple collections can explicitly share one cache instance. <code>clear()</code> should be internal/advanced &#8212; scope by collection prefix internally so a collection-level clear doesn&#8217;t nuke another collection&#8217;s entries when sharing an instance. For aligning with lru-cache/keyv settings: both support <code>max</code> (max entries) and <code>ttl</code> (TTL in ms). Start with <code>max</code> and <code>ttl</code> as the general params.</p><h3>Q5: Cache key strategy</h3><p><strong>Question:</strong> Since cache instances might be shared across collections, how to avoid key collisions?</p><ul><li><p>A) Pongo manages key prefixing internally (<code>{dbName}:{collectionName}:{id}</code>). The cache interface works with plain string keys, namespacing is Pongo&#8217;s concern.</p></li><li><p>B) Key is just the document <code>_id</code>. Simpler but only safe with per-collection instances.</p></li></ul><p><strong>Answer:</strong> A, definitely.</p><h3>Q6: Cache invalidation and consistency with optimistic concurrency</h3><p><strong>Question:</strong> How should cache interact with different operations?</p><ol><li><p>Successful writes &#8594; update/evict cache. Clear.</p></li><li><p>Concurrency errors &#8594; evict stale entry. Clear.</p></li><li><p>Reads with <code>_id</code> filter &#8594; cache hit or miss-then-populate. Clear.</p></li><li><p><code>handle</code> method &#8212; should the read inside <code>handle</code> use the cache (risking more concurrency errors on stale data) or always go to DB?</p></li></ol><p><strong>Answer:</strong> Definitely use cache. We could consider disabling cache through handle options.</p><p><strong>Claude&#8217;s thoughts:</strong> Use cache by default &#8212; optimistic concurrency is the safety net. If cached version is stale, write fails, we evict, caller can retry. Adding <code>skipCache?: boolean</code> to <code>HandleOptions</code> is a clean escape hatch for high-contention cases.</p><h3>Q7: Should <code>skipCache</code> be available on read methods too?</h3><p><strong>Question:</strong> Should <code>skipCache</code> also be available on <code>findOne</code> and other read methods for symmetry, or keep it <code>handle</code>-only?</p><p><strong>Answer:</strong> Good point, having <code>skipCache</code> on find methods (also <code>findMany</code> etc.) would be good.</p><p><strong>Claude&#8217;s thoughts:</strong> Consistent API surface. Add <code>skipCache?: boolean</code> on the shared options type (e.g. <code>CollectionOperationOptions</code>) so it&#8217;s available everywhere without repeating yourself.</p><h3>Q8: Batch operations on <code>handle</code> &#8212; signature and handler shape</h3><p><strong>Question:</strong> You want <code>handle</code> to accept an id or array of ids. Two design choices:</p><ol><li><p>Overloaded signature (<code>handle(id: string | string[], ...)</code>) vs separate method (<code>handleMany</code>)?</p></li><li><p>Handler shape for batch: A) still receives/returns one document at a time (Pongo batches DB ops internally) or B) receives/returns full array?</p></li></ol><p>Also noted: since PongoDocuments always have <code>_version</code>, if someone provides <code>expectedVersion</code> we could compare it against the cached version and decide if it&#8217;s stale without hitting the DB.</p><p><strong>Answer:</strong> Option A for handler shape. <code>handleMany</code> would be consistent with other APIs, but single id or array (<code>string | string[]</code>) is clean enough.</p><p><strong>Claude&#8217;s thoughts:</strong> Version comparison in cache is a cheap short-circuit &#8212; if cached <code>_version</code> doesn&#8217;t match <code>expectedVersion</code>, we know it&#8217;s stale without a round trip. On signature: TypeScript overloads can narrow the return type (<code>string</code> &#8594; single result, <code>string[]</code> &#8594; array). <code>handle</code> is a higher-level orchestration method, not direct CRUD, so a union input feels natural. Go with <code>handle(id: string | string[], handler, options?)</code>.</p><h3>Q9: Defaults &#8212; max entries, TTL, enabled by default?</h3><p><strong>Question:</strong> What defaults to set? Proposed: <code>max: 1000</code>, <code>ttl: 60_000</code> (1 min), and should cache be enabled by default or opt-in?</p><p><strong>Answer:</strong> Stick to recommended lru-cache defaults for now. Enable cache by default.</p><p><strong>Claude&#8217;s thoughts:</strong> Enabling by default works because: (1) optimistic concurrency is always the safety net, (2) it&#8217;s document-by-id only so invalidation is straightforward, (3) users can set <code>'disabled'</code>. <code>lru-cache</code> requires <code>max</code> to be set (no default) and has no default TTL. Suggested: <code>max: 1000</code>, no TTL (rely on LRU eviction + invalidation on writes/concurrency errors) &#8212; most lru-cache-aligned.</p><h3>Q10: TTL or no TTL as staleness backstop?</h3><p><strong>Question:</strong> No TTL means cached entries live until LRU-evicted or invalidated by a Pongo write. If something updates the DB outside this Pongo instance (another service, direct SQL), the cache won&#8217;t know. Options:</p><ul><li><p>A) No TTL, accept the trade-off &#8212; OC catches it on next write.</p></li><li><p>B) Set a conservative default TTL (e.g., 5 minutes) as a staleness bound.</p></li></ul><p><strong>Answer:</strong> Fine with using TTL and sometimes hitting OC issue. OC assumes rare conflicts.</p><h3>Q11: Where does cache integration hook into existing code?</h3><p><strong>Question:</strong> Two approaches for minimal invasiveness:</p><ol><li><p>Wrap at the <code>pongoCollection</code> level &#8212; intercept operations inside the factory function, driver/SQL layers untouched.</p></li><li><p>Decorator/wrapper pattern &#8212; <code>cachedPongoCollection</code> wraps <code>PongoCollection</code>, adds cache logic around delegation.</p></li></ol><p><strong>Answer:</strong> Leaning more to option 1. Thinks it&#8217;ll be easier to make observability precise eventually.</p><p><strong>Claude&#8217;s assessment:</strong> Agreed with option 1. Cache decisions depend on internal state (whether filter targets <code>_id</code>, document <code>_version</code>, write success vs concurrency error). A wrapper would need to re-inspect all of that from the outside. For observability, cache hit/miss logic next to the actual DB call enables precise metrics in context. Pongo&#8217;s factory functions already compose behavior internally (transactions, schema migration) &#8212; cache is another concern at the same level. A decorator would duplicate filter-inspection logic and add indirection that doesn&#8217;t carry its weight.</p><h3>Q12: Cache interaction with transactions</h3><p><strong>Question:</strong> How should cache interact with transactions?</p><ol><li><p>Should writes within a transaction update the cache immediately, or only after commit?</p></li><li><p>Should reads within a transaction check the cache?</p></li></ol><p><strong>Answer:</strong></p><ol><li><p>After commit.</p></li><li><p>We should use cache, but allow skipping it on transaction or session options. We can also allow setting cache settings per session.</p></li></ol><p><strong>Claude&#8217;s thoughts:</strong> Reads within a transaction benefiting from cache is more aggressive but logical &#8212; most transactions are short-lived. The narrow case: <code>insertOne</code> then <code>findOne</code> same <code>_id</code> within a transaction &#8212; cache won&#8217;t have the uncommitted insert, falls through to DB, gets the right answer anyway. No correctness issue, just no perf benefit for that case. Per-session cache settings fit the cascading pattern (e.g., <code>startSession({ cache: 'disabled' })</code> for batch migrations). Proposed cascade priority: client &#8594; db &#8594; collection &#8594; session &#8594; per-operation <code>skipCache</code>.</p><h3>Q13: Cascade priority &#8212; does session override collection?</h3><p><strong>Question:</strong> Does the cascade priority <strong>client &#8594; db &#8594; collection &#8594; session &#8594; per-operation </strong><code>skipCache</code> feel right? Session overriding collection is the non-obvious part &#8212; the argument being that session is the most immediate runtime context, while collection is structural config. Or would you prefer collection always wins over session?</p><p><strong>Answer:</strong> That&#8217;s fine. Session should precede collection, as normally you create session to e.g. share connection, and join the number of operations logically, so it&#8217;d make sense actually to also do it to e.g. override settings like caching.</p><p><strong>Claude&#8217;s thoughts:</strong> Session as logical grouping of operations with shared context (connection, transaction) &#8212; overriding cache settings at that level is extending the same concept. Bulk import in a session? Set cache disabled once on the session instead of <code>skipCache</code> on every call. Final cascade: client &#8594; db &#8594; collection &#8594; session &#8594; per-operation.</p><h3>Q14: Cache provider error handling</h3><p><strong>Question:</strong> If the cache provider throws (e.g., Redis connection drops), should Pongo:</p><ul><li><p>A) Treat it as a cache miss &#8212; fall through to DB silently, maybe log a warning. Cache failures never break the application.</p></li><li><p>B) Propagate the error &#8212; let the caller deal with it.</p></li></ul><p><strong>Answer:</strong> A &#8212; yes, when we add observability to Pongo, we need to make it also a metric and trace event/attribute/log entry.</p><p><strong>Claude&#8217;s thoughts:</strong> Cache is an optimization, not a correctness requirement. Silent degradation now, observable degradation when the observability layer lands.</p><h3>Q15: Cache warming, events/hooks, and delete operations</h3><p><strong>Question:</strong> Three remaining areas:</p><ol><li><p>Cache warming &#8212; should Pongo support pre-populating the cache on startup?</p></li><li><p>Cache events/hooks &#8212; should users be able to listen to cache hits/misses/evictions?</p></li><li><p>Delete operations &#8212; <code>deleteOne</code>/<code>deleteMany</code> should evict from cache. Anything non-obvious there?</p></li></ol><p><strong>Answer:</strong></p><ol><li><p>For now keep it as a future concept, or define pluggable strategy for users.</p></li><li><p>You can add the basic hook to the Pongo Cache wrapper. Shouldn&#8217;t be that hard to just call some function.</p></li><li><p>Yes, they should evict if they can, we can delete (for now) when <code>deleteMany</code> deletes by ids.</p></li></ol><p><strong>Claude&#8217;s thoughts:</strong> Cache warming deferred &#8212; can add a strategy hook later. Basic event callbacks (onHit, onMiss, onEvict, onError) on the cache wrapper are low-cost and useful for debugging even before full observability. Delete eviction scoped to id-based deletes for now &#8212; filter-based <code>deleteMany</code> doesn&#8217;t evict (no way to know which ids were affected without querying first).</p><div><hr></div><h2>Interactive Rubber Ducking result</h2><h3>Pongo 2nd Level Cache &#8212; Specification</h3><h3>Overview</h3><p>Add a document-level (by <code>_id</code>) 2nd level cache to Pongo with a pluggable provider interface. Ships with an in-memory provider backed by <code>lru-cache</code>. Enabled by default. Designed to be minimally invasive to existing code.</p><h3>Cache Interface</h3><h4><code>PongoCacheProvider&lt;T&gt;</code></h4><p>A thin, Pongo-owned interface. No coupling to any third-party API.</p><pre><code><code>type MaybePromise&lt;T&gt; = T | PromiseLike&lt;T&gt;;

interface PongoCacheProvider {
  get(key: string): MaybePromise&lt;PongoDocument | null | undefined&gt;;
  set(key: string, value: PongoDocument, options?: { ttl?: number }): MaybePromise&lt;void&gt;;
  delete(key: string): MaybePromise&lt;void&gt;;
  getMany(keys: string[]): MaybePromise&lt;(PongoDocument | null | undefined)[]&gt;;
  setMany(entries: { key: string; value: PongoDocument; ttl?: number }[]): MaybePromise&lt;void&gt;;
  deleteMany(keys: string[]): MaybePromise&lt;void&gt;;
  clear(): MaybePromise&lt;void&gt;;
}</code></code></pre><ul><li><p><code>MaybePromise</code> return types: sync providers (in-memory) return values directly, async providers (Redis) return Promises. <code>await</code> handles both transparently.</p></li><li><p>Batch methods (<code>getMany</code>, <code>setMany</code>, <code>deleteMany</code>) are first-class. Default in-memory implementation may loop internally, but the interface allows optimized batch ops for external providers.</p></li><li><p><code>clear()</code> is internal/advanced &#8212; not exposed to typical users. When sharing a cache instance across collections, scoping is handled via key prefixing by Pongo, not by the provider.</p></li></ul><h4>Cache key strategy</h4><p>Pongo manages key prefixing internally: <code>{dbName}:{collectionName}:{documentId}</code>.</p><p>The cache provider works with plain string keys &#8212; namespacing is Pongo&#8217;s concern, not the provider&#8217;s.</p><h4>Event hooks</h4><p>The Pongo cache wrapper supports basic callbacks:</p><ul><li><p><code>onHit?(key: string): void</code></p></li><li><p><code>onMiss?(key: string): void</code></p></li><li><p><code>onEvict?(key: string): void</code></p></li><li><p><code>onError?(error: unknown, operation: string): void</code></p></li></ul><p>These are optional and intended for debugging and future observability integration.</p><h3>Configuration</h3><h4><code>CacheConfig</code></h4><pre><code><code>type CacheConfig = {
 type: string;               // e.g., 'in-memory', 'redis', etc.
 max?: number;               // max entries (general param, cascades)
 ttl?: number;               // TTL in ms (general param, cascades)
  // type-specific options live here too, keyed by type
 [key: string]: unknown;
} | 'disabled';</code></code></pre><p>Three states:</p><ul><li><p><code>undefined</code> &#8212; inherit from parent level</p></li><li><p><code>'disabled'</code> &#8212; explicitly turn off caching at this level</p></li><li><p><code>CacheConfig</code> object &#8212; explicit configuration</p></li></ul><h4>Cascading configuration</h4><p>Cache config can be set at multiple levels. Each level inherits from its parent unless explicitly overridden:</p><p><strong>client &#8594; db &#8594; collection &#8594; session &#8594; per-operation</strong></p><p>Inheritance rules:</p><ul><li><p>General params (<code>max</code>, <code>ttl</code>) cascade down.</p></li><li><p>If a lower level switches <code>type</code>, type-specific params reset to defaults (not inherited from parent).</p></li><li><p>Session overrides collection &#8212; session is a logical grouping of operations, natural place to override runtime behavior (e.g., disable cache for a bulk import).</p></li><li><p>Per-operation <code>skipCache?: boolean</code> is the most granular escape hatch.</p></li></ul><h4>Passing a cache instance</h4><p>Users can provide either:</p><ul><li><p><strong>Settings</strong> &#8212; Pongo creates and manages the cache provider.</p></li><li><p><strong>A pre-built cache provider instance</strong> &#8212; Pongo uses it directly.</p></li></ul><p>If settings are the same across multiple collections, Pongo can reuse the same provider instance internally. When a user passes an instance, multiple collections can explicitly share one cache.</p><h4>Defaults</h4><ul><li><p><strong>Enabled by default</strong></p></li><li><p><code>max</code>: follow <code>lru-cache</code> recommended defaults (1000)</p></li><li><p><code>ttl</code>: follow <code>lru-cache</code> recommended defaults</p></li><li><p>Default provider: in-memory (<code>lru-cache</code>)</p></li></ul><h3>Integration points</h3><h4>Where: <code>pongoCollection</code> factory function</h4><p>Cache logic is added directly inside <code>pongoCollection</code>, not as an external decorator/wrapper. This gives cache operations access to internal state (filter inspection, <code>_version</code>, write outcomes) and keeps observability precise.</p><h4>Read operations</h4><p><code>findOne</code><strong>:</strong></p><ul><li><p>If the filter targets <code>_id</code>, check cache first.</p></li><li><p>Cache hit &#8594; return cached document.</p></li><li><p>Cache miss &#8594; query DB, populate cache, return.</p></li><li><p><code>skipCache?: boolean</code> option available.</p></li></ul><p><code>findMany</code><strong> / other query methods:</strong></p><ul><li><p>If the filter is a list of <code>_id</code> values, check cache for each.</p></li><li><p>Return cached hits, query DB for misses, populate cache with DB results.</p></li><li><p>Non-<code>_id</code> filters bypass cache entirely (cache is by-id only).</p></li></ul><h4>Write operations</h4><p><code>insertOne</code><strong>:</strong></p><ul><li><p>After successful insert, put the document into cache.</p></li></ul><p><code>insertMany</code><strong>:</strong></p><ul><li><p>After successful insert, put all documents into cache.</p></li></ul><p><code>updateOne</code><strong> / </strong><code>updateMany</code><strong> / </strong><code>replaceOne</code><strong>:</strong></p><ul><li><p>After successful write, update the cache entry with the new document state.</p></li></ul><p><code>deleteOne</code><strong>:</strong></p><ul><li><p>After successful delete, evict from cache.</p></li></ul><p><code>deleteMany</code><strong>:</strong></p><ul><li><p>If deleting by ids, evict those ids from cache.</p></li><li><p>Filter-based <code>deleteMany</code> does not evict (no way to know affected ids without extra query). Future improvement possible.</p></li></ul><h3>Optimistic concurrency</h3><ul><li><p>On concurrency error (version mismatch), <strong>evict the stale entry</strong> from cache. This is critical &#8212; a concurrency error means someone else updated the record (e.g., from another node), so the cached version is stale.</p></li><li><p>If a caller provides <code>expectedVersion</code> and the cached document has <code>_version</code>, compare them in memory. If they don&#8217;t match, we know it&#8217;s stale without hitting the DB &#8212; cheap short-circuit.</p></li></ul><h4><code>handle</code> method</h4><ul><li><p>Accepts <code>id: string | string[]</code> (overloaded: string returns single result, string[] returns array).</p></li><li><p>Read phase uses cache by default.</p></li><li><p>Write phase updates/evicts cache based on outcome.</p></li><li><p><code>skipCache?: boolean</code> available in handle options.</p></li><li><p>For batch: loads all ids from cache, fetches misses from DB in one query, processes handler per document, batch writes to DB, updates cache after commit.</p></li></ul><h4>Transactions</h4><ul><li><p><strong>Writes update cache only after commit.</strong> Not during the transaction &#8212; uncommitted data should not enter the cache.</p></li><li><p><strong>Reads within a transaction check cache</strong> by default (most transactions are short-lived, cached data is probably valid). If the transaction modified a document and then reads it, cache miss falls through to DB which returns the correct uncommitted state.</p></li><li><p>Cache can be disabled per-session: <code>startSession({ cache: 'disabled' })</code>.</p></li><li><p>Per-session cache settings follow the cascade (session overrides collection).</p></li></ul><h3>Error handling</h3><p>Cache provider errors are <strong>swallowed</strong> &#8212; treated as cache misses. The application never fails because of a cache failure.</p><ul><li><p>Cache is an optimization, not a correctness requirement.</p></li><li><p>Errors trigger <code>onError</code> hook for debugging.</p></li><li><p>Future: errors will become metrics and trace events when Pongo&#8217;s observability layer lands.</p></li></ul><h3>Future considerations (out of scope for v1)</h3><ul><li><p><strong>Cache warming</strong>: pluggable strategy for pre-populating cache on startup. Interface hook defined but no default implementation shipped.</p></li><li><p><strong>Query-result caching</strong>: caching results of non-<code>_id</code> queries. Complex invalidation &#8212; deferred.</p></li><li><p><strong>Cross-node cache sharing</strong>: Redis/Memcached providers. The interface supports it; no provider shipped in v1.</p></li><li><p><strong>Filter-based </strong><code>deleteMany</code><strong> eviction</strong>: would require querying affected ids before delete.</p></li><li><p><strong>Observability integration</strong>: metrics (hit ratio, latency saved, eviction counts), trace attributes, log entries for cache operations.</p></li></ul><h3>Implementation approach</h3><ol><li><p>Define <code>PongoCacheProvider</code> interface and <code>CacheConfig</code> types.</p></li><li><p>Implement in-memory provider using <code>lru-cache</code>.</p></li><li><p>Build Pongo cache wrapper (key prefixing, event hooks, error swallowing).</p></li><li><p>Wire cascading config resolution (client &#8594; db &#8594; collection &#8594; session &#8594; per-op).</p></li><li><p>Integrate into <code>pongoCollection</code>: intercept <code>_id</code>-based reads, update cache on writes, evict on deletes and concurrency errors.</p></li><li><p>Extend <code>handle</code> to accept <code>string | string[]</code> with batch support.</p></li><li><p>Add <code>skipCache</code> to operation options.</p></li><li><p>Tests: unit tests for cache provider, integration tests for cache + collection operations, e2e tests for concurrency scenarios.</p></li></ol><div><hr></div><p>Cheers!</p><p>Oskar</p><p><strong>p.s, and yes, Nano Banana also did pretty well generating the cover of this post!</strong></p><p>p.s.2. <strong>Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to <a href="https://www.icrc.org/en/donate/ukraine">Red Cross</a>, <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian organisation</a> or <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">donate Ambulances for Ukraine</a>.</p>]]></content:encoded></item><item><title><![CDATA[The End of Coding? Wrong Question]]></title><description><![CDATA[Be careful what you wish for, because your wish may come true.]]></description><link>https://www.architecture-weekly.com/p/the-end-of-coding-wrong-question</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/the-end-of-coding-wrong-question</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 09 Mar 2026 10:54:24 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!7ks-!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdab4d70f-1e28-4ea4-9c02-1f84a903244d_800x500.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!7ks-!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdab4d70f-1e28-4ea4-9c02-1f84a903244d_800x500.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!7ks-!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdab4d70f-1e28-4ea4-9c02-1f84a903244d_800x500.png 424w, https://substackcdn.com/image/fetch/$s_!7ks-!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdab4d70f-1e28-4ea4-9c02-1f84a903244d_800x500.png 848w, https://substackcdn.com/image/fetch/$s_!7ks-!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdab4d70f-1e28-4ea4-9c02-1f84a903244d_800x500.png 1272w, https://substackcdn.com/image/fetch/$s_!7ks-!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdab4d70f-1e28-4ea4-9c02-1f84a903244d_800x500.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!7ks-!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdab4d70f-1e28-4ea4-9c02-1f84a903244d_800x500.png" width="800" height="500" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/dab4d70f-1e28-4ea4-9c02-1f84a903244d_800x500.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:500,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:319978,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://www.architecture-weekly.com/i/190367899?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdab4d70f-1e28-4ea4-9c02-1f84a903244d_800x500.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!7ks-!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdab4d70f-1e28-4ea4-9c02-1f84a903244d_800x500.png 424w, https://substackcdn.com/image/fetch/$s_!7ks-!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdab4d70f-1e28-4ea4-9c02-1f84a903244d_800x500.png 848w, https://substackcdn.com/image/fetch/$s_!7ks-!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdab4d70f-1e28-4ea4-9c02-1f84a903244d_800x500.png 1272w, https://substackcdn.com/image/fetch/$s_!7ks-!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdab4d70f-1e28-4ea4-9c02-1f84a903244d_800x500.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><strong>Be careful what you wish for, because your wish may come true.</strong> </p><p>What LLMs revealed is how many people in our industry don't like to code. <br><br>It's intriguing that now they claim and showcase what they "built with Claude", whereas usually that means they generated a PoC.<br><br>It's funny, as people still focus on how they're building, so it's all about the code. And if that's the message sent outside, together with the thought that LLMs are already better than "average coder Joe", then the logical follow-up question is: why do we need those humans in the loop?<br><br><strong>I think that most people look at the forest and see trees.</strong> The current way of working with LLMs is not scalable. It's transition phase. I can't imagine calling myself an engineer and doing ONLY stringly-typed development with chat or markdown.<br><br>What I can imagine is getting help from it, and using those tools as a help for research, for generating the OUTPUT, still keeping me responsible for the OUTCOME.<br><br>Why am I saying that we're in the transition phase? As prompting in our natural language is not precise, it's verbose, and adding a translation layer between our freeform prompts and programming languages is a waste of time (and tokens, which LLM vendors love).<br><br><strong>I think that we'll still be coding, but with some other layer, as LLMs are good with structured input, like programming languages.</strong> So we might need other programming languages than we have atm. Might we need different tools to evaluate LLMs' output to make it deterministic? Might we need a different approach for engineering to make it scalable? Might we need more?<br><br>Still, I don't see those discussions. <br><br>I mostly see: noise and celebrities who don't code showing their beautiful PoCs. And doing a mic drop about the end of coding. Just like PoC represents the whole Software Development Life Cycle.<br><br><strong>So, will we code or not?</strong> <br><br>If the answer is yes, let's talk about what's next, then let's discuss how and when.<br><br>If the answer is not, then let's talk about how our job will change, ask if it's still engineering, etc.<br><br>Let's try to make our discussions more precise, more focused on the essence, and avoid them from becoming just a bunch of anecdotal evidence. <br><br>Noone outside our industry cares how we code unless it changes the cost, quality, or delivery time.<br><br>Let's discuss the impact that matters, rather than just the amount of code we produce (or not).</p><p>Now, guess who I am quoting:</p><blockquote><p>Imagine you&#8217;re a software application developer. Your programming language of choice (or the language that&#8217;s been foisted on you) is Java or Typescript . You&#8217;ve been at this for quite a while and your job doesn&#8217;t seem to be getting any easier. </p><p>These past few years you&#8217;ve seen the growth of multiple incompatible  architectures. Now you&#8217;re supposed to cope with all this and make your applications work in a distributed client-server environment. The growth of the Internet, the World-Wide Web, and &#8220;electronic commerce&#8221; haveintroduced new dimensions of complexity into the development process.</p><p>The tools you use to develop applications don&#8217;t seem to help you much. You&#8217;re still coping with the same old problems; the fashionable new object-oriented techniques seem to have added new problems without solving the old ones.</p><p>You say to yourself and your friends, &#8220;There has to be a better way&#8221;!</p><p><strong>The Better Way is Here Now</strong></p><p>Now there is a better way&#8212;it&#8217;s our new model. Imagine, if you will, this development world&#8230;</p><p>&#8226; It&#8217;s still dead simple.</p><p>&#8226; Your development cycle is much faster.</p><p>&#8226; Your applications can be created across multiple platforms. Write your spec once, and you never need to port them&#8212;they will be recreated if you without your hand-rolled modification on multiple operating systems and hardware architectures.</p><p>&#8226; Your applications are adaptable to changing environments.</p><p>&#8226; Your end users can trust that your applications are secure, and you can use protection against viruses and tampering through security scans.</p><p>You don&#8217;t need to dream about these features. They&#8217;re here now. </p></blockquote><p>And also this from another source:</p><blockquote><p>When I started interviewing programmers in 2005, I would generally let them use any language or tool they wanted to solve the coding problems I gave them. 99% of the time, they chose Java.</p><p>Nowadays, they tend to choose LLMs.</p><p>Now, don&#8217;t get me wrong: there&#8217;s nothing wrong with LLM as an implementation tool.</p><p>Wait a minute, I want to modify that statement. I&#8217;m not claiming, <em>in this particular article,</em> that there&#8217;s anything wrong with LLM as an implementation tool. There are lots of things wrong with it but those will have to wait for a different article.</p><p>Instead what I&#8217;d like to claim is that LLM is not, generally, a hard enough programming tool that it can be used to discriminate between great programmers and mediocre programmers. It may be a fine tool to work in, but that&#8217;s not today&#8217;s topic. I would even go so far as to say that the fact that LLMs aere not hard enough is a feature, not a bug, but it does have this one problem.</p></blockquote><p>Well, I cheated you, but only a bit. I changed &#8220;Java&#8221; to &#8220;LLM &#8221; and cut some phrases.</p><p>The first one is from <a href="https://www.stroustrup.com/1995_Java_whitepaper.pdf">&#8220;The Java Language Environment&#8221;</a> by Sun Microsystems, introducing Java in 1995. </p><p>The second one was from Joel Spolsky&#8217;s <a href="https://www.joelonsoftware.com/2005/12/29/the-perils-of-javaschools-2/">&#8220;The Perils of JavaSchools&#8221;</a> article, written in 2001.</p><p>Let me be clear. I&#8217;m not trying to do grandpa talk on the old days and claim that it&#8217;s the same old thing.</p><p>What I&#8217;m trying to say is that we were continuously introducing new abstractions into our development cycle to scale it. By scale, I mean: getting more people to deliver more code. Even Java was invented for precisely this goal. Yes, the one that&#8217;s together with craftsmanship madness presented as &#8220;the enterprisy complex environment&#8221;. In the early days, it was just said that it&#8217;ll make us dumber.</p><p><strong>The goal of abstraction is not to gatekeep but to allow us to reduce cognitive load.</strong> We invented new languages to help us, but then added more components like a distributed environment, multiregion, because of globalisation. For the same reasons, we use cloud-native tooling so we don't have to deal with it.</p><p>Some of the architecture and security tools were commoditised by the cloud. We don&#8217;t need to think about much stuff we had to do before.</p><p>Will Claude-native do the same?</p><p>We don&#8217;t need to learn Assembler, C++, Lisp anymore; we have lost a lot of mechanical sympathy. We deal with higher abstractions, but we still engineer solution, we still code, is it a different coding? It is. Is it better or worse? Well, it is how it is. As Gerald Weinberg said:</p><blockquote><p>Things are the way they are because they got that way</p></blockquote><p>And now the question is whether we&#8217;re fine with the way we&#8217;re doing stuff, and where we're getting to. </p><p>Personally, I don&#8217;t think that stringly-typed markdown or chat-based design will be the thing in the future.</p><p>Knowing how skilled we were always with:<br>- breaking down tasks into smaller chunks,<br>- writing precisely what we had in mind,<br>- thinking before doing,<br>- waging tradeoffs,<br><br>I'm optimistic about the Spec-Driven Design idea. It's going to be great.</p><p>Not.</p><p>If we want to call ourselves engineers, we need to put more structure and determinism.</p><p>I agree that reviewing all code generated by the GenAI is not sustainable.</p><p>But I also don&#8217;t think that generating tons of code is, in general, sustainable.</p><p>With the current state of the art we have, sure, that&#8217;s some solution to just generate based on the tools we have.</p><p>But let&#8217;s start to think what&#8217;s next.</p><p>Simon Wardley <a href="https://www.linkedin.com/feed/update/urn:li:activity:7426977059677077504/">brought an interesting point</a>:</p><blockquote><p>That said, it is fine for an entire culture to decide that producing outputs matters more than understanding mechanisms. You only have to compare the practical engineering of the Roman Empire and the loss of inquiry from science in the Hellenistic age to see this. When the Roman Empire collapsed, the practical knowledge embedded in those institutions (how to maintain aqueducts, how to produce certain grades of concrete) was lost remarkably quickly. Not because people decided to forget it, but because the knowledge was procedural, embedded in chains of practice rather than recorded as transferable understanding. When the chains of practice broke, that embedded knowledge went with them. We had to rediscover the art of inquiry (i.e. Science) to bring them back.</p></blockquote><p>So if we don&#8217;t code, how do we hone our skills? How will newcomers from LLMSchools (using Joel&#8217;s terms) be able to decide whether something is wrong or right? I don&#8217;t think that you can be good on something without doing it.</p><p>The paradox with code is that it&#8217;s not an asset; it&#8217;s a liability. Some say that code doesn&#8217;t matter, only proper design. But how do you define &#8220;proper design&#8221;? Yes, code style doesn&#8217;t matter as long as it works as expected. But&#8230;</p><p>But code eventually matters, as that&#8217;s the source of truth for what&#8217;s on production. As Alberto Brandolini said:</p><blockquote><p>It's <em>developers</em>' (mis)understanding, <em>not</em> domain experts' knowledge, that gets released in production.</p></blockquote><p>Now, it&#8217;s the developers&#8217; and LLMs&#8217; misunderstandings that are deployed to production, not the expert&#8217;s knowledge. Neither the markdown spec.</p><p>And coding is <em>just one danger.</em></p><p><strong>Outsourcing thinking is an even more dangerous path, as:</strong></p><ul><li><p>If LLMs are doing everything, then again, what are humans for? Aren&#8217;t we cutting the branch on which we&#8217;re sitting?</p></li><li><p>LLMs are statistical parrots. They repeat the most possible answer. Which means mediocre. This can still be fine enough for many cases, but for those we want to make a difference for? Definitely not.</p></li><li><p>Just like we&#8217;re losing our coding skills by not doing them, we&#8217;re losing design skills by not practising them.</p></li></ul><p>Of course, whatever happens, LLMs will stay with us. How and where it&#8217;s hard to say. Unless you have a Magic 8 Ball of 100% correct predictions. I don&#8217;t.</p><p>That&#8217;s why I&#8217;d like our industry to finally start mature discussions on the real impact. I would like us to stop acting like children, bragging about generating code and then claiming that code doesn&#8217;t matter.</p><p>I&#8217;d like to think about how to reshape our SDLC process and make it sustainable.</p><p>I&#8217;d like us to think about what tools we need, and how to change what we have.</p><p>If we won&#8217;t finally start to do it, then things will be the way they are because they got that way. </p><p>And it might not be what we wished for.</p><p>Cheers</p><p>Oskar</p><p><strong>p.s.</strong> to kinda prove that I&#8217;m more sceptic and pragmatic than hater, I recently started playing with building an Agent with Emmett to better understand those tools. If you&#8217;d like to read about the findings and honest thoughts I have while doing it, please comment!</p><p>p.s.2. <strong>Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to <a href="https://www.icrc.org/en/donate/ukraine">Red Cross</a>, <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian organisation</a> or <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">donate Ambulances for Ukraine</a>.</p><p></p>]]></content:encoded></item><item><title><![CDATA[Parse, Don't Guess]]></title><description><![CDATA[Last time, I shared with you how sneaky I was on transaction handling.. Today, the opposite: I&#8217;ll tell you how I fixed the issue when I tried to be too sneaky. I already told you that Sneaky Code Bites Back. The moral? Do as I tell, not how I do.]]></description><link>https://www.architecture-weekly.com/p/parse-dont-guess</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/parse-dont-guess</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 02 Mar 2026 17:18:15 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!Rc3Y!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9abd1e13-5804-495b-bbe8-a3cca0867aca_800x420.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Rc3Y!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9abd1e13-5804-495b-bbe8-a3cca0867aca_800x420.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Rc3Y!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9abd1e13-5804-495b-bbe8-a3cca0867aca_800x420.png 424w, https://substackcdn.com/image/fetch/$s_!Rc3Y!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9abd1e13-5804-495b-bbe8-a3cca0867aca_800x420.png 848w, https://substackcdn.com/image/fetch/$s_!Rc3Y!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9abd1e13-5804-495b-bbe8-a3cca0867aca_800x420.png 1272w, https://substackcdn.com/image/fetch/$s_!Rc3Y!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9abd1e13-5804-495b-bbe8-a3cca0867aca_800x420.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Rc3Y!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9abd1e13-5804-495b-bbe8-a3cca0867aca_800x420.png" width="800" height="420" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/9abd1e13-5804-495b-bbe8-a3cca0867aca_800x420.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:420,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;cover&quot;,&quot;title&quot;:&quot;cover&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="cover" title="cover" srcset="https://substackcdn.com/image/fetch/$s_!Rc3Y!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9abd1e13-5804-495b-bbe8-a3cca0867aca_800x420.png 424w, https://substackcdn.com/image/fetch/$s_!Rc3Y!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9abd1e13-5804-495b-bbe8-a3cca0867aca_800x420.png 848w, https://substackcdn.com/image/fetch/$s_!Rc3Y!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9abd1e13-5804-495b-bbe8-a3cca0867aca_800x420.png 1272w, https://substackcdn.com/image/fetch/$s_!Rc3Y!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9abd1e13-5804-495b-bbe8-a3cca0867aca_800x420.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><a href="https://event-driven.io/en/cloudflare_d1_transactions_and_tradeoffs/">Last time, I shared with you how sneaky I was on transaction handling.</a>. Today, the opposite: I&#8217;ll tell you how I fixed the issue when I tried to be too sneaky. I already told you that <a href="https://www.architecture-weekly.com/p/sneaky-code-bites-back">Sneaky Code Bites Back</a>. The moral? Do as I tell, not how I do.</p><p>In some environments, we&#8217;re spoiled. We&#8217;re getting a lot from a Base Class Library or standard frameworks, so we stop thinking that those issues can exist. For instance, serialisation. Do you know how many data types JSON has? 6. Six. Sze&#347;&#263;.</p><p>Exactly those:</p><ul><li><p>string,</p></li><li><p>number,</p></li><li><p>boolean,</p></li><li><p>object,</p></li><li><p>array,</p></li><li><p>and (TADA!) null.</p></li></ul><p>What about number precision and size? It is. That&#8217;s what I can tell you, but it&#8217;s not enough, e.g., to keep big int/long, etc. What about Dates? Also, there are none. I wrote about it longer <a href="https://event-driven.io/en/fun_with_json_serialisation/">here or how much fun that brings</a>.</p><p>If you use statically typed languages and runtimes like C#, Java, etc., your serialiser can, in addition to parsing, perform mapping and, sometimes, validation. And it can also be tricky, as nicely <a href="https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/">Alexis King put in her &#8220;Parse, don&#8217;t validate&#8221;</a>.</p><p>If you&#8217;re in a dynamic environment, like JavaScript, then you&#8217;re left with parsing and explicit mapping afterwards. What about TypeScript? Same case, types are only used during compilation, then erased and not visible at runtime. So, the place where we do parsing.</p><p>Because JSON was defined a long time ago, JavaScript moved on and now supports bigints (Big Integers) and Dates natively (what an achievement!), which created a gap I wanted to fill.</p><p>As you know from my previous articles (e.g. <a href="https://event-driven.io/en/checkpointing_message_processing/">this one</a>), big integers are quite important in distributed processing. You can represent the position in log with them. Since your log may be quite long, regular numbers aren&#8217;t enough. Or they&#8217;re long enough, until they overflow, then they&#8217;re not anymore.</p><p>I&#8217;m using those bigint types extensively in internals in <a href="https://github.com/event-driven-io/emmett">Emmett</a> and <a href="https://github.com/event-driven-io/pongo">Pongo</a>. And I store them in JSONs. I store them as alphanumeric strings, because strings don&#8217;t have a max length (or at least I don&#8217;t know such).</p><p>So, for instance, an event payload can look like:</p><pre><code><code>{
  "type": "InvoiceIssued",
  "data": {
    "invoiceNumber": "123",
    "version": 1,
    "issuer": "John Doe",
    "issuedAt": "2026-02-23T14:07:20Z"
  },
  "metadata": {
    "streamPosition": "3",
    "globalPosition": "928391"
  }
}</code></code></pre><p>As you can see in the metadata stream and global positions, the values are bigints (even if they&#8217;re smaller than the maximum value), and data can also use bigints if the user decides to (e.g., invoice number).</p><p>And encoding data is simple: you convert it to a string, call it a day. But how to get it back?</p><p>And here&#8217;s where my struggles started. How do you know that someone intentionally used bigint when they just wanted to store a number as a string?</p><p>There are several options. The first one is: encode value.</p><p>We could store it, for instance, as:</p><ul><li><p>prefixed value: <strong>&#8220;_bigint:928391&#8221;</strong>. But then you need to find a prefix that will be unique enough not to cause conflicts,</p></li><li><p>nested object, e.g. <strong>{ &#8220;_kind&#8221;: &#8220;bigint&#8221;, value: &#8220;928391&#8221; }</strong>.</p></li></ul><p>Then, either based on the prefix or the object structure, we could automatically decode the value. Still, ther creates other issues, as the structure no longer matches the original value. If we&#8217;re just storing and retrieving, that shouldn&#8217;t be so bad, but&#8230; But remember that in <a href="https://github.com/event-driven-io/pongo">Pongo</a> I&#8217;m allowing the use of PostgreSQL and SQLite as document databases, supporting such queries:</p><pre><code><code>const invoices = pongoDb.collection&lt;Invoice&gt;("invoices");

const invoiceNumber = 123n;
const invoice = await invoices.findOne({ invoiceNumber });</code></code></pre><p>That gets translated into a <a href="https://www.architecture-weekly.com/p/postgresql-jsonb-powerful-storage">fancy JSONB SQL query</a>.</p><p>Of course, I could work around it by encoding the value, but&#8230; But I was lazy!</p><p>I decided to use a Get Out of Jail Free Card and just treat all strings with numbers as bigints. Sneaky. And it will get even sneakier.</p><p>In JavaScript, JSON.parse accepts a parameter that allows you to provide custom mapping logic. I decided to use it and check if the string is alphanumeric, and gulp, I&#8217;ve used a regular expression to parse it:</p><pre><code><code>const bigIntReviver: JSONReviver = (_key, value) =&gt; {
  if (typeof value === 'string' &amp;&amp; /^[+-]?\d+n?$/.test(value)) {
    return BigInt(value);
  }

  return value;
};</code></code></pre><p>Yes, it&#8217;s either DNS or Regex. Or both.</p><p>I <a href="https://www.architecture-weekly.com/p/typescript-migrates-to-go-whats-really">explained in another article</a> that JavaScript runtime doesn&#8217;t like where you do CPU-heavy computations.</p><p>Small Regex isn&#8217;t CPU-heavy, but if you consider that ther will be done for each string in each document or event you try to deserialise, and multiply that by the number of concurrent requests? That can cause the <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Execution_model">JavaScript event loop to freeze</a>.</p><p>What&#8217;s more, I plugged that automatically into <a href="https://node-postgres.com/">node-postgres driver</a> custom type handling, so each JSONB deserialization goes through it.</p><p>Again, not shit, Sherlock. I should have known it wasn&#8217;t the best choice, but I was busy trying to be sneaky at that moment.</p><p>Fortunately, a user, Dawid, benchmarked and noticed CPU freezes. It wasn&#8217;t catastrophic, but clearly needed a fix.</p><h2>The Shift</h2><p><strong>And here I had several options.</strong> I could keep hacking on the same idea- maybe replace the Regex with a simpler string check, still globally. Or I could just ignore bigints during deserialisation entirely, let them stay strings, call it a day, move on. Or I could apply the encoding I mentioned earlier, prefixed values, and nested objects. All of those would fix the performance issue. And all of those would be the same kind of wrong choice I already made: trying to solve a schema problem without the schema.</p><p>Because that&#8217;s the actual mistake here, not the Regex. The <em>pg</em> driver has no idea what your schema looks like. It doesn&#8217;t know that <em>&#8220;928391&#8221;</em> is a bigint and <em>&#8220;John Doe&#8221;</em> is a name. It doesn&#8217;t know that <em>&#8220;123&#8221;</em> is an invoice number (bigint!) and <em>&#8220;90210&#8221;</em> is a zip code (string!). I asked it to guess, and it guessed wrong, because there is no right guess at that level.</p><p>Enough is enough. I had been planning to do ther properly for a while, and the performance issue gave me the push I needed.</p><p><strong><a href="https://wiki.c2.com/?MakeItWorkMakeItRightMakeItFast">Old rule says: &#8220;Make it work, make it right, make it pretty&#8221;.</a></strong> I had <em>&#8220;make it work&#8221;</em> covered for a long time. Now it was time for <em>&#8220;make it right&#8221;</em>.</p><p>And honestly? It wasn&#8217;t that hard. Maybe because &#8220;make it work&#8221; came first, I already understood the problem well enough to see the shape of the solution.</p><p>In <a href="https://github.com/event-driven-io/Pongo/pull/149">Pongo</a>, I dropped the automatic bigint parsing from the driver entirely. If you want bigint or date parsing, you say so at the client level:</p><pre><code><code>const client = pongoClient({
  driver: databaseDriver,
  connectionString: postgresConnectionString,
  serialization: {
    options: {
      parseBigInts: true,
      parseDates: true,
    }
  },
});</code></code></pre><p>By default, strings stay strings. You opt in. I didn&#8217;t want to break things for users who don&#8217;t care about bigint precision or don&#8217;t have performance-sensitive workloads. The serializer became an explicit parameter passed down to each query, each collection, each operation- instead of a global that silently changed everything.</p><p><strong>That was the &#8220;make it right&#8221; part for Pongo.</strong> But disabling alone isn&#8217;t a solution, it&#8217;s a band-aid. Users who need bigints and dates still need a way to get them back after deserialisation. The question is: where does that conversion happen?</p><p>And that&#8217;s where upcasting comes in. Let me start with a simple example in <a href="https://github.com/event-driven-io/Pongo/pull/149">Pongo</a>, then build up.</p><p>Say you have a user document. In the database, dates are stored as ISO strings, and the version counter is a numeric string (because JSON). But in your application, you want proper Date objects and bigints:</p><pre><code><code>type UserDocStored = {
  name: string;
  createdAt: string;
  lastLogin: string;
};

type UserDoc = {
  name: string;
  createdAt: Date;
  lastLogin: Date;
};</code></code></pre><p>The upcast function does the conversion:</p><pre><code><code>const upcast = (doc: UserDocStored): UserDoc =&gt; ({
  name: doc.name,
  createdAt: new Date(doc.createdAt),
  lastLogin: new Date(doc.lastLogin),
});</code></code></pre><p>You wire it into the collection, and every read goes through it:</p><pre><code><code>const collection = pongoDb.collection&lt;UserDoc, UserDocStored&gt;(
  'users',
  {
    schema: { versioning: { upcast } },
  },
);</code></code></pre><p>What&#8217;s in the database:</p><pre><code><code>{ "name": "Alice", "createdAt": "2024-01-15T10:30:00.000Z", ... }</code></code></pre><p>What you get back:</p><pre><code><code>{ name: 'Alice', createdAt: Date, ... }</code></code></pre><p>That&#8217;s all. <em>new Date(str)</em> is cheap. Running a Regex against every string in the document is not. The CPU freeze Dawid spotted came from that check running millions of times at the driver level for every field on every concurrent request. With upcasting, the conversion runs only for the fields you declared, in a plain function, no Regex.</p><p>But ther is just type mapping - the simplest case. As I wrote about <a href="https://event-driven.io/en/fun_with_json_serialisation/">in my serialisation article</a>, the explicit mapping pattern is useful for much more than just fixing types. It&#8217;s the same pattern you need for schema versioning. It defines the stored schema and the application schema separately together with function to transform one into the other.</p><p>Let&#8217;s say business requirements changed. You now need to group user data differently: a <em>profile</em> object for identity data, and a <em>timestamps</em> object for temporal data. The V1 documents are flat. The new V2 shape is nested:</p><pre><code><code>type UserDocV1 = {
  name: string;
  createdAt: string;
  lastLogin: string;
};

type UserDocV2 = {
  profile: {
    name: string;
  };
  timestamps: {
    createdAt: Date;
    lastLogin: Date;
  };
};</code></code></pre><p>Ther isn&#8217;t just a type change like string-to-Date anymore. The structure itself is different. Flat fields became nested objects, and field names moved into sub-objects. And you have thousands of V1 documents already stored. You can&#8217;t migrate them all at once (or don&#8217;t want to, because it&#8217;s risky, and some consumers might still expect V1). But your application now expects V2.</p><h2>Compatibility FTW</h2><p>Ther is where backward and forward compatibility come in.</p><p><strong>Backward compatibility</strong> means: old data still works. V1 documents stored months ago need to be readable by the V2 code. The upcast handles ther. It reads the document in whatever shape it has and transforms it into V2.</p><p><strong>Forward compatibility</strong> means: new data doesn&#8217;t break old consumers. If you have another service or an older deployment that still reads the V1 format, it needs to keep working. The downcast handles ther. When storing V2 documents, it writes the V1 fields alongside the V2 fields, so older readers can still find what they expect.</p><p>Together:</p><pre><code><code>type StoredPayload = UserDocV1 &amp; UserDocV2;

const upcast = (doc: StoredPayload): UserDocV2 =&gt; ({
  profile: doc.profile ?? { name: doc.name },
  timestamps: {
    createdAt: new Date(doc.timestamps?.createdAt ?? doc.createdAt),
    lastLogin: new Date(doc.timestamps?.lastLogin ?? doc.lastLogin),
  },
});

const downcast = (doc: UserDocV2): StoredPayload =&gt; ({
  name: doc.profile.name,
  createdAt: doc.timestamps.createdAt.toISOString(),
  lastLogin: doc.timestamps.lastLogin.toISOString(),
  profile: doc.profile,
  timestamps: doc.timestamps,
});</code></code></pre><p>Look at the upcast: if the nested <em>profile</em> or <em>timestamps</em> fields exist (document was written by V2 code), it uses them. If they don&#8217;t exist (for the old V1 document), it falls back to the flat fields. One function handles both old and new documents: that&#8217;s backward compatibility.</p><p>And look at the downcast: it writes <em>name</em>, <em>createdAt</em>, <em>lastLogin</em> as flat string fields (V1 shape) alongside <em>profile</em> and <em>timestamps</em> (V2 shape). A service still reading V1 sees the flat fields and works fine. A service reading V2 sees the nested ones. That&#8217;s forward compatibility.</p><p>You wire both into the collection:</p><pre><code><code>const collection = pongoDb.collection&lt;UserDocV2, StoredPayload&gt;(
  'users',
  {
    schema: { versioning: { upcast, downcast } },
  },
);</code></code></pre><p>From here, your application code only deals with V2. The collection handles the translation in both directions:</p><pre><code><code>const v2Doc: UserDocV2 = {
  profile: { name: 'Alice' },
  timestamps: {
    createdAt: new Date('2024-01-15T10:30:00.000Z'),
    lastLogin: new Date('2024-06-20T14:45:00.000Z'),
  },
};

await collection.insertOne(v2Doc);</code></code></pre><p>What&#8217;s stored (downcasted, both shapes for compatibility):</p><pre><code><code>{
  "name": "Alice", 
  "createdAt": "2024-01-15T10:30:00.000Z",
  "lastLogin": "2024-06-20T14:45:00.000Z",
  "profile": { 
    "name": "Alice" 
  },
  "timestamps": { 
    "createdAt": "2024-01-15T10:30:00.000Z",
    "lastLogin": "2024-06-20T14:45:00.000Z" 
  } 
}</code></code></pre><p>Then you can read it back with:</p><pre><code><code>const doc = await collection.findOne({ ... });</code></code></pre><p>And get upcasted to V2 data in your application code:</p><pre><code><code>{
  "profile": { 
    "name": "Alice" 
  },
  "timestamps": { 
    "createdAt": "2024-01-15T10:30:00.000Z",
    "lastLogin": "2024-06-20T14:45:00.000Z" 
  } 
}</code></code></pre><p>Same collection, V1 and V2 documents coexisting. <em>insertMany</em>, <em>replaceOne</em>, <em>findOne</em>: all go through the upcast/downcast. No batch migration needed. You roll out the new code, and old documents are handled transparently.</p><p>There&#8217;s another thing the downcast gives you: querying remains backwards-compatible. Because the downcast writes the flat V1 fields alongside the nested V2 ones, a query like <em>collection.findOne({ name: &#8216;Alice&#8217; })</em> still works even though V2 code doesn&#8217;t use <em>name</em> directly anymore. The V1 field is there in the stored document. That matters if you have queries or indexes built against the old shape. They don&#8217;t break.</p><p>Now, for events, ther matters even more. In event sourcing, stored events are immutable- the log is append-only, and you don&#8217;t modify what was already written. I wrote about <a href="https://event-driven.io/en/simple_events_versioning_patterns/">versioning patterns</a> in more detail. The core idea is: your business evolves, your code evolves, your event schemas evolve, but the events in the store stay as they were. You can&#8217;t go back and rewrite them (well, you can, but you really shouldn&#8217;t). Upcasting is how you bridge the gap.</p><p>For <a href="https://github.com/event-driven-io/emmett/pull/292">Emmett</a>, the same pattern works at the event store level. You define the stored shape (what JSON gives you from the database) and the application shape (what your code works with):</p><pre><code><code>type ShoppingCartOpenedFromDB = Event&lt;
  'ShoppingCartOpened',
  { openedAt: string; loyaltyPoints: string }
&gt;;

type ShoppingCartOpened = Event&lt;
  'ShoppingCartOpened',
  { openedAt: Date; loyaltyPoints: bigint }
&gt;;</code></code></pre><p>And an upcast that handles each event type:</p><pre><code><code>const upcast = (event: Event): ShoppingCartEventWithDatesAndBigInt =&gt; {
  switch (event.type) {
    case 'ShoppingCartOpened': {
      const e = event as ShoppingCartOpenedFromDB;
      return {
        ...e,
        data: {
          openedAt: new Date(e.data.openedAt),
          loyaltyPoints: BigInt(e.data.loyaltyPoints),
        },
      };
    }
    case 'ShoppingCartConfirmed': {
      const e = event as ShoppingCartConfirmedFromDB;
      return {
        ...e,
        data: {
          confirmedAt: new Date(e.data.confirmedAt),
          totalCents: BigInt(e.data.totalCents),
        },
      };
    }
    default:
      return event as ShoppingCartEventWithDatesAndBigInt;
  }
};</code></code></pre><p>You pass it when reading a stream:</p><pre><code><code>const { state } = await eventStore.aggregateStream&lt;
  ShoppingCartState,
  ShoppingCartEventWithDatesAndBigInt
&gt;(shoppingCartId, {
  evolve: evolveState,
  initialState,
  read: { schema: { versioning: { upcast } } },
});</code></code></pre><p>Or in a command handler:</p><pre><code><code>const handle = CommandHandler&lt;ShoppingCart, ShoppingCartEvent&gt;({
  evolve,
  initialState: () =&gt; ({ ... }),
  schema: { versioning: { upcast: upcastDatesAndBigInt } },
});</code></code></pre><p>The difference with events is that you can&#8217;t update them in place. For documents, you have both directions: upcast on read, downcast on write. For events, upcasting is the main tool because the event store is append-only. Old events stay as they were written. But downcasting has its place too.</p><p>Consider ther: you have a projection or a subscriber that was built months ago against the old event schema. Maybe it&#8217;s a read model that listens to <em>ShoppingCartOpened</em> and expects <em>clientId</em> as a flat string. But your current code evolved. Now <em>ShoppingCartOpened</em> carries a <em>client</em> object with <em>id</em> and <em>name</em>:</p><pre><code><code>// What old subscribers expect
type ShoppingCartOpenedV1 = Event&lt;
  'ShoppingCartOpened',
  { clientId: string; openedAt: string }
&gt;;

// What current code produces
type ShoppingCartOpenedV2 = Event&lt;
  'ShoppingCartOpened',
  { client: { id: string; name: string }; openedAt: Date }
&gt;;</code></code></pre><p>Upcasting enables the current code to read older events. The ones stored with just <em>clientId</em>. Downcasting helps old subscribers consume new events. It transforms the new <em>client</em> object back into the flat <em>clientId</em> they expect. Same principle as with documents, but especially important here because event subscribers often live in separate services or deployments that you can&#8217;t update all at once.</p><p>And the same upcast function that started as simple type mapping <em>string &#8594; Date</em>, <em>string &#8594; bigint</em> handles ther structural change too. You just add another case to the switch:</p><pre><code><code>case 'ShoppingCartOpened': {
  const e = event as ShoppingCartOpenedV1;
  return {
    ...e,
    data: {
      client: { id: e.data.clientId, name: 'Unknown' },
      openedAt: new Date(e.data.openedAt),
    },
  };
}</code></code></pre><p>Old events get the <em>client</em> object synthesised from the flat <em>clientId</em>. New events already have it. The evolve function only deals with the V2 shape.</p><p>And here&#8217;s where things started to click for me. I added upcasting to fix the bigint problem: explicit type mapping instead of a Regex. But the same mechanism, without any changes, also handles structural versioning. The simple <em>string &#8594; Date</em> mapping from the first example is the same code path as the <em>clientId &#8594; client</em> migration above. It&#8217;s one function, one place, one pattern for all of it: type coercion, field restructuring, schema migration.</p><h2>Right decisions stack</h2><p>Right decisions stack. The Regex hack was blocking the slot where upcasting should have been all along. Once I removed it, the performance got fixed, and I got schema versioning on top. One fix created room for the next one, which created room for the next. That doesn&#8217;t happen when you keep patching around the same bad decision.</p><p>Looking back, maybe the Regex wasn&#8217;t the wrong first move. The rule is <em>&#8220;make it work, make it right, make it pretty&#8221;</em>, in that order. The Regex made it work. It had performance problems, but it still let me ship and learn where the real problem was. If I had tried to design the upcast/downcast system from scratch, without having lived with the Regex for a while, I might have over-engineered it or missed the connection to schema versioning entirely. The understanding came from living with the shortcut.</p><p>Dawid raised a performance issue with Pongo projections, but the same Regex was running in Emmett as well. I could have fixed it in one place and called it a day. Instead, I used it as a push to do the thing I&#8217;d been planning anyway and applied it to both Pongo and Emmett to keep things consistent. Because I already understood the problem well enough, &#8220;make it right&#8221; turned out easier than I expected.</p><p>You can recover from shortcuts. You should. But you also shouldn&#8217;t be afraid to take them in the first place, as long as you come back and do it properly.</p><p>Full changes:</p><ul><li><p><a href="https://github.com/event-driven-io/Pongo/pull/149">Pongo PR #149</a>,</p></li><li><p><a href="https://github.com/event-driven-io/emmett/pull/292">Emmett PR #292</a>.</p></li></ul><p>Cheers!</p><p>Oskar</p><p>p.s. <strong>Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to <a href="https://www.icrc.org/en/donate/ukraine">Red Cross</a>, <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian organisation</a> or <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">donate Ambulances for Ukraine</a>.</p>]]></content:encoded></item><item><title><![CDATA[How I cheated on transactions]]></title><description><![CDATA[Or how to make tradeoffs based on my Cloudflare D1 support]]></description><link>https://www.architecture-weekly.com/p/how-i-cheated-on-transactions</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/how-i-cheated-on-transactions</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 16 Feb 2026 16:53:30 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!rs0c!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff316a45c-df05-42c2-951e-c71583545c96_800x500.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>We&#8217;re being told that software design is the art of making tradeoffs. But... Are we taught how to make them?</p><p>Not that it&#8217;s easy to teach tradeoffs, it&#8217;s a subtle art of explanation. You need to provide enough context and be precise so others don&#8217;t treat tradeoffs as a general best practice. Because they&#8217;re usually not such, they typically come from the muddy banks of the Wishkah River.</p><p>I think that a decent way is to tell the story. Not the fairy tale, but the specific tradeoff applied in practice. That&#8217;s what I&#8217;m going to do today: tell you how I cheated on database transactions.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!rs0c!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff316a45c-df05-42c2-951e-c71583545c96_800x500.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!rs0c!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff316a45c-df05-42c2-951e-c71583545c96_800x500.png 424w, https://substackcdn.com/image/fetch/$s_!rs0c!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff316a45c-df05-42c2-951e-c71583545c96_800x500.png 848w, https://substackcdn.com/image/fetch/$s_!rs0c!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff316a45c-df05-42c2-951e-c71583545c96_800x500.png 1272w, https://substackcdn.com/image/fetch/$s_!rs0c!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff316a45c-df05-42c2-951e-c71583545c96_800x500.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!rs0c!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff316a45c-df05-42c2-951e-c71583545c96_800x500.png" width="800" height="500" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/f316a45c-df05-42c2-951e-c71583545c96_800x500.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:500,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:173338,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://www.architecture-weekly.com/i/188131828?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff316a45c-df05-42c2-951e-c71583545c96_800x500.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!rs0c!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff316a45c-df05-42c2-951e-c71583545c96_800x500.png 424w, https://substackcdn.com/image/fetch/$s_!rs0c!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff316a45c-df05-42c2-951e-c71583545c96_800x500.png 848w, https://substackcdn.com/image/fetch/$s_!rs0c!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff316a45c-df05-42c2-951e-c71583545c96_800x500.png 1272w, https://substackcdn.com/image/fetch/$s_!rs0c!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff316a45c-df05-42c2-951e-c71583545c96_800x500.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><h2><strong>Dumbo</strong></h2><p>Do you know Dumbo? Of course, flying elephant, such a sweetie. It&#8217;s also a codename of my Open Source project. I didn&#8217;t tell you about it so far, as it&#8217;s a shared dependency for <a href="https://github.com/event-driven-io/pongo">Pongo</a> and <a href="https://event-driven-io.github.io/emmett/getting-started.html">Emmett</a>, responsible for:</p><ul><li><p>connection pooling,</p></li><li><p>safe connection lifetime management,</p></li><li><p>handling transactions,</p></li><li><p>migrations,</p></li><li><p>SQL execution.</p></li></ul><p>Not that small a scope for a Dumb tool, aye? Still, the intention is to make usage dumb, hiding the weirdness of a specific SQL database, so I solve it once and don&#8217;t need to be constantly distracted thinking about it, but focus on:</p><ul><li><p>How to append and process events the best way in Emmett,</p></li><li><p>How to make the best JSON handling and translation into specific SQL dialects in Pongo.</p></li></ul><p>Dumbo usage is quite simple:</p><pre><code><code>import { dumbo } from '@event-driven-io/dumbo';
import { pgDumboDriver } from '@event-driven-io/dumbo/pg';

const pool = dumbo({ connectionString, driver: pgDatabaseDriver });</code></code></pre><p>You need to set up a specific database driver (e.g. <em>pg</em> for PostgreSQL or <em>sqlite3</em> for SQLite), as Dumbo now supports multiple relational databases.</p><p>Having that, you can do stuff like:</p><pre><code><code>import { SQL } from '@event-driven-io/dumbo';

await pool.execute.batchCommand([
  SQL`CREATE TABLE test_users (id SERIAL PRIMARY KEY, name TEXT)`,
  SQL`INSERT INTO test_users (name) VALUES ('Alice'), ('Bob')`,
]);</code></code></pre><p>And also do queries:</p><pre><code><code>const count = pool.execute.query&lt;{count: number}&gt;(
  SQL`SELECT COUNT(*) as count 
      FROM test_users 
      WHERE ${SQL.in('id', userIds)}`,
);</code></code></pre><p>It&#8217;ll handle query parameterisation, data escaping, etc.</p><p>It can also handle transactions:</p><pre><code><code>const users = await pool.withTransaction(async (tx) =&gt; {
  await tx.execute.command(
    SQL`INSERT INTO test_users (name) 
        VALUES (${firstUserName}), (${secondUserName})`,
  );

  return execute.query&lt;User&gt;(
    SQL`SELECT *
        FROM test_users 
        WHERE ${SQL.in('id', userIds)}`,
   );
});</code></code></pre><p>As simple as this looks, you should already have the question about the first tradeoff I made in your head.</p><p><strong>Why on earth I think that&#8217;s a sane move to write my own multi-database driver?!</strong></p><p>Well, I agree, that&#8217;s not the best move at the first glance, but let me explain to you why in <strong>MY</strong> context this actually makes more sense:</p><ol><li><p>Yes, there are tools like Knex, Kysely, Drizzle, etc. in Node.js land that handle similar stuff. They&#8217;re nice, I really like them, I really do, but&#8230; But they are all big and are bringing a lot of their conventions, and when I&#8217;m building storage tools like Emmett and Pongo, I need to have more control. I don&#8217;t want those tools and their limitations drive my architecture decisions. I also don&#8217;t want to be surprised when the creator decides to drop working on it, or when I become a victim of a supply chain attack. Still, when I want a new kitchen table, I don&#8217;t start by going to the forest with a saw. I&#8217;m still using existing database drivers, what&#8217;s more I&#8217;m allowing people to choose the one they prefer. I just don&#8217;t want to be driven (and also my users be driven) by some other higher abstraction tool.</p></li><li><p>Even with that, this decision can seem like a bold &#8220;how hard can it be?!&#8221; statement. And it is, but&#8230; But I&#8217;ve built, or was co-authoring, such tools in the past. Some were proprietary, some were Open Sourced (see <a href="https://github.com/JasperFx/weasel">Weasel</a>). And they served me well.</p></li><li><p>And last but not least, the last point. Well, I only had those two above. OK, I can add that I didn&#8217;t plan to make it a general-usage tool, just a small one for my needs.</p></li></ol><p>So I did.</p><p>And here we&#8217;re at the moment when I had to cheat on transactions because of those decisions.</p><h2>Cloudflare D1</h2><p>All relational databases seem similar, but only until you start using them extensively, or until you need to write your own storage library. Then you learn stuff that not necessarily the stuff you&#8217;d like to spend time on. For instance:</p><ul><li><p>What&#8217;s the difference between databases like PostgreSQL and single-threaded databases like SQLite or DuckDB, and how concurrent processing can be surprising</p></li><li><p>or that sqlite3 only calls the first query, but the next silently ignores,</p></li><li><p>etc.</p></li></ul><p>Still, well, abstractions like Dumbo give the possibility to <em>massage</em> such cases behind the scenes.</p><p>And then Cloud Databases and SaaS Databases came into play, like Cloudlfare D1.</p><p>I was motivated by the generous sponsorship from <a href="https://www.linkedin.com/in/samhatoum">Sam Hatoum</a> to make supporting Emmett and Pongo a top priority. Thanks Sam, appreciate that! And I made it, but I had to cheat a bit.</p><p>Databases like Cloudflare D1 and Supabase expose databases as pay-as-you-go services. They optimise deployment, making it highly scalable, so you don&#8217;t need to care about it. It&#8217;s cost-effective for start-ups and scenarios when you&#8217;re not under a huge load. If you are, then you&#8217;ll need to pay more, but they also give you autoscaling at least.</p><p>They do it by exposing the database API through HTTP API, for instance <a href="https://docs.postgrest.org/en/v14/">PostgREST</a>. This gives them easier management around throughput, security, etc., as each call is made as an HTTP request through the exposed API.</p><p>Yet, that kills some options like: ekhm, transactions. The challenge with transactions is that they don&#8217;t scale (that&#8217;s why <a href="https://www.youtube.com/watch?v=b2F-DItXtZs">MongoDB is WebScale</a>). They don&#8217;t scale, as you&#8217;d need to open a transaction, do some freehand operations, and then commit or rollback. That means (typically) you need to keep a connection open during that time. If you&#8217;re building SaaS, it&#8217;s a no-go, because sneaky users would open it for a few hours, do crazy stuff, and kill your SaaS resources&#8217; utilisation.</p><p>But, boy, aren&#8217;t transactions one of the selling points of relational databases? They do, so how to proceed?</p><p><strong>Then you need to cheat, as I did.</strong></p><h2>Repeatable reads, batches, etc.</h2><p>Cloudflare D1 doesn&#8217;t provide transaction data for the reasons I outlined, but it doesn&#8217;t leave us without other tools. Those two tools to let me do the smoke and mirrors trick are:</p><ul><li><p>sessions,</p></li><li><p>SQL batches.</p></li></ul><p>What are sessions? <a href="https://developers.cloudflare.com/d1/best-practices/read-replication">Per Cloudflare Docs</a>:</p><blockquote><p>A session encapsulates all the queries from one logical session for your application. For example, a session may correspond to all queries coming from a particular web browser session. All queries within a session read from a database instance which is as up-to-date as your query needs it to be. Sessions API ensures <a href="https://developers.cloudflare.com/d1/best-practices/read-replication/#replica-lag-and-consistency-model">sequential consistency</a> for all queries in a session.</p></blockquote><p>Essentially, that means that we&#8217;re getting <a href="https://jepsen.io/consistency/models/repeatable-read">repeatable reads</a>. So when we&#8217;re starting specific sessions, they will be handled sequentially.</p><p><strong>And now, the second ingredient: Batches. Per <a href="https://developers.cloudflare.com/d1/worker-api/d1-database/#batch">Cloudflare docs</a></strong></p><blockquote><p>Sends multiple SQL statements inside a single call to the database. This can have a huge performance impact as it reduces latency from network round trips to D1. D1 operates in auto-commit. Our implementation guarantees that each statement in the list will execute and commit, sequentially, non-concurrently.</p><p>Batched statements are <a href="https://www.sqlite.org/lang_transaction.html">SQL transactions</a>. If a statement in the sequence fails, then an error is returned for that specific statement, and it aborts or rolls back the entire sequence.</p><p>To send batch statements, provide D1Database::batch a list of prepared statements and get the results in the same order.</p></blockquote><p>So Cloudflare didn&#8217;t allow us to do the full, freehand transaction, but they allowed us to send multiple statements that internally will be executed as a SQLite Transaction. When the request is handled, it&#8217;ll open a transaction, run the statements, and return the results.</p><p><strong>Cool, let&#8217;s mix this soup together, as having that, I decided to:</strong></p><ol><li><p>Fail automatically if someone tries to create a transaction on Cloudflare D1 with an error:</p></li></ol><blockquote><p>D1 does not support SQL transactions (BEGIN/COMMIT/ROLLBACK/SAVEPOINT). Use { mode: &#8220;session_based&#8221; } to opt-in to session+batch semantics, or use &#8216;connection.execute.batchCommand() for atomic multi-statement execution.</p></blockquote><p>Then they get clear information on the first try.</p><ol start="2"><li><p>Allow users to explicitly open a session-based transaction by providing mode:</p></li></ol><pre><code><code>const users = await pool.withTransaction(
  async (tx) =&gt; {
    await tx.execute.command(
      SQL`INSERT INTO test_users (name) 
      VALUES (${firstUserName}), (${secondUserName})`,
    );

    return tx.execute.query&lt;User&gt;(
      SQL`SELECT *
              FROM test_users 
              WHERE ${SQL.in('id', userIds)}`,
    );
  },  
  { mode: 'session_based' }
);</code></code></pre><p>When they do it, they will need to be aware of the limitations of the tool they have. So that this will internally create a D1 session, and only handle a single batch of operations properly. We&#8217;re mimicking the sequential processing by the session-based repeatable reads capability. Still, we need to remember that we won&#8217;t be able to roll back changes across multiple statements. Only a single command or batch command is an atomic operation.</p><p>We can&#8217;t, for instance, run a batch of updates, and fail the whole batch if one update didn&#8217;t change any record. The batch will only fail if the database throws an exception. An exception can be in SQLite only called by a table constraint or trigger.</p><p>By making this choice to require explicit mode and naming it explicitly, I didn&#8217;t manage to cover all cases, but at least made it safe, so people need to learn about this non-default behaviour and be more careful about it. When designing an API, it&#8217;s usually better to start with a more strict option and do a bit of <em>&#8220;scarification&#8221;</em> sometimes.</p><p>Still, when I implemented that in <a href="https://event-driven-io.github.io/emmett/getting-started.html">Emmett</a> and <a href="https://github.com/event-driven-io/pongo">Pongo</a>, I intentionally used it to enable event appends, but document operations, etc.</p><p>If you&#8217;d like to try it, you can check Emmett&#8217;s or Pongo&#8217;s beta versions.</p><p>For Pongo, you can install it with:</p><pre><code><code>npm install @event-driven-io/pongo@0.17.0-beta.21</code></code></pre><p>And use it as:</p><pre><code><code>import { d1PongoDriver } from '@event-driven-io/pongo/cloudflare';

const client = pongoClient({
  driver: d1PongoDriver,
  database,
  transactionOptions: { mode: 'session_based' },
});</code></code></pre><p>Or in Emmett by installing:</p><pre><code><code>npm install @event-driven-io/emmett-sqlite@0.43.0-beta.1</code></code></pre><p>And use it as:</p><pre><code><code>import { getSQLiteEventStore } from '@event-driven-io/emmett-sqlite';
import { d1EventStoreDriver } from '@event-driven-io/emmett-sqlite/cloudflare';

const eventStore = getSQLiteEventStore({
  driver: d1EventStoreDriver,
  database,
});</code></code></pre><p>Still, even if you don&#8217;t care about Emmett, Pongo, and my Open Source project, I hope that this will give you decent inspiration for your own tradeoffs analysis.</p><p>I hope that you learned a bit about how design APIs work, how to check the guarantees of your tools and learn to walkaround them when you have to.</p><p>Cheers!</p><p>Oskar</p><p>p.s. <strong>Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to <a href="https://www.icrc.org/en/donate/ukraine">Red Cross</a>, <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian organisation</a> or <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">donate Ambulances for Ukraine</a>.</p>]]></content:encoded></item><item><title><![CDATA[On rebuilding read models, Dead-Letter Queues and Why Letting Go is Sometimes the Answer]]></title><description><![CDATA[In the last article, I explained how to rebuild Event-Driven Read Models in a safe and resilient way. I asked readers to let me know if they find any blind spots in my design.]]></description><link>https://www.architecture-weekly.com/p/on-rebuilding-read-models-dead-letter</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/on-rebuilding-read-models-dead-letter</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 19 Jan 2026 18:01:47 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!jBdJ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F247ed8c5-16a9-4d5a-93cd-01077982a5a7_800x500.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!jBdJ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F247ed8c5-16a9-4d5a-93cd-01077982a5a7_800x500.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!jBdJ!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F247ed8c5-16a9-4d5a-93cd-01077982a5a7_800x500.png 424w, https://substackcdn.com/image/fetch/$s_!jBdJ!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F247ed8c5-16a9-4d5a-93cd-01077982a5a7_800x500.png 848w, https://substackcdn.com/image/fetch/$s_!jBdJ!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F247ed8c5-16a9-4d5a-93cd-01077982a5a7_800x500.png 1272w, https://substackcdn.com/image/fetch/$s_!jBdJ!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F247ed8c5-16a9-4d5a-93cd-01077982a5a7_800x500.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!jBdJ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F247ed8c5-16a9-4d5a-93cd-01077982a5a7_800x500.png" width="800" height="500" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/247ed8c5-16a9-4d5a-93cd-01077982a5a7_800x500.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:500,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;cover&quot;,&quot;title&quot;:&quot;cover&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="cover" title="cover" srcset="https://substackcdn.com/image/fetch/$s_!jBdJ!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F247ed8c5-16a9-4d5a-93cd-01077982a5a7_800x500.png 424w, https://substackcdn.com/image/fetch/$s_!jBdJ!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F247ed8c5-16a9-4d5a-93cd-01077982a5a7_800x500.png 848w, https://substackcdn.com/image/fetch/$s_!jBdJ!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F247ed8c5-16a9-4d5a-93cd-01077982a5a7_800x500.png 1272w, https://substackcdn.com/image/fetch/$s_!jBdJ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F247ed8c5-16a9-4d5a-93cd-01077982a5a7_800x500.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>In the last article, I explained <a href="https://event-driven.io/en/rebuilding_event_driven_read_models/">how to rebuild Event-Driven Read Models in a safe and resilient way</a>. I asked readers to let me know if they find any blind spots in my design.</p><p>Well, I found one myself.</p><p>This article is about that edge case, but more importantly, it&#8217;s about the rabbit hole I went down thinking about how to &#8220;fix&#8221; it. At the end of this hole, there is a nice learning about dealing with distributed systems. Sometimes the best engineering isn&#8217;t about preventing all failures. It&#8217;s about recognising your blind spots and making sure they don&#8217;t catch you off guard.</p><h2>Everyone has a plan until they get punched in the mouth</h2><p>Let me recap the situation described in <a href="https://event-driven.io/en/rebuilding_event_driven_read_models/">the previous article</a>. We&#8217;re using PostgreSQL to store events and read models. We want to rebuild an inline projection, one that&#8217;s applied in the same transaction as the appended event.</p><p>In the design from my previous article, during a rebuild, we:</p><ol><li><p>Mark the projection as &#8220;rebuilding&#8221;.</p></li><li><p>Skip inline projections (the rebuild process will catch up anyway).</p></li><li><p>Process all historical events.</p></li><li><p>Mark the projection as &#8220;active&#8221; again.</p></li></ol><p>The hybrid locking strategy with advisory locks and status checks ensures that inline projections know when to skip and that only one rebuild runs at a time.</p><p>Sounds solid. Here&#8217;s where it breaks:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!eFXU!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9438d06-2f76-4cf8-bb2d-12db61049962_800x548.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!eFXU!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9438d06-2f76-4cf8-bb2d-12db61049962_800x548.png 424w, https://substackcdn.com/image/fetch/$s_!eFXU!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9438d06-2f76-4cf8-bb2d-12db61049962_800x548.png 848w, https://substackcdn.com/image/fetch/$s_!eFXU!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9438d06-2f76-4cf8-bb2d-12db61049962_800x548.png 1272w, https://substackcdn.com/image/fetch/$s_!eFXU!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9438d06-2f76-4cf8-bb2d-12db61049962_800x548.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!eFXU!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9438d06-2f76-4cf8-bb2d-12db61049962_800x548.png" width="800" height="548" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b9438d06-2f76-4cf8-bb2d-12db61049962_800x548.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:548,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;mermaid1&quot;,&quot;title&quot;:&quot;mermaid1&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="mermaid1" title="mermaid1" srcset="https://substackcdn.com/image/fetch/$s_!eFXU!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9438d06-2f76-4cf8-bb2d-12db61049962_800x548.png 424w, https://substackcdn.com/image/fetch/$s_!eFXU!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9438d06-2f76-4cf8-bb2d-12db61049962_800x548.png 848w, https://substackcdn.com/image/fetch/$s_!eFXU!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9438d06-2f76-4cf8-bb2d-12db61049962_800x548.png 1272w, https://substackcdn.com/image/fetch/$s_!eFXU!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9438d06-2f76-4cf8-bb2d-12db61049962_800x548.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>The rebuild process checks for new events, sees none (because event 1000 is still in an uncommitted transaction), declares victory, and sets the projection to active. Meanwhile, the append transaction has already decided to skip the inline projection. When it finally commits, the event exists but was never projected. The rebuild process is done, and only new events will update the projection during the next update to the new event append.</p><p>We could handwave it with <a href="https://event-driven.io/en/no_it_can_never_happen/">it won&#8217;t ever happen!</a>. But, well, under load, this will happen eventually. You might not even notice it, but it will. The timing window is small, but with enough throughput, it becomes a certainty.</p><h2>The Rabbit Hole of &#8220;Fixes&#8221;</h2><p>My first instinct was to engineer my way out of this. Surely with enough clever coordination, we can close this gap?</p><p>Let me walk you through the rabbit hole.</p><h3>Attempt 1: Wait for In-Flight Transactions</h3><p>PostgreSQL provides <em>pg_snapshot_xmin(pg_current_snapshot())</em> which tells us the oldest transaction that&#8217;s still running. The idea: after processing events, wait until all potentially-skipping transactions have completed before marking the projection as active.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!NO-_!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4bd1f3e2-25af-4609-804c-220315d4a021_800x418.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!NO-_!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4bd1f3e2-25af-4609-804c-220315d4a021_800x418.png 424w, https://substackcdn.com/image/fetch/$s_!NO-_!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4bd1f3e2-25af-4609-804c-220315d4a021_800x418.png 848w, https://substackcdn.com/image/fetch/$s_!NO-_!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4bd1f3e2-25af-4609-804c-220315d4a021_800x418.png 1272w, https://substackcdn.com/image/fetch/$s_!NO-_!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4bd1f3e2-25af-4609-804c-220315d4a021_800x418.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!NO-_!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4bd1f3e2-25af-4609-804c-220315d4a021_800x418.png" width="800" height="418" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/4bd1f3e2-25af-4609-804c-220315d4a021_800x418.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:418,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;mermaid2&quot;,&quot;title&quot;:&quot;mermaid2&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="mermaid2" title="mermaid2" srcset="https://substackcdn.com/image/fetch/$s_!NO-_!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4bd1f3e2-25af-4609-804c-220315d4a021_800x418.png 424w, https://substackcdn.com/image/fetch/$s_!NO-_!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4bd1f3e2-25af-4609-804c-220315d4a021_800x418.png 848w, https://substackcdn.com/image/fetch/$s_!NO-_!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4bd1f3e2-25af-4609-804c-220315d4a021_800x418.png 1272w, https://substackcdn.com/image/fetch/$s_!NO-_!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4bd1f3e2-25af-4609-804c-220315d4a021_800x418.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><strong>Why it fails:</strong> We don&#8217;t know what we&#8217;re waiting for. While we wait for existing transactions to complete, new transactions start and make their own skip decisions. The target keeps moving. We can never &#8220;catch up&#8221; because new skips happen while we&#8217;re waiting for old ones to become visible.</p><h3>Attempt 2: Use Transaction IDs as Boundaries</h3><p>PostgreSQL assigns monotonic transaction IDs to each transaction when it starts. This seems useful&#8212;what if we record a &#8220;sealing&#8221; transaction ID when we&#8217;re ready to complete the rebuild, and use it to make decisions?</p><p>The logic would be:</p><p>When rebuild is ready to complete, record sealing_txid = current_transaction_id</p><p>Any event from a transaction with ID lower than the sealing point was &#8220;in flight&#8221; during rebuild, so it should be handled by async Events from transactions with higher IDs started after sealing, so they can safely use inline projections (the read model will be ready)</p><p>Sounds reasonable?</p><p>Here&#8217;s why it doesn&#8217;t work.</p><p><strong>The core problem: transaction ID order &#8800; commit order.</strong></p><p>When a transaction starts, PostgreSQL assigns it the next available transaction ID. But transactions don&#8217;t commit in the order they started. A transaction that started earlier (lower ID) might take longer to complete and commit after a transaction that started later (higher ID).</p><p>Let&#8217;s trace through a concrete scenario:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!xuir!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe74809f4-37b6-43c5-af79-4b1bbb24c5ec_800x688.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!xuir!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe74809f4-37b6-43c5-af79-4b1bbb24c5ec_800x688.png 424w, https://substackcdn.com/image/fetch/$s_!xuir!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe74809f4-37b6-43c5-af79-4b1bbb24c5ec_800x688.png 848w, https://substackcdn.com/image/fetch/$s_!xuir!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe74809f4-37b6-43c5-af79-4b1bbb24c5ec_800x688.png 1272w, https://substackcdn.com/image/fetch/$s_!xuir!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe74809f4-37b6-43c5-af79-4b1bbb24c5ec_800x688.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!xuir!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe74809f4-37b6-43c5-af79-4b1bbb24c5ec_800x688.png" width="800" height="688" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/e74809f4-37b6-43c5-af79-4b1bbb24c5ec_800x688.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:688,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;mermaid3&quot;,&quot;title&quot;:&quot;mermaid3&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="mermaid3" title="mermaid3" srcset="https://substackcdn.com/image/fetch/$s_!xuir!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe74809f4-37b6-43c5-af79-4b1bbb24c5ec_800x688.png 424w, https://substackcdn.com/image/fetch/$s_!xuir!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe74809f4-37b6-43c5-af79-4b1bbb24c5ec_800x688.png 848w, https://substackcdn.com/image/fetch/$s_!xuir!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe74809f4-37b6-43c5-af79-4b1bbb24c5ec_800x688.png 1272w, https://substackcdn.com/image/fetch/$s_!xuir!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe74809f4-37b6-43c5-af79-4b1bbb24c5ec_800x688.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Here&#8217;s what happened:</p><ol><li><p><strong>Tx 99</strong> started first and got the lowest transaction ID. It inserted an event and decided to skip the inline projection (status was &#8216;rebuilding&#8217;). But then it got slow&#8212;maybe network latency, maybe the application did other work.</p></li><li><p><strong>Tx 100</strong> (the rebuild) started second, recorded <em>sealing_txid = 100</em>, and prepared to complete.</p></li><li><p><strong>Tx 101</strong> started third. It checked: &#8220;Is my transaction ID (101) &gt;= sealing_txid (100)?&#8221; Yes, so it assumed the read model was ready and processed its inline projection. It committed successfully.</p></li><li><p><strong>Tx 100</strong> marked the projection as active and committed.</p></li><li><p><strong>Tx 99</strong> finally committed. But it had already decided to skip the projection back when status was &#8216;rebuilding&#8217;. That decision was made, the skip happened, and the event is now missing from the read model.</p></li></ol><p>The fundamental issue: <strong>we can&#8217;t see uncommitted transactions.</strong> When Tx 100 set the sealing point, it had no way to know that Tx 99 was still out there, holding an event that would skip projection. Transaction 99 is invisible until it commits, but by then it&#8217;s too late.</p><p>You might think: &#8220;Just wait until all transactions before the sealing point have committed!&#8221; PostgreSQL even provides <em>pg_snapshot_xmin(pg_current_snapshot())</em> which tells you the oldest active transaction. But this leads us back to Attempt 1&#8212;while we wait, new transactions start and make their own skip decisions. The target keeps moving.</p><p>I wrote about this exact problem in <a href="https://event-driven.io/en/ordering_in_postgres_outbox/">How Postgres sequences issues can impact your messaging guarantees</a>. The same visibility challenges that affect outbox ordering apply here. Transaction IDs are useful for ordering within committed data, but they can&#8217;t help us coordinate with transactions that haven&#8217;t committed yet.</p><h3>Attempt 3: Lock Appends During Transition</h3><p>What if we use advisory locks more aggressively? The idea: when rebuild is ready to complete, acquire an exclusive lock that blocks the entire append path. While holding this lock, flip the status to &#8216;active&#8217;. No appends can be in progress during the flip, so no race condition, right?</p><p>Here&#8217;s the proposed flow:</p><ul><li><p>Rebuild finishes processing historical events</p></li><li><p>Acquire exclusive advisory lock (all new appends must wait)</p></li><li><p>Set status = &#8216;active&#8217;</p></li><li><p>Release lock</p></li><li><p>Waiting appends resume and sees &#8216;active&#8217;, processes inline normally</p></li></ul><p>It should work. In theory. We&#8217;re creating a synchronisation point where nothing can slip through. But there&#8217;s a subtle problem: <strong>the skip decision was already made inside the append transaction.</strong></p><p>An append transaction doesn&#8217;t just check the lock and status at one instant. It does several things:</p><ol><li><p>BEGIN transaction</p></li><li><p>INSERT the event</p></li><li><p>Try to acquire a shared advisory lock</p></li><li><p>Check projection status</p></li><li><p>Decide: process inline or skip?</p></li><li><p>Execute that decision</p></li><li><p>COMMIT</p></li></ol><p>The decision in step 5 happens <em>inside</em> the transaction. If the transaction saw &#8216;rebuilding&#8217; status, it was skipped. That decision is now part of the transaction&#8217;s pending work. The transaction might be waiting to commit or doing other work, but the skip decision has already been made.</p><p>Our exclusive lock in the completion flow blocks step 3 - new transactions can&#8217;t acquire the shared lock. But what about transactions that already passed step 5 and are just waiting to commit?</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!mreF!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43e69c73-b6c3-43d4-9b12-317eb5d3a66e_800x730.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!mreF!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43e69c73-b6c3-43d4-9b12-317eb5d3a66e_800x730.png 424w, https://substackcdn.com/image/fetch/$s_!mreF!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43e69c73-b6c3-43d4-9b12-317eb5d3a66e_800x730.png 848w, https://substackcdn.com/image/fetch/$s_!mreF!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43e69c73-b6c3-43d4-9b12-317eb5d3a66e_800x730.png 1272w, https://substackcdn.com/image/fetch/$s_!mreF!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43e69c73-b6c3-43d4-9b12-317eb5d3a66e_800x730.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!mreF!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43e69c73-b6c3-43d4-9b12-317eb5d3a66e_800x730.png" width="800" height="730" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/43e69c73-b6c3-43d4-9b12-317eb5d3a66e_800x730.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:730,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;mermaid4&quot;,&quot;title&quot;:&quot;mermaid4&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="mermaid4" title="mermaid4" srcset="https://substackcdn.com/image/fetch/$s_!mreF!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43e69c73-b6c3-43d4-9b12-317eb5d3a66e_800x730.png 424w, https://substackcdn.com/image/fetch/$s_!mreF!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43e69c73-b6c3-43d4-9b12-317eb5d3a66e_800x730.png 848w, https://substackcdn.com/image/fetch/$s_!mreF!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43e69c73-b6c3-43d4-9b12-317eb5d3a66e_800x730.png 1272w, https://substackcdn.com/image/fetch/$s_!mreF!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43e69c73-b6c3-43d4-9b12-317eb5d3a66e_800x730.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>The timing here is tricky. Advisory locks in PostgreSQL can be either:</p><ul><li><p><strong>Transaction-scoped</strong> (<em>pg_advisory_xact_lock</em>): released automatically when transaction commits</p></li><li><p><strong>Session-scoped</strong> (<em>pg_advisory_lock</em>): held until explicitly released or session ends</p></li></ul><p>If we use transaction-scoped locks for inline projections (which makes sense&#8212;you want the lock tied to the transaction lifetime), then the append transaction might have already released its shared lock by the time we try to acquire an exclusive lock. The lock protected the status check, but the transaction is still running with its skip decision already made.</p><p>Even if we could perfectly synchronise the lock acquisition, there&#8217;s another problem: <strong>advisory locks are session-scoped, not durable.</strong></p><p>If the connection dies while holding the exclusive lock:</p><ul><li><p>The lock releases immediately (that&#8217;s how advisory locks work)</p></li><li><p>We might have partially updated the status</p></li><li><p>The system is now in an unknown state</p></li><li><p>Other transactions resume with potentially inconsistent data</p></li></ul><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!mBnK!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ae43c21-aaa7-44eb-9c96-db54e695c00d_800x498.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!mBnK!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ae43c21-aaa7-44eb-9c96-db54e695c00d_800x498.png 424w, https://substackcdn.com/image/fetch/$s_!mBnK!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ae43c21-aaa7-44eb-9c96-db54e695c00d_800x498.png 848w, https://substackcdn.com/image/fetch/$s_!mBnK!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ae43c21-aaa7-44eb-9c96-db54e695c00d_800x498.png 1272w, https://substackcdn.com/image/fetch/$s_!mBnK!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ae43c21-aaa7-44eb-9c96-db54e695c00d_800x498.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!mBnK!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ae43c21-aaa7-44eb-9c96-db54e695c00d_800x498.png" width="800" height="498" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/5ae43c21-aaa7-44eb-9c96-db54e695c00d_800x498.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:498,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;mermaid5&quot;,&quot;title&quot;:&quot;mermaid5&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="mermaid5" title="mermaid5" srcset="https://substackcdn.com/image/fetch/$s_!mBnK!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ae43c21-aaa7-44eb-9c96-db54e695c00d_800x498.png 424w, https://substackcdn.com/image/fetch/$s_!mBnK!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ae43c21-aaa7-44eb-9c96-db54e695c00d_800x498.png 848w, https://substackcdn.com/image/fetch/$s_!mBnK!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ae43c21-aaa7-44eb-9c96-db54e695c00d_800x498.png 1272w, https://substackcdn.com/image/fetch/$s_!mBnK!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ae43c21-aaa7-44eb-9c96-db54e695c00d_800x498.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>We can&#8217;t build reliable coordination on something that vanishes when connections fail. This is exactly why the original article combined advisory locks with persistent status checks&#8212;but that combination doesn&#8217;t solve this particular race condition.</p><h3>TLDR on Attempts</h3><p>Every &#8220;fix&#8221; follows the same pattern:</p><ol><li><p>Identify a coordination point.</p></li><li><p>Discover there&#8217;s a window before that point we can&#8217;t see.</p></li><li><p>Try to close that window.</p></li><li><p>Create a new window somewhere else.</p></li><li><p>Repeat.</p></li></ol><p>We&#8217;re not solving the problem. We&#8217;re relocating it.</p><p>So is it a bug in the initial design? Depending how you look on that. I see it as an example of fundamental property of concurrent systems. PostgreSQL&#8217;s isolation guarantees mean uncommitted transactions are invisible to other transactions. That&#8217;s a feature, not a bug - but it means there&#8217;s always a window we can&#8217;t see into.</p><h2>Stop Fighting, Start Tracking</h2><p>After chasing my tail through various &#8220;solutions,&#8221; I stepped back and asked a different question.</p><p>Instead of: <em>&#8220;How do we prevent events from being skipped?&#8221;</em> (which requires blocking appends or seeing into uncommitted transactions&#8212;both unacceptable because of performance, guarantees etc.)</p><p>I started to think on: <em>&#8220;How do we know when an event was skipped?&#8221;</em> and <em>&#8220;How do we ensure skipped events get processed eventually?&#8221;</em></p><p>Both of these are solvable.</p><p>If we can&#8217;t prevent skips from happening, let&#8217;s make them visible. When an inline projection skips, it could record that it skipped in the same transaction as the event append.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!wdhL!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5513455f-be66-4a42-990f-f0e31ef00583_800x573.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!wdhL!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5513455f-be66-4a42-990f-f0e31ef00583_800x573.png 424w, https://substackcdn.com/image/fetch/$s_!wdhL!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5513455f-be66-4a42-990f-f0e31ef00583_800x573.png 848w, https://substackcdn.com/image/fetch/$s_!wdhL!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5513455f-be66-4a42-990f-f0e31ef00583_800x573.png 1272w, https://substackcdn.com/image/fetch/$s_!wdhL!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5513455f-be66-4a42-990f-f0e31ef00583_800x573.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!wdhL!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5513455f-be66-4a42-990f-f0e31ef00583_800x573.png" width="800" height="573" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/5513455f-be66-4a42-990f-f0e31ef00583_800x573.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:573,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;mermaid6&quot;,&quot;title&quot;:&quot;mermaid6&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="mermaid6" title="mermaid6" srcset="https://substackcdn.com/image/fetch/$s_!wdhL!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5513455f-be66-4a42-990f-f0e31ef00583_800x573.png 424w, https://substackcdn.com/image/fetch/$s_!wdhL!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5513455f-be66-4a42-990f-f0e31ef00583_800x573.png 848w, https://substackcdn.com/image/fetch/$s_!wdhL!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5513455f-be66-4a42-990f-f0e31ef00583_800x573.png 1272w, https://substackcdn.com/image/fetch/$s_!wdhL!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5513455f-be66-4a42-990f-f0e31ef00583_800x573.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>If the append transaction rolls back, the skip record also rolls back (there&#8217;s no event to worry about). If it commits, we have a durable record of exactly what was skipped.</p><p>In Emmett, I&#8217;m using a dedicated <em>emt_system_messages</em> table rather than reusing the regular <em>emt_messages</em> table or creating a simple &#8220;skipped events&#8221; table. This might seem like over-engineering&#8212;why not just create a simple table with projection ID and event position? Or why not just reuse the existing messages table?</p><p><strong>Why a dedicated system messages table?</strong></p><p>The regular <em>emt_messages</em> table is for business events&#8212;the actual domain events that drive your application. Mixing system-level concerns (like &#8220;this projection skipped this event during rebuild&#8221;) with business events pollutes the event log and makes it harder to reason about. A simple &#8220;skipped_events&#8221; table with just (projection_id, event_position) could be enough, but in the long term, it may be hard to maintain. As systems evolve, we&#8217;ll have more cases where we might want to skip events. Plus, having system events recorded with all metadata gives us full observability of the internals of our system. We could even make tables for projections and processors&#8217; status, built as read models from system events! Still, let&#8217;s hold our horses and get back to our use case.</p><p>The system messages table could mirror the structure of regular messages with:</p><ul><li><p>Global position sequencing (with all the transaction visibility handling from the <a href="https://event-driven.io/en/ordering_in_postgres_outbox/">outbox ordering article</a>)</p></li><li><p>Transaction ID tracking for proper visibility checks</p></li><li><p>Archiving support via <em>is_archived</em> flag</p></li><li><p>Partitioning for performance</p></li></ul><p>If we created a throwaway &#8220;skipped events&#8221; table, we&#8217;d need to solve all these problems again. We&#8217;d essentially be building a second event log with the same guarantees.</p><p>The skip could be stored as a system message where:</p><ul><li><p><strong>Stream ID</strong> = the projection identifier (name + version), so we can query all skips for a specific projection</p></li><li><p><strong>Message data</strong> = reference to the original event (sequence ID from the event log)</p></li><li><p><strong>Message metadata</strong> = processor ID, reason for skip, timestamp</p></li><li><p><strong>Message type</strong> = indicates this is a &#8220;skip during rebuild&#8221; system event</p></li></ul><p><strong>Won&#8217;t this table grow too much? It may, but we can archive skip events when they&#8217;re no longer needed.</strong></p><p>When the rebuild processor handles a skipped event, we could archive it. In Emmett, this means setting is_archived = true on the record. The table is partitioned by this flag. Archived records automatically move to a separate partition, which can even be on a different disk drive.</p><p>Why archive instead of delete?</p><ul><li><p>Audit trail: You can see what was skipped and when it was processed</p></li><li><p>Debugging: If something goes wrong, you have history to investigate</p></li><li><p>Idempotency: If a processor crashes and restarts, it won&#8217;t reprocess already - archived skips</p></li></ul><p>Later, we could define retention policies and clean up old archived records. We could do it more aggressively than for business events, since these are operational records, not business history. You might keep business events forever, but archived skip records only need to stick around long enough for debugging (days or weeks, not years).</p><p>The same processor that handles regular events for the projection could also process its skipped records. When it reads an event from the main event log and applies the projection, it also checks for and archives any corresponding skip record for that event position. This keeps everything in sync.</p><p>As mentioned, this also opens the door for other system events in the future - not just rebuild skips, but potentially failed projections, poison messages, or audit records. The infrastructure would already be in place, separated from the business event log.</p><h2>The Final Flow</h2><p>Let me walk through how this works in practice, bringing together all the pieces.</p><h3>During rebuild</h3><p>The projection status is &#8216;rebuilding&#8217;. The async rebuild processor works through historical events from the beginning. Meanwhile, normal operations continue. Events are appended, and inline projections check the status. When they see &#8216;rebuilding&#8217;, they skip the projection but record a skip message in emt_system_messages within the same transaction.</p><h3>When rebuild catches up</h3><p>The rebuild processor eventually reaches the current position where no new events are visible. At this point, it sets the status to &#8216;active&#8217;. From now on, new appends will process their inline projections normally.</p><p>But what about those skip records? The events they reference exist, but their projections were never applied.</p><h3>Draining the skipped events</h3><p>The rebuild processor (or a dedicated processor, or a manual trigger&#8212;your choice) now queries emt_system_messages for skip records belonging to this projection. Using the same transaction visibility rules from the outbox pattern (transaction_id &lt; pg_snapshot_xmin), it only sees skip records from committed transactions.</p><p>For each skip record:</p><ol><li><p>Find the referenced event in the event log (or use its data if stored in skipped event)</p></li><li><p>Apply the projection for that event</p></li><li><p>Archive the skip record (set is_archived = true)</p></li></ol><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!xE1k!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5445a586-1080-470f-bfe1-03689fe52bd9_800x730.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!xE1k!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5445a586-1080-470f-bfe1-03689fe52bd9_800x730.png 424w, https://substackcdn.com/image/fetch/$s_!xE1k!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5445a586-1080-470f-bfe1-03689fe52bd9_800x730.png 848w, https://substackcdn.com/image/fetch/$s_!xE1k!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5445a586-1080-470f-bfe1-03689fe52bd9_800x730.png 1272w, https://substackcdn.com/image/fetch/$s_!xE1k!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5445a586-1080-470f-bfe1-03689fe52bd9_800x730.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!xE1k!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5445a586-1080-470f-bfe1-03689fe52bd9_800x730.png" width="800" height="730" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/5445a586-1080-470f-bfe1-03689fe52bd9_800x730.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:730,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;mermaid7&quot;,&quot;title&quot;:&quot;mermaid7&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="mermaid7" title="mermaid7" srcset="https://substackcdn.com/image/fetch/$s_!xE1k!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5445a586-1080-470f-bfe1-03689fe52bd9_800x730.png 424w, https://substackcdn.com/image/fetch/$s_!xE1k!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5445a586-1080-470f-bfe1-03689fe52bd9_800x730.png 848w, https://substackcdn.com/image/fetch/$s_!xE1k!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5445a586-1080-470f-bfe1-03689fe52bd9_800x730.png 1272w, https://substackcdn.com/image/fetch/$s_!xE1k!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5445a586-1080-470f-bfe1-03689fe52bd9_800x730.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>The skip record and the event are committed in the same transaction. This gives us a simple invariant: if an event exists, either its projection was applied inline (status was &#8216;active&#8217;) or a skip record exists (status was &#8216;rebuilding&#8217;). There&#8217;s no third option where an event exists without a trace.</p><p>The drain process might find new skip records appearing. Transactions that were in flight during the status change, committed after we started draining. That&#8217;s fine. We keep querying until no more visible skip records exist. They will stop appearing as we alread stopped rebuilding processor, so no more inline projections should be skipped.</p><p>The &#8220;drain skipped events&#8221; phase can be automatically triggered by finishing projection rebuild. It could be handled as the 2nd phase of rebuild processor, or triggering a dedicated one. It could be also just handled by a human initiating the drain.</p><p>This flexibility lets user choose the approach based on the specific operational requirements.</p><h2>The Dead Letter Queue Pattern</h2><p>What we&#8217;ve built is essentially a <strong>Dead Letter Queue (DLQ)</strong>&#8212;a place where messages that couldn&#8217;t be processed normally are stored for later handling.</p><p>This pattern exists in every serious messaging system:</p><ul><li><p><strong>Apache Kafka:</strong> Dead Letter Topics for messages that fail consumer processing</p></li><li><p><strong>RabbitMQ:</strong> Dead letter exchanges for rejected or expired messages</p></li><li><p><strong>AWS SQS:</strong> Redrive policies that move messages to a DLQ after N failures</p></li><li><p><strong>Azure Service Bus:</strong> Built-in dead-letter sub-queues for each queue</p></li></ul><p>The pattern is universal because all messaging systems face the same fundamental problem: sometimes messages can&#8217;t be processed immediately, and you need a place to store them without blocking the rest of the system.</p><p>It&#8217;s a topic in its own right, as it&#8217;s not perfect and should be used cautiously. Many teams fail to apply them correctly. The DLQ becomes like a car alarm in a parking lot, technically signalling a problem, practically ignored by everyone. And that&#8217;s what I see in many systems: teams set up DLQs and do nothing about them.</p><p>It starts innocently. You configure the DLQ &#8220;just in case.&#8221; A few messages end up there during a deployment. Someone says, &#8220;We&#8217;ll look at it later.&#8221;</p><p>More messages accumulate. The DLQ becomes background noise&#8212;a number on a dashboard that nobody checks. Eventually, something critical lands there, and nobody notices until a customer complains.</p><p>That&#8217;s why in the discussed design, skip records aren&#8217;t meant to accumulate indefinitely. The rebuild processor drains them during completion.</p><p>Retention policies clean up archived records after a reasonable period. If skip records exist for too long, that&#8217;s a signal that something is wrong with the rebuild process - and users should know about it.</p><p>A DLQ is only helpful if it&#8217;s monitored, processed, and understood why messages end up there. Otherwise, it&#8217;s just a fancy way to lose data slowly rather than immediately.</p><h2>The Broader Lesson</h2><p>This article isn&#8217;t really about PostgreSQL advisory locks or projection rebuilding. It&#8217;s about how we approach problems in distributed systems.</p><p>When we find a race condition, the instinct is to fix it. Add a lock. Add a check. Add a coordination phase. But each &#8220;fix&#8221; often just moves the race condition somewhere else. We went through three attempts&#8212;waiting for transactions, using transaction ID boundaries, locking appends&#8212;and each one failed for a different reason. We weren&#8217;t solving the problem; we were relocating it.</p><p>At some point, I had to ask myself: am I making this system more reliable, or just more complicated?</p><p>The answer came when I changed the question. Instead of asking &#8220;how do I prevent events from being skipped?&#8221; I asked, &#8220;How do I know when an event was skipped, and how do I make sure it gets processed eventually?&#8221;</p><p>The first question has no good answer, not without blocking appends, which defeats the purpose. The second question is straightforward: record the skip in the same transaction as the event, and process it later.</p><p>A system isn&#8217;t trustworthy because it never fails. That&#8217;s impossible for anything sufficiently complex. A system is trustworthy because you know when it can fail, how it will fail, and how to recover. The skip tracking approach doesn&#8217;t prevent failures during the transition period. It makes them visible and recoverable. That&#8217;s a stronger guarantee than complex coordination machinery with hidden edge cases.</p><p><strong>Sometimes the best engineering decision is to accept what you can&#8217;t control and focus on what you can.</strong> We can&#8217;t control PostgreSQL&#8217;s transaction visibility rules. We can&#8217;t see into uncommitted transactions. We can&#8217;t make the append decision and the rebuild completion atomic without stopping the world.</p><p>What we can control is recording skipped items, ensuring those skips are processed, and making the whole thing observable. That&#8217;s enough.</p><p>The blind spot I found wasn&#8217;t really about the specific race condition. It was a reminder that distributed systems have fundamental constraints we can&#8217;t engineer around, at least not without trade-offs worse than the original problem.</p><p>Transaction isolation and concurrent systems mean we can&#8217;t have zero-downtime rebuilds with perfect inline projection consistency and no coordination overhead, all at the same time.</p><p>Something has to give.</p><p>What we get instead is explicit tracking of what was skipped, guaranteed eventual processing via the system messages table, observability into the transition period, and crash recovery that doesn&#8217;t lose data. The read model might be briefly inconsistent during the transition, but we know exactly what&#8217;s missing, and we have a clear path to fix it.</p><p>That&#8217;s the kind of system I can reason about and trust.</p><div><hr></div><p><strong>If you&#8217;re dealing with similar challenges, I&#8217;m happy to help through <a href="mailto:oskar@event-driven.io">consulting or mentoring</a>.</strong> You can also join the discussion in our <a href="https://discord.gg/fTpqUTMmVa">Emmett Discord</a>&#8212;we have a nice community working through these exact problems.</p><p>Or check also other related resources:</p><ul><li><p><a href="https://event-driven.io/en/rebuilding_event_driven_read_models/">Rebuilding Event-Driven Read Models in a safe and resilient way</a></p></li><li><p><a href="https://event-driven.io/en/ordering_in_postgres_outbox/">How Postgres sequences issues can impact your messaging guarantees</a></p></li><li><p><a href="https://event-driven.io/en/projections_and_read_models_in_event_driven_architecture/">Guide to Projections and Read Models in Event-Driven Architecture</a>,</p></li><li><p><a href="https://www.architecture-weekly.com/p/distributed-locking-a-practical-guide">Distributed Locking: A Practical Guide</a>,</p></li><li><p><a href="https://event-driven.io/en/consumers_processors_in_emmett/">Consumers, projectors, reactors and all that messaging jazz in Emmett</a>,</p></li><li><p><a href="https://event-driven.io/en/how_to_scale_projections_in_the_event_driven_systems/">How to scale projections in the event-driven systems?</a>,</p></li><li><p><a href="https://event-driven.io/en/checkpointing_message_processing/">Checkpointing the message processing</a>,</p></li><li><p><a href="https://event-driven.io/en/lets_talk_about_positions_in_event_stores/">Let&#8217;s talk about positions in event stores</a>.</p></li></ul><p>Cheers!</p><p>Oskar</p><p>p.s. <strong>Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to <a href="https://www.icrc.org/en/donate/ukraine">Red Cross</a>, <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian organisation</a> or <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">donate Ambulances for Ukraine</a>.</p>]]></content:encoded></item><item><title><![CDATA[Rebuilding Event-Driven Read Models in a safe and resilient way]]></title><description><![CDATA[And what distributed locking, PostgreSQL Advisory Locks have to do with that]]></description><link>https://www.architecture-weekly.com/p/rebuilding-event-driven-read-models</link><guid isPermaLink="false">https://www.architecture-weekly.com/p/rebuilding-event-driven-read-models</guid><dc:creator><![CDATA[Oskar Dudycz]]></dc:creator><pubDate>Mon, 05 Jan 2026 19:01:10 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!9xrQ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75073730-6b26-4f4a-ac3e-74b77d704aee_800x500.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!9xrQ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75073730-6b26-4f4a-ac3e-74b77d704aee_800x500.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!9xrQ!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75073730-6b26-4f4a-ac3e-74b77d704aee_800x500.png 424w, https://substackcdn.com/image/fetch/$s_!9xrQ!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75073730-6b26-4f4a-ac3e-74b77d704aee_800x500.png 848w, https://substackcdn.com/image/fetch/$s_!9xrQ!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75073730-6b26-4f4a-ac3e-74b77d704aee_800x500.png 1272w, https://substackcdn.com/image/fetch/$s_!9xrQ!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75073730-6b26-4f4a-ac3e-74b77d704aee_800x500.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!9xrQ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75073730-6b26-4f4a-ac3e-74b77d704aee_800x500.png" width="800" height="500" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/75073730-6b26-4f4a-ac3e-74b77d704aee_800x500.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:500,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;cover&quot;,&quot;title&quot;:&quot;cover&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="cover" title="cover" srcset="https://substackcdn.com/image/fetch/$s_!9xrQ!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75073730-6b26-4f4a-ac3e-74b77d704aee_800x500.png 424w, https://substackcdn.com/image/fetch/$s_!9xrQ!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75073730-6b26-4f4a-ac3e-74b77d704aee_800x500.png 848w, https://substackcdn.com/image/fetch/$s_!9xrQ!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75073730-6b26-4f4a-ac3e-74b77d704aee_800x500.png 1272w, https://substackcdn.com/image/fetch/$s_!9xrQ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75073730-6b26-4f4a-ac3e-74b77d704aee_800x500.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Let&#8217;s make a soup today: a blog soup. We&#8217;ll mix multiple ingredients like:</p><ul><li><p>events (obviously),</p></li><li><p>read models,</p></li><li><p>inline and async projections,</p></li><li><p>rebuilding read models,</p></li><li><p>backfilling new ones with data from existing events,</p></li><li><p>scaling async processing horizontally,</p></li><li><p>distributed locking,</p></li><li><p>PostgreSQL and its Advisory Locks.</p></li></ul><p>Sounds a lot? Well, the soup should be nutritious.</p><p>In an event-driven way, after handling business logic, we record new facts and call them events. They gather information about what has happened. That brings many benefits, such as business observability by keeping a log of them. Especially if we&#8217;re doing Event Sourcing, we can make the next decision based on them.</p><p>Typically, we&#8217;re using events in two ways:</p><ul><li><p>reacting to them, triggering and integrating the steps of our business workflow,</p></li><li><p>projecting them, and getting the flattened interpretation of our system state inside read models.</p></li></ul><p>Such processing can happen asynchronously, but doesn&#8217;t have to. If we&#8217;re using a transactional database (like PostgreSQL, SQLite, or even MongoDB), we can update our read models in the same atomic transaction that stores new events. Such a process is typically called <em>inline projection</em>. Event stores like <a href="https://event-driven.io/en/emmett_projections_testing/">Emmett</a> and <a href="https://event-driven.io/en/projections_in_marten_explained/">Marten</a> allows that. Still, you can do the same if you&#8217;re using <a href="https://event-driven.io/en/outbox_inbox_patterns_and_delivery_guarantees_explained/">outbox pattern</a>, then you can achieve the same without Event Sourcing.</p><p>Inline processing is tempting because we&#8217;re getting immediate consistency. Yet, there&#8217;s no free lunch here. We&#8217;re slowing down our event append as we need to process more, and our transactions will be open longer, which can cause deadlocks, etc. We also can&#8217;t take advantage of batching event processing. </p><p>Also, as <a href="https://www.linkedin.com/in/bastian-waidelich-84865221?lipi=urn%3Ali%3Apage%3Ad_flagship3_profile_view_base_contact_details%3B8r9f%2Fno%2BRbyTnp4YOzWo9g%3D%3D">Bastian Waidelich</a> rightfully pointed out to me, using inline projections also increases the coupling and fragility of our business logic. If the inline projection fails (due to a bug in its projection logic, a database constraint or other random issue), then we won&#8217;t be able to append our event, which is counterintuitive, as why would read model block our business logic (e.g. confirming shopping cart).</p><p>My thumb rule is that for single stream, simple projections, I prefer inline projections, but for more complex or workflow processing, I&#8217;d go with async.</p><p>The big benefit of a durable event log is that we can correct past mistakes and gain more insights from existing data.</p><p>How does it look in practice? Let&#8217;s say we initially had a basic read model that showed a summary of the specific shopping cart. In TypeScript this could look as follows:</p><pre><code><code>type ShoppingCartSummary = {
  _id?: string;
  productItemsCount: number;
  totalAmount: number;
};</code></code></pre><p>Besides the data, we also need a method that represents how we apply events on top of the existing state to get the next, evolved state.</p><pre><code><code>const evolve = (
  document: ShoppingCartSummary | null,
  event: ProductItemAdded | ProductItemRemoved,
): ShoppingCartSummary =&gt; {
  document = document ?? { totalAmount: 0, productItemsCount: 0 };

  switch (type) {
    case 'ProductItemAdded':
      return withAdjustedTotals({
        document,
        productItem: event.data.productItem,
        by: 'adding',
      });
    case 'ProductItemRemoved':
      return withAdjustedTotals({
        document,
        productItem: event.data.productItem,
        by: 'removing',
      });
  }
};

const withAdjustedTotals = (options: {
  document: ShoppingCartSummary;
  productItem: PricedProductItem;
  by: 'adding' | 'removing';
}) =&gt; {
  const { document, productItem, by } = options;
  const plusOrMinus = by === 'adding' ? 1 : -1;

  return {
    ...document,
    totalAmount:
      document.totalAmount +
      productItem.unitPrice * productItem.quantity * plusOrMinus,
    productItemsCount:
      document.productItemsCount + productItem.quantity * plusOrMinus,
  };
};</code></code></pre><p>Sounds fine, but well, it may appear that either we, implementing it, or our business, through requirements, forgot about some requirements. Or we didn&#8217;t forget anything, but just requirements evolved as they tend to always do.</p><p>What if we need to handle now also:</p><ul><li><p>Show the shopping cart status and show whether the shopping cart is open, confirmed or cancelled.</p></li><li><p>not only show totals, but also a list of product items with their details.</p></li></ul><p>Our new read model would look like:</p><pre><code><code>type ShoppingCartSummary = {
  _id?: string;
  productItemsCount: number;
  totalAmount: number;
  status: 'Opened' | 'Confirmed' | 'Cancelled';
  productItems: ProductItem[];
};

type ProductItem = {
  productId: string;
  name: string;
  quantity: number;
  unitPrice: number;
}</code></code></pre><p>And here we should ask ourselves the following questions:</p><ul><li><p>Is it really the same read model or another one that&#8217;ll be used in another place in our UI? Maybe the initial just shows basic data in the menu bar, and this will be used as the summary before confirmation?</p></li><li><p>If the read model is the same, then are you fine with downtime where you clean the old data, and reprocess events?</p></li><li><p>If it&#8217;s the new read model, do we need to backfill it with the old data?</p></li></ul><p>There&#8217;s no best practice here; we need to do the drill and be prepared for multiple options.</p><h2>Add new vs update existing.</h2><p>In our case, my initial guess would be that this should be a new read model. We need to add significantly more data and new event handling for confirmation and cancellation.</p><p>You could say that:</p><blockquote><p>Why add a new read model with similar data? Can&#8217;t we just do a subquery?</p></blockquote><p>Of course, you can. I wrote about that more in <a href="https://event-driven.io/en/how_to_create_projections_of_events_for_nested_object_structures/">How to create projections of events for nested object structures?</a>. This can make sense if those read models will always evolve together.</p><p>If you need to run multiple <em>views</em> on the same read models, you&#8217;re increasing coupling. As such, when you&#8217;re rebuilding the read model, then potential downtime will impact both. Also, each time you adjust one view, ensure you haven&#8217;t broken the others.</p><p>You&#8217;re getting a smaller storage size, and potentially don&#8217;t need to remember multiple read models, but are doing it just one.</p><p>My experience shows that optimising for the end storage until we check that it&#8217;s too big isn&#8217;t a good driver. Nowadays, storage is cheap, so my default is to keep read models separated, and also not reuse the same evolve logic. A bit of code duplication won&#8217;t harm us, but we&#8217;ll see benefits as our models evolve. We&#8217;ll decrease the cognitive load.</p><p>Still, if those models are indeed the same, or we bet they&#8217;ll constantly evolve together, then it could be fine to reuse them.</p><h2>In place update, blue/green rebuild and backfilling data</h2><p>Ok, I was already using <em>rebuilding</em> word multiple times. But how do we actually do it?</p><p>If you&#8217;ve read articles on <a href="https://event-driven.io/en/checkpointing_message_processing/">checkpointing</a> or <a href="https://event-driven.io/en/lets_talk_about_positions_in_event_stores/">positions in event stores</a>, you already know that each event in the event store/outbox can have its unique, monotonic position. We can subscribe to notifications about new events and process them one by one. That&#8217;s how <a href="https://event-driven.io/en/consumers_processors_in_emmett/">consumers and processors</a> work in <a href="https://github.com/event-driven-io/emmett">Emmett</a>. Once we process a specific event, we can store the checkpoint in the end storage. This enables resilient failover when our processor dies for some reason. When we restart, we&#8217;ll read the last processed checkpoint first and start listening for events from that point.</p><p>We could reuse this not only for failover but also for rebuilds. Instead of starting processing from the last known checkpoint, we could reset the checkpoint in our database to a specific point (e.g., the beginning of the log). Then we&#8217;ll get recorded events again.</p><p>And this is the moment we need to decide whether to do an in-place update or a blue/green rebuild.</p><p>In-place means that we&#8217;re the same storage target. We need to truncate it and then apply data from scratch, typically. The downside is that when we clear it, our read models won&#8217;t be up to date with the current state of our event store. We already have newer events recorded, but our read models are empty. We need to fill them in.</p><p>The same happens when we create a new read model based on the existing events. When we create a new read model, it won&#8217;t magically contain data from existing events.</p><p>Typically, we need to spin up a background worker (e.g., <a href="https://event-driven.io/en/consumers_processors_in_emmett/">Emmett consumer with a projector plugged in</a>) that pulls all events since the beginning and runs projection logic. Obviously, that means we need to run it asynchronously, and depending on the data size, we&#8217;ll need to wait until it catches up. During that time, querying for the data will return outdated information - eventual consistency in practice.</p><p>That&#8217;s why there&#8217;s another option. Instead of truncating existing data, we can keep it as it is, or even keep updating it in the old form. Having that, we can build a new read model in parallel. This read model could have a different name, or just a suffix, e.g. V2, V2.0.1, whatever we find helpful.</p><p>Then, once this new read model catches up (so it has processed all events, or is close enough to the latest event with a defined threshold), we can switch queries from the old to the new read model storage.</p><p>This is actually the preferred way, but it&#8217;s a bit more challenging when it comes to dynamically switching the query target. If you&#8217;re using Pongo, that&#8217;s not that hard, since you just switch the text-based collection name, which is just another table. But if you&#8217;re using an ORM, adding a new table dynamically and mapping it can be much more challenging.</p><h2>Concurrency issues while (re)building read models</h2><p>To make it harder, eventual consistency is not the only challenge we&#8217;ll face. We also need to deal with concurrency and parallel processing.</p><p>What should we do when a new event is appended while we&#8217;re rebuilding/backfilling the inline projection? If we try to update the end storage in the middle of the processing, we can end up with an inconsistent or erroneous state.</p><p>Another issue is what to do if we&#8217;re in a Kubernetes-like setup and don&#8217;t have full control over the number of instances of the same service? Or what happens if we accidentally spin up multiple rebuild workers?</p><p>Then we&#8217;re doomed, or at least the consistency of our read model data.</p><p>How do we solve it? Let me explain my plan for <a href="https://github.com/event-driven-io/emmett">Emmett</a>.</p><h2>Distributed Locking and PostgreSQL advisory locks</h2><p>I encourage you to check my other article: <a href="https://www.architecture-weekly.com/p/distributed-locking-a-practical-guide">Distributed Locking: A Practical Guide</a>. Yet, don&#8217;t worry, I won&#8217;t leave you with Read-The-Fucking-Manual type of answer.</p><p>Distributed locks are a fundamental tool for coordinating concurrency across systems. We can use a central place, typically scalable on its own, that&#8217;ll be used in multiple instances of our service, to ensure that exactly one can request a lock and run specific code.</p><p>In the mentioned article, I described multiple options and popular tools for handling that (e.g., Redis, Zookeeper, Kubernetes Replica Sets), as well as PostgreSQL and its Advisory Locks. Let me focus today on the last one and describe how I want to use it in <a href="https://github.com/event-driven-io/emmett">Emmett</a>&#8217;s PostgreSQL projection handling.</p><p>PostgreSQL gives us two options for coordination.</p><p><strong>Row-level locks</strong> lock individual table rows. You do <em>SELECT &#8230; FOR UPDATE</em>, and anyone else trying to modify that row waits. The lock is tied to the specific row in a table.</p><pre><code><code>BEGIN;
SELECT * FROM some_table WHERE id = @key FOR UPDATE;
-- make changes
COMMIT;</code></code></pre><p>Such locks are straightforward, as we explicitly state what we want to lock. We could define the following table for projections:</p><pre><code><code>CREATE TABLE IF NOT EXISTS emt_projections(
      version                       INT                    NOT NULL DEFAULT 1,  
      type                          VARCHAR(1)             NOT NULL,
      name                          TEXT                   NOT NULL,
      status                        TEXT                   NOT NULL,  
      PRIMARY KEY (name, version)
);</code></code></pre><p>The background worker could lock the projection by a specific name and version and set the status to &#8220;rebuilding&#8221;. Then, during handling the inline projection, we could use the same lock and check whether the status is &#8220;active&#8221;; if not, skip processing. Using a lock-in inline projection would also prevent the rebuilding process from starting, as they wouldn&#8217;t acquire the lock.</p><p>And that&#8217;s a nuke option, as it would work but could create a performance problem. If every inline projection needs to grab a row lock on a coordination row, they will all be processed sequentially, one at a time. That&#8217;s a throughput killer when you&#8217;re appending thousands of events per second. Each of these append would require access to the lock for the specific projection type (e.g. our shopping cart summary), making not only updates but also event appends sequential. That&#8217;s not acceptable for most cases.</p><p><strong><a href="https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS">Advisory locks</a></strong> are different. They&#8217;re locks on arbitrary integers. PostgreSQL doesn&#8217;t care what the integer means&#8212;it just manages who holds the lock. No table rows involved. It also allows two modes: exclusive or shared.</p><pre><code><code>-- Shared lock: many can hold simultaneously
SELECT pg_try_advisory_xact_lock_shared(12345);

-- Exclusive lock: only one holder, blocks shared locks
SELECT pg_advisory_lock(12345);</code></code></pre><p>Those locks are either scoped to an open connection session or an opened transaction (with <em>xact</em> with name) and released automatically once they end.</p><p>Shared locks allow multiple sessions to access the lock with the same value. Exclusive lock blocks both other exclusive locks and new shared locks.</p><p><strong>This allows a design where shared locks are used for readers</strong>, and exclusive locks for writers and maps directly to our problem:</p><ul><li><p>Inline projections take <strong>shared</strong> locks as they run concurrently, and just need to check if there&#8217;s no async job updating these projections</p></li><li><p>Rebuilds take <strong>exclusive</strong> locks - they block inlines and other instances of async processing.</p></li></ul><p>We just need to do one more thing: since advisory locks take integers, we need to map our projection name and version to them. We can do it by a consistent hash algorithm, either in the application code or in PostgreSQL.</p><p>PostgreSQL provides a built-in MD5 hash function. It&#8217;s not perfect, as it&#8217;s not a sophisticated hash, but it&#8217;s fast enough and predictable. In our case, we won&#8217;t have thousands of projections in our application, so the risk of a <a href="https://en.wikipedia.org/wiki/Hash_collision">hash collision</a> is negligible. If you&#8217;re still worried it&#8217;s too high, we could store id in our projections table and use it instead of hash-mapping. Still, if we used md5 function, it could look as follows:</p><pre><code><code>// shared for inline projections
SELECT pg_try_advisory_xact_lock_shared(
        ('x' || substr(md5(?), 1, 16))::bit(64)::bigint
) AS acquired;

// exclusive for inline projections
SELECT pg_try_advisory_xact_lock(
        ('x' || substr(md5(?), 1, 16))::bit(64)::bigint
) AS acquired;</code></code></pre><p>Where as query param, we&#8217;d pass the joined projection name and its version.</p><p>Thanks to that, multiple inline projections can access the lock if it&#8217;s not held exclusively by the async (re)building worker. Thanks to that, we&#8217;re not blocking event appends because of the lock on the inline projections.</p><p>An exclusive lock can be held only when there&#8217;s no single inline projection being applied at the moment.</p><p>What&#8217;s also cool is that advisory locks can be used with two strategies: fail fast when the lock is held, or wait for the lock to be released. The first option would be useful for inline projections, and the second for rebuilds.</p><p>Is that all? Well, not quite.</p><h2>Why advisory locks alone aren&#8217;t enough</h2><p>Advisory locks have a gap: they&#8217;re session-scoped. If the connection holding the lock dies, the lock releases automatically. For most cases, that&#8217;s fine and expected, but think through this scenario:</p><ol><li><p>We have 1000 events in our event store.</p></li><li><p>Rebuild starts, acquires exclusive lock.</p></li><li><p>It truncates the projection and processes events 1-500.</p></li><li><p>Connection dies (network blip, out of memory kill, whatever).</p></li><li><p>Lock releases automatically.</p></li><li><p>Projection contains data only for events 1-500.</p></li><li><p>Inlines see no lock, start processing.</p></li><li><p>Inline applies event 1001, ending up with a potentially corrupted state (as there may be some events between 500 and 1001 that would impact the state).</p></li></ol><p>Advisory locks can&#8217;t persist across connection failures. We need to add something that does.</p><h2>The hybrid-locking approach</h2><p>Emmett already maintains tables for tracking processors and projections. The relevant ones:</p><pre><code><code>CREATE TABLE IF NOT EXISTS emt_projections(
    version         INT         NOT NULL DEFAULT 1,  
    type            VARCHAR(1)  NOT NULL,
    name            TEXT        NOT NULL,
    partition       TEXT        NOT NULL DEFAULT 'emt:default',
    kind            TEXT        NOT NULL, 
    status          TEXT        NOT NULL, 
    definition      JSONB       NOT NULL DEFAULT '{}'::jsonb, 
    PRIMARY KEY (name, partition, version)
) PARTITION BY LIST (partition);

CREATE TABLE IF NOT EXISTS emt_processors(
    last_processed_transaction_id XID8    NOT NULL,
    version                       INT     NOT NULL DEFAULT 1,
    processor_id                  TEXT    NOT NULL,
    partition                     TEXT    NOT NULL DEFAULT 'emt:default',
    status                        TEXT    NOT NULL DEFAULT 'stopped', 
    last_processed_checkpoint     TEXT    NOT NULL,    
    processor_instance_id         TEXT    DEFAULT 'emt:unknown',
    PRIMARY KEY (processor_id, partition, version)
) PARTITION BY LIST (partition);</code></code></pre><p>The <em>status</em> column in the projections table can do what advisory locks can&#8217;t: persist status between connection crashes. Even if the connection dies, <em>status = &#8216;rebuilding&#8217;</em> stays in the table. Inlines could check this and skip processing.</p><p>The processor&#8217;s table tracks checkpoint progress. When a rebuild runs, it updates <em>last_processed_checkpoint</em> as it goes. If it crashes and restarts, it can resume from where it left off rather than starting over.</p><p>Potentially, we could reuse the processor&#8217;s table, but having a dedicated projection table could also be useful for diagnostics (e.g., storing the projection definition) and for switching queries when a new projection version catches up. We could select the highest active projection version.</p><p>Having that, the query checking if the inline projection should be processed or skipped could look as follows:</p><pre><code><code>WITH lock_check AS (
    SELECT pg_try_advisory_xact_lock_shared(
        ('x' || substr(md5($1), 1, 16))::bit(64)::bigint
    ) AS acquired
),
status_check AS (
    SELECT status = 'active' AS is_active
    FROM emt_projections
    WHERE partition = $2 AND name = $3 AND version = $4
)
SELECT
    COALESCE((SELECT acquired FROM lock_check), false) AS acquired,
    COALESCE((SELECT is_active FROM status_check), true) AS is_active;</code></code></pre><p>Two checks, both must pass:</p><ol><li><p>Can we get a shared advisory lock? Fails if a rebuild holds exclusive.</p></li><li><p>Is the projection status &#8216;active&#8217;? Fails if it&#8217;s &#8216;rebuilding&#8217;.</p></li></ol><p>Then for async (re)building worker:</p><pre><code><code>WITH lock_check AS (
    SELECT pg_try_advisory_lock(
        ('x' || substr(md5($1), 1, 16))::bit(64)::bigint
    ) AS acquired
),
ownership_check AS (
    INSERT INTO emt_processors (
        processor_id,
        partition,
        version,
        processor_instance_id,
        status,
        last_processed_checkpoint,
        last_processed_transaction_id
    )
    VALUES ($2, $3, $4, $5, 'running', '0', '0'::xid8)
    ON CONFLICT (processor_id, partition, version) DO UPDATE
    SET processor_instance_id = $5,
        status = 'running'
    WHERE 
       -- We already own it
       emt_processors.processor_instance_id = $5 
       -- Unclaimed
       OR emt_processors.processor_instance_id = 'emt:unknown' 
       -- Previous instance finished or crashed
       OR emt_processors.status = 'stopped
    RETURNING last_processed_checkpoint
)
SELECT
    COALESCE((SELECT acquired FROM lock_check), false) AS acquired,
    (SELECT last_processed_checkpoint FROM ownership_check) AS checkpoint;</code></code></pre><p>Together Advisory locks prevent the race at the transition point where the connection was closed, and the rebuilding job is restarting. The status check handles crash recovery</p><p>The rebuild acquires the exclusive lock <em>before</em> updating the status. It waits for in-flight inlines (which hold shared locks) to finish.</p><p>If the checkpoint is null, the UPDATE matched no rows, then another instance owns the processor and is actively running. Back off.</p><p>If both succeed, you own the lock and the processor. The checkpoint tells you where to resume from (with a fallback to the beginning). Only then does it flip the status and start async processing.</p><p>If the rebuild crashes, the lock is released, but the status remains &#8216;rebuilding&#8217;. Inlines check status and skip.</p><h2>The scenarios walkthrough</h2><p>As a single good image speaks more than thousands of words, let me give you some diagrams to summarise how it works.</p><p><strong>1. During inline projection while event appends:</strong></p><ul><li><p>Each inline projection grabs a shared lock before applying,</p></li><li><p>Multiple inlines can run concurrently (shared locks are compatible),</p></li><li><p>Projection updates happen normally.</p></li></ul><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Mrgj!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F370212d5-cdbc-4712-9553-60c9c4b3bc40_800x695.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Mrgj!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F370212d5-cdbc-4712-9553-60c9c4b3bc40_800x695.png 424w, https://substackcdn.com/image/fetch/$s_!Mrgj!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F370212d5-cdbc-4712-9553-60c9c4b3bc40_800x695.png 848w, https://substackcdn.com/image/fetch/$s_!Mrgj!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F370212d5-cdbc-4712-9553-60c9c4b3bc40_800x695.png 1272w, https://substackcdn.com/image/fetch/$s_!Mrgj!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F370212d5-cdbc-4712-9553-60c9c4b3bc40_800x695.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Mrgj!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F370212d5-cdbc-4712-9553-60c9c4b3bc40_800x695.png" width="800" height="695" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/370212d5-cdbc-4712-9553-60c9c4b3bc40_800x695.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:695,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;mermaid1&quot;,&quot;title&quot;:&quot;mermaid1&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="mermaid1" title="mermaid1" srcset="https://substackcdn.com/image/fetch/$s_!Mrgj!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F370212d5-cdbc-4712-9553-60c9c4b3bc40_800x695.png 424w, https://substackcdn.com/image/fetch/$s_!Mrgj!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F370212d5-cdbc-4712-9553-60c9c4b3bc40_800x695.png 848w, https://substackcdn.com/image/fetch/$s_!Mrgj!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F370212d5-cdbc-4712-9553-60c9c4b3bc40_800x695.png 1272w, https://substackcdn.com/image/fetch/$s_!Mrgj!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F370212d5-cdbc-4712-9553-60c9c4b3bc40_800x695.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><strong>2. When a rebuild starts:</strong></p><ul><li><p>Rebuild grabs an exclusive lock (waits for any in-flight inlines to finish),</p></li><li><p>Marks projection as &#8220;rebuilding&#8221;,</p></li><li><p>New inlines see the lock or status and skip,</p></li><li><p>Rebuild processes all historical events,</p></li><li><p>Marks the projection as &#8220;active&#8221; and releases the lock,</p></li><li><p>Inlines resume.</p></li></ul><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!dgRz!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5d3af5c7-33e4-428f-965c-798c0630daf9_800x583.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!dgRz!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5d3af5c7-33e4-428f-965c-798c0630daf9_800x583.png 424w, https://substackcdn.com/image/fetch/$s_!dgRz!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5d3af5c7-33e4-428f-965c-798c0630daf9_800x583.png 848w, https://substackcdn.com/image/fetch/$s_!dgRz!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5d3af5c7-33e4-428f-965c-798c0630daf9_800x583.png 1272w, https://substackcdn.com/image/fetch/$s_!dgRz!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5d3af5c7-33e4-428f-965c-798c0630daf9_800x583.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!dgRz!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5d3af5c7-33e4-428f-965c-798c0630daf9_800x583.png" width="800" height="583" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/5d3af5c7-33e4-428f-965c-798c0630daf9_800x583.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:583,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;mermaid2&quot;,&quot;title&quot;:&quot;mermaid2&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="mermaid2" title="mermaid2" srcset="https://substackcdn.com/image/fetch/$s_!dgRz!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5d3af5c7-33e4-428f-965c-798c0630daf9_800x583.png 424w, https://substackcdn.com/image/fetch/$s_!dgRz!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5d3af5c7-33e4-428f-965c-798c0630daf9_800x583.png 848w, https://substackcdn.com/image/fetch/$s_!dgRz!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5d3af5c7-33e4-428f-965c-798c0630daf9_800x583.png 1272w, https://substackcdn.com/image/fetch/$s_!dgRz!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5d3af5c7-33e4-428f-965c-798c0630daf9_800x583.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><strong>3. If rebuild crashes:</strong></p><ul><li><p>Lock releases automatically</p></li><li><p>Status stays &#8220;rebuilding&#8221;</p></li><li><p>Inlines keep skipping until another rebuild completes</p></li></ul><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!s8cJ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F471bbf4f-801c-41ac-9a0b-797ef45297a0_800x783.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!s8cJ!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F471bbf4f-801c-41ac-9a0b-797ef45297a0_800x783.png 424w, https://substackcdn.com/image/fetch/$s_!s8cJ!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F471bbf4f-801c-41ac-9a0b-797ef45297a0_800x783.png 848w, https://substackcdn.com/image/fetch/$s_!s8cJ!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F471bbf4f-801c-41ac-9a0b-797ef45297a0_800x783.png 1272w, https://substackcdn.com/image/fetch/$s_!s8cJ!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F471bbf4f-801c-41ac-9a0b-797ef45297a0_800x783.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!s8cJ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F471bbf4f-801c-41ac-9a0b-797ef45297a0_800x783.png" width="800" height="783" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/471bbf4f-801c-41ac-9a0b-797ef45297a0_800x783.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:783,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;mermaid3&quot;,&quot;title&quot;:&quot;mermaid3&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="mermaid3" title="mermaid3" srcset="https://substackcdn.com/image/fetch/$s_!s8cJ!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F471bbf4f-801c-41ac-9a0b-797ef45297a0_800x783.png 424w, https://substackcdn.com/image/fetch/$s_!s8cJ!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F471bbf4f-801c-41ac-9a0b-797ef45297a0_800x783.png 848w, https://substackcdn.com/image/fetch/$s_!s8cJ!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F471bbf4f-801c-41ac-9a0b-797ef45297a0_800x783.png 1272w, https://substackcdn.com/image/fetch/$s_!s8cJ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F471bbf4f-801c-41ac-9a0b-797ef45297a0_800x783.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><strong>4. Multiple async processors:</strong></p><ul><li><p>Each processor tries to acquire the exclusive lock,</p></li><li><p>First one wins, others wait or skip,</p></li><li><p>Guarantees that only one processor handles a projection at a time.</p></li></ul><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!lA3t!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0788fea4-468c-4de6-8f8d-5c754e82fa93_800x822.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!lA3t!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0788fea4-468c-4de6-8f8d-5c754e82fa93_800x822.png 424w, https://substackcdn.com/image/fetch/$s_!lA3t!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0788fea4-468c-4de6-8f8d-5c754e82fa93_800x822.png 848w, https://substackcdn.com/image/fetch/$s_!lA3t!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0788fea4-468c-4de6-8f8d-5c754e82fa93_800x822.png 1272w, https://substackcdn.com/image/fetch/$s_!lA3t!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0788fea4-468c-4de6-8f8d-5c754e82fa93_800x822.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!lA3t!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0788fea4-468c-4de6-8f8d-5c754e82fa93_800x822.png" width="800" height="822" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/0788fea4-468c-4de6-8f8d-5c754e82fa93_800x822.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:822,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;mermaid4&quot;,&quot;title&quot;:&quot;mermaid4&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="mermaid4" title="mermaid4" srcset="https://substackcdn.com/image/fetch/$s_!lA3t!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0788fea4-468c-4de6-8f8d-5c754e82fa93_800x822.png 424w, https://substackcdn.com/image/fetch/$s_!lA3t!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0788fea4-468c-4de6-8f8d-5c754e82fa93_800x822.png 848w, https://substackcdn.com/image/fetch/$s_!lA3t!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0788fea4-468c-4de6-8f8d-5c754e82fa93_800x822.png 1272w, https://substackcdn.com/image/fetch/$s_!lA3t!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0788fea4-468c-4de6-8f8d-5c754e82fa93_800x822.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><strong>5. When you add a new projection:</strong></p><ul><li><p>No status row exists yet,</p></li><li><p>Inlines skip automatically,</p></li><li><p>First rebuild creates the row and backfills data.</p></li></ul><h2>TLDR</h2><p>Just like making delicious soup, designing robust, fault-tolerant and performant distributed systems is not that easy. Building an event store is not that hard. But only if we exclude the async processing part.</p><p>I hope this walkthrough covers both the conceptual and practical aspects of handling projection rebuilds.</p><p>We used PostgreSQL and Advisory Locks, as PostgreSQL is cool and is a driving force in <a href="https://github.com/event-driven-io/emmett">Emmett</a>. But all the same principles apply to other tools and storage (with their specifics).</p><p>I explained why advisory locks and status columns complement each other:</p><ul><li><p><strong>Advisory locks</strong> handle the fast path (in-memory, no disk I/O for normal operations) and prevent races at transition points (rebuild can&#8217;t start until in-flight inlines finish)</p></li><li><p><strong>Status column</strong> handles crash recovery (persists across connection failures) and new projection bootstrapping (inlines skip until first rebuild completes)</p></li></ul><p>Neither alone is sufficient. Together, they provide the guarantees we need without external infrastructure. Just PostgreSQL doing what PostgreSQL does.</p><p>The cost on the hot path is microseconds: one in-memory lock check, one indexed read on a tiny cached table. For most systems, that&#8217;s an acceptable tradeoff.</p><p>I hope this article also shows you how to use distributed locking in practice.</p><p><strong>Please tell me your thoughts and concerns, especially if you see any blind spots in this design!</strong> You can do that in our <a href="https://discord.gg/fTpqUTMmVa">Emmett Discord</a>, come on in, we have a nice community!</p><p><strong>If you&#8217;re dealing with such issues, I&#8217;m happy to help you through consulting or mentoring. <a href="mailto:oskar@event-driven.io">Contact me</a> and we&#8217;ll find a way to unblock you!</strong></p><p>Or check also other related resources:</p><ul><li><p><a href="https://github.com/event-driven-io/emmett/pull/286">Emmett&#8217;s Pull Request implementing described approach</a></p></li><li><p><a href="https://event-driven.io/en/projections_and_read_models_in_event_driven_architecture/">Guide to Projections and Read Models in Event-Driven Architecture</a>,</p></li><li><p><a href="https://www.architecture-weekly.com/p/distributed-locking-a-practical-guide">Distributed Locking: A Practical Guide</a>,</p></li><li><p><a href="https://event-driven.io/en/consumers_processors_in_emmett/">Consumers, projectors, reactors and all that messaging jazz in Emmett</a>,</p></li><li><p><a href="https://event-driven.io/en/how_to_scale_projections_in_the_event_driven_systems/">How to scale projections in the event-driven systems?</a>,</p></li><li><p><a href="https://event-driven.io/en/checkpointing_message_processing/">Checkpointing the message processing</a>,</p></li><li><p><a href="https://event-driven.io/en/lets_talk_about_positions_in_event_stores/">Let&#8217;s talk about positions in event stores</a>.</p></li></ul><p>Cheers!</p><p>Oskar</p><p>p.s. <strong>Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help.</strong> You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to <a href="https://www.icrc.org/en/donate/ukraine">Red Cross</a>, <a href="https://savelife.in.ua/en/donate/">Ukraine humanitarian organisation</a> or <a href="https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone">donate Ambulances for Ukraine</a>.</p>]]></content:encoded></item></channel></rss>