<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Building Moments on From The Architect</title><link>https://fromthearchitect.dev/series/building-moments/</link><description>Recent content in Building Moments on From The Architect</description><image><title>From The Architect</title><url>https://fromthearchitect.dev/og-image.png</url><link>https://fromthearchitect.dev/og-image.png</link></image><generator>Hugo</generator><language>en-us</language><lastBuildDate>Thu, 13 Aug 2026 00:00:00 +1000</lastBuildDate><atom:link href="https://fromthearchitect.dev/series/building-moments/index.xml" rel="self" type="application/rss+xml"/><item><title>Moments: The Photo App I Built to Win an Argument</title><link>https://fromthearchitect.dev/posts/introducing-moments/</link><pubDate>Thu, 13 Aug 2026 00:00:00 +1000</pubDate><guid>https://fromthearchitect.dev/posts/introducing-moments/</guid><description>A phone call with a mate turned into a wager: could AI actually build a real application from scratch? Five months, 898 commits and 40,000 lines of Rust later, here&amp;#39;s the verdict — and the app.</description><content:encoded><![CDATA[<p><em>A phone call with a mate turned into a wager: could AI actually build a real application from scratch? Five months, 898 commits and 40,000 lines of Rust later, here&rsquo;s the verdict — and the app.</em></p>
<h2 id="it-started-with-a-phone-call">It started with a phone call</h2>
<p>Like most of my questionable decisions, this one started on the phone with a mate, talking about technology the way we always do. The conversation drifted, as every technical conversation eventually does now, into AI. And we ended up circling the same question everyone circles: is this stuff actually capable of building real software, or is it just very confident autocomplete?</p>
<p>He was the sceptic. I was less sure. I&rsquo;d been watching AI coding tools long enough to see signs they could do more than trivial hello-world demos — I&rsquo;d even <a href="/posts/the-ai-pair-programmer/">written about the right way to work with them</a> more than a year earlier. But I don&rsquo;t think either of us honestly believed the answer would be a full-featured desktop application.</p>
<p>So I did the thing you should never do on a phone call after 9pm: I made a declaration. I would definitively prove, one way or the other, whether AI could build a real application from scratch.</p>
<p>Not from a single prompt. That&rsquo;s a stupid idea, and it&rsquo;s a strawman both camps love: the boosters because it makes a great demo, the sceptics because it makes a great failure. I mean built the way software actually gets built: architecture, iteration, review, tests, releases. Using the pair programming approach I&rsquo;d already laid out on this blog — human as navigator, AI as driver, with the conversation between them doing the real work.</p>
<p>If it failed, I&rsquo;d write that post. It would have been an easier post to write, honestly. This is the other one.</p>
<hr>
<h2 id="picking-a-fight-worth-having">Picking a fight worth having</h2>
<p>If you&rsquo;re going to test whether AI can build real software, the project has to be real: something with real complexity, a demanding platform, and a reason to exist beyond the experiment.</p>
<p>I&rsquo;ve been an on-again, off-again Linux user for the best part of thirty years. The kernel was never the problem. The desktop was never really the problem either, especially lately. The thing that has always dragged me back to commercial operating systems is the application ecosystem. It&rsquo;s better today than it has ever been, thanks to an enormous number of dedicated open source developers. But there are still categories where the open source options simply don&rsquo;t compete with their commercial rivals. Photos. Email. The apps your family actually touches.</p>
<p>And that second part matters, because this wasn&rsquo;t abstract for me. I&rsquo;ve been trying to move my family away from Apple Photos, and self-hosted <a href="https://immich.app/" target="_blank" rel="noopener">Immich</a> is a brilliant back end for that. But my family has spent too many years inside the Apple ecosystem to accept a web interface for something as personal as their photo library. They want a real application. Native, fast, polished. The kind of thing the Linux desktop is chronically short of.</p>
<p>So that was the wager, fully formed: a modern GTK4/libadwaita photo management application for the GNOME desktop, written in Rust. (All the cool kids use Rust. It&rsquo;s been a long time since I was a kid.)</p>
<p>There was one more reason this experiment appealed to me, and it&rsquo;s the one I suspect resonates with most working engineers. My background is software engineering. I&rsquo;ve built some large, complex, performance-critical systems for companies big and small. Building software has never been the hard part. <em>Finding the time</em> has. A career plus a family leaves you time-poor in exactly the way that kills side projects, and it&rsquo;s why I&rsquo;d never seriously committed to a sizable open source project before. If AI could compress the gap between the architecture in my head and working code on disk, the economics of contributing to open source change completely — not just for me, for every experienced engineer who&rsquo;s been sitting on the sidelines for the same reason.</p>
<hr>
<h2 id="what-it-was-actually-like">What it was actually like</h2>
<p>Five months later I can tell you the answer to the wager, but the honest version of this story is the middle, not the ending. Because the first few weeks were not smooth.</p>
<p>The early problems were all variations on the same theme: an AI will cheerfully solve the problem in front of it with no memory of how it solved the same problem last Tuesday. I&rsquo;d find duplicate implementations of the same logic in different corners of the codebase. I&rsquo;d find three different patterns for the same category of problem, each locally reasonable, collectively a mess. I had to learn the right size to chunk features — too big and the AI loses the thread, too small and you spend more time briefing than you save.</p>
<p>But the biggest lesson was about architecture, and it&rsquo;s the one I&rsquo;d put on a poster. <strong>Claude&rsquo;s instinct is to reach the outcome in front of it as fast as possible.</strong> It is genuinely good at that. What it does not do — unless you make it — is weigh that outcome against the long-term shape of the system. Left unsupervised, you get software that works today and calcifies by next month. Which, now that I write it down, also describes plenty of human teams I&rsquo;ve worked with.</p>
<p>So the collaboration settled into a division of labour that will look very familiar to anyone who read my pair programming post:</p>
<ul>
<li><strong>Mine:</strong> application architecture, design, and feature management. Every structural decision — the backend abstraction, the sync model, the storage layout — came from me. I was the navigator, holding the long-term picture.</li>
<li><strong>Claude&rsquo;s:</strong> the code. The overwhelming majority of the actual Rust was written by the AI, driving under direction.</li>
<li><strong>Ours:</strong> the review loop. Every change went through a pull request, reviewed and audited before merge — the same discipline I&rsquo;d demand on any professional team. No PR process, no experiment; it&rsquo;s the review loop that makes the whole thing defensible.</li>
</ul>
<p>Part way through I added a technique I now consider essential: <strong>adversarial review</strong>. Don&rsquo;t let the AI that wrote the code be the only AI that reads it. A second model — or even the same model under a deliberately hostile prompt — catches code smells, duplication and quiet regressions far earlier than a friendly one does. The author is always too fond of its own work. That turns out to be true of machines too.</p>
<p>Somewhere in those months, the experiment stopped feeling like an experiment. That&rsquo;s the part I didn&rsquo;t expect. Within days it was apparent that working this way wasn&rsquo;t just <em>possible</em> — it was <em>preferred</em>. I wasn&rsquo;t tolerating the AI to prove a point. I was reaching for it because the loop of architect–direct–review–refine was producing better software, faster, than I would have produced alone in the hours I actually have.</p>
<hr>
<h2 id="the-verdict">The verdict</h2>
<p>Here&rsquo;s what the wager produced:</p>
<ul>
<li><strong>~40,000 lines of Rust</strong>, GTK4 and libadwaita</li>
<li><strong>898 commits</strong> over five months, first commit 22 March 2026</li>
<li><strong>Every change human-reviewed</strong> before merge, through a PR workflow</li>
<li>Releases through <strong>v0.4.1</strong>, shipped as Flatpak bundles with checksums, and GPG signatures on signed releases</li>
<li>Built almost entirely in evenings and spare hours: the time budget of a person with a job and a family, not a funded team</li>
</ul>
<p>Now overlay the cost lens, because this is where the wager stops being a curiosity and starts being an economic argument. The entire AI side of this project ran on a Claude Code subscription — I started on the Pro plan and very quickly determined it wasn&rsquo;t enough, so most of the project was built on the Max 5x tier, at roughly AU$150 a month. During heavy development I&rsquo;d regularly burn through 80–90% of my weekly token allocation, but I never went over it. So call it $150 a month, all in, for a coding partner that helped produce a 40,000-line application. That&rsquo;s not cheap as subscriptions go, and I won&rsquo;t pretend it&rsquo;s nothing — but weighed against what it unlocked, I found it an easy cost to justify. For me, the constraint was never the money. It was only ever the time. I&rsquo;ll admit I spent more evenings on this than my family would have liked while it was all coming together. Now that we have a functioning app, it&rsquo;s consuming far less of them.</p>
<p>And here is the verdict, as plainly as I can put it: <strong>AI can absolutely build a real application — but not alone, and that&rsquo;s precisely the point.</strong> The single-prompt fantasy is still a fantasy. What is real, right now, is an experienced engineer setting the architecture and standards, an AI doing the driving, and a review process keeping everyone honest. That combination settled the phone argument — and it removed the constraint that has kept me, and I suspect a great many engineers like me, out of open source for years.</p>
<p>I called my mate to concede that neither of us had been right. He&rsquo;s yet to install a Linux desktop, so I&rsquo;m calling it a draw.</p>
<hr>
<h2 id="meet-moments">Meet Moments</h2>
<p>Enough about the wager — here&rsquo;s the app.</p>
<p><strong><a href="https://github.com/justinf555/Moments" target="_blank" rel="noopener">Moments</a></strong> is a photo management application for the GNOME desktop. It&rsquo;s built for exactly the situation my family is in: your photos live either on your own disk or on a self-hosted Immich server, and you want a fast, native way to actually browse them.</p>
<p>It talks to a local folder or to an Immich server. The Immich backend caches metadata and thumbnails locally in SQLite, so browsing, searching and anything you&rsquo;ve already opened works fully offline; a photo you&rsquo;ve never viewed before will fetch its full-resolution original on demand, then keep it in a local cache for next time. The grid is keyset-paginated with six zoom levels, and it stays smooth through large libraries.</p>
<p><img alt="The Moments photo grid, showing a synced Immich library with sidebar navigation and albums" loading="lazy" src="/images/introducing-moments/moments-grid.jpg"></p>
<p>Beyond that:</p>
<ul>
<li><strong>RAW and modern formats</strong> — CR2, NEF, ARW, DNG and friends, alongside JPEG, PNG, WebP, HEIC and TIFF</li>
<li><strong>Video</strong> — import and playback via GStreamer</li>
<li><strong>Albums, People and Favourites</strong> — including face data synced from Immich</li>
<li><strong>EXIF metadata</strong> — camera, lens, exposure and GPS in the detail panel</li>
<li><strong>Non-destructive rotate, flip and crop</strong>, with adjustments and filters behind experimental flags</li>
</ul>
<p>It&rsquo;s GPL-3.0-or-later, and it&rsquo;s a real daily-driver application — my family&rsquo;s photo library runs on it.</p>
<p><strong>Installing it</strong> takes a download and two commands. Every release ships a single-file Flatpak bundle (with checksums, and GPG signatures on signed releases):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>flatpak install --user moments-&lt;version&gt;-x86_64.flatpak
</span></span><span style="display:flex;"><span>flatpak run io.github.justinf555.Moments
</span></span></code></pre></div><p>Grab the latest bundle from the <a href="https://github.com/justinf555/Moments/releases/latest" target="_blank" rel="noopener">releases page</a>.</p>
<hr>
<h2 id="the-flathub-question">The Flathub question</h2>
<p>The obvious question is why those two commands aren&rsquo;t just <code>flatpak install flathub io.github.justinf555.Moments</code>. That deserves a straight answer, because it&rsquo;s part of this story too.</p>
<p>I submitted Moments to Flathub on 27 March 2026, five days after the project&rsquo;s first commit — early, deliberately, so the review could run alongside development. The submission worked through the normal review cycle: manifest fixes, portal permissions, test builds passing on both architectures.</p>
<p>Then, at the end of May, <a href="https://docs.flathub.org/docs/for-app-authors/requirements" target="_blank" rel="noopener">Flathub changed its inclusion policy</a>. The new rule is a blanket one: <em>&ldquo;Applications containing AI-generated or AI-assisted code, documentation, or any other content are not allowed.&rdquo;</em> Not AI-generated slop with no human behind it — which the previous policy already prohibited, rightly — but AI-assisted anything. Two days after the policy landed, my submission was closed. The <a href="https://github.com/flathub/flathub/pull/8227" target="_blank" rel="noopener">full exchange is public</a>, and I&rsquo;d encourage you to read it and draw your own conclusions rather than take my summary of it. Read closely and you&rsquo;ll notice I described the AI&rsquo;s share of the codebase as &ldquo;parts of the app&rdquo; in that thread — more of it than I was comfortable admitting mid-argument, if I&rsquo;m honest. This post is the fuller account.</p>
<p>I withdrew the submission, and I said my piece on the way out, which I&rsquo;ll stand behind here rather than repeat at length: the old policy drew the correct line — AI-generated code with no human behind it, not allowed; AI-assisted code that a human reviewed, tested and stands behind, allowed. The new policy erases that line. It stops judging software by whether it&rsquo;s good, tested and maintained, and starts judging it by how it was typed.</p>
<p>I understand where the policy comes from. Maintainers are drowning in low-effort AI submissions, and I have real sympathy for that — I wrote <a href="/posts/why-open-source-doesnt-embrace-ai/">a whole post about the open source community&rsquo;s fraught relationship with AI</a> before I was ever personally on the receiving end of it. Flathub&rsquo;s repo, Flathub&rsquo;s rules. But I think the blanket version of the rule is a mistake, and I think this app is a concrete counterexample: working GPL software, architected and reviewed by a human, in a category the Linux desktop has needed for decades. The policy does allow exceptions for &ldquo;mature, well-maintained projects&rdquo;, so perhaps Moments and Flathub will meet again down the track. The door&rsquo;s open on my side.</p>
<p>In the meantime, the Flatpak bundles work everywhere Flatpak does, and updates are one download away.</p>
<hr>
<h2 id="what-id-ask-of-you">What I&rsquo;d ask of you</h2>
<p>Three things, in order:</p>
<ol>
<li><strong>Install it.</strong> Point it at a folder of photos or an Immich server and see what you think. It&rsquo;s <a href="https://github.com/justinf555/Moments/releases/latest" target="_blank" rel="noopener">a download and two commands</a>.</li>
<li><strong>Star the repo.</strong> <a href="https://github.com/justinf555/Moments" target="_blank" rel="noopener">github.com/justinf555/Moments</a> — discovery is the thing losing Flathub actually cost this project, and a star is the closest substitute GitHub offers.</li>
<li><strong>Tell me what&rsquo;s broken or missing.</strong> <a href="https://github.com/justinf555/Moments/issues" target="_blank" rel="noopener">Issues</a> for bugs and feature requests, <a href="https://github.com/justinf555/Moments/discussions" target="_blank" rel="noopener">Discussions</a> for everything else. This is the part of open source no AI can do: real people, with real photo libraries, telling you what actually matters.</li>
</ol>
<p>And if you&rsquo;re an experienced engineer who&rsquo;s been sitting on an idea for years because you couldn&rsquo;t find the time — that&rsquo;s the real takeaway of the wager. The time problem is the one that just changed. Go and settle an argument of your own.</p>
]]></content:encoded></item><item><title>Four Ways the Outbox Bit Me</title><link>https://fromthearchitect.dev/posts/four-ways-the-outbox-bit-me/</link><pubDate>Sun, 31 May 2026 09:00:00 +1000</pubDate><guid>https://fromthearchitect.dev/posts/four-ways-the-outbox-bit-me/</guid><description>A follow-up. The architecture is sound. The first six weeks of running it were not. Here are four bugs the round-trip taught me — two about the abstraction, two about the substrate underneath.</description><content:encoded><![CDATA[<p>In <a href="/posts/recording-mutations-not-events/">the previous post</a> I described an architecture where the <a href="https://github.com/justinf555/Moments" target="_blank" rel="noopener">Moments</a> library doesn&rsquo;t know that sync exists. Services record <code>Mutation</code> values into a one-method trait; one implementation writes to an outbox table, another does nothing. The library code is identical regardless of which backend it&rsquo;s running under, and a third backend could be added without touching the library at all. The architecture is sound. I still believe in it.</p>
<p>The first six weeks of running it were a different story. Four bugs in particular are worth describing, because each one tells you something the diagrams don&rsquo;t. Two are about the <em>abstraction</em> — assumptions the trait quietly makes about how the world works, and what breaks when the world doesn&rsquo;t cooperate. Two are about the <em>substrate</em> — the boring queue-and-retry machinery underneath the trait, which has its own opinions about how things should be done. Clean architecture doesn&rsquo;t relieve you of the substrate&rsquo;s problems. It just gives you one place to deal with them.</p>
<h2 id="1-the-pull-delete-loop">1. The pull-delete loop</h2>
<p>The first bug was the most embarrassing one, and the cleanest illustration of a hidden assumption in the design.</p>
<p>The symptom, as a user would see it: trash a photo on your phone via the Immich mobile app. Watch it disappear from the desktop a few seconds later when the next pull cycle arrives. Watch it <em>reappear</em> a few seconds after that. Watch it disappear again. The asset ping-pongs across the network, never settling, every thirty seconds, forever — or at least until you notice and start digging.</p>
<p>The mechanics: the pull-side handler for <code>AssetDeleteV1</code> called <code>MediaService::delete_permanently</code>, which dutifully recorded an <code>AssetDeleted</code> mutation into the outbox, which the push manager dutifully tried to send back to the server, which had just told us the asset was gone. Server says delete → local deletes → outbox records delete → push tries to delete on server → server returns 404, push gives up. So far so harmless. But the <em>next</em> pull cycle then re-emits the original delete (because the server is still asserting &ldquo;this asset has been deleted, here&rsquo;s the event&rdquo;), and we go around again.</p>
<p>The recorder-based design has a hidden assumption baked into it: mutations have a single source, and that source is the user sitting at the desktop. The whole point of recording mutations is to push them to the server. When the <em>server</em> starts producing mutations — and in a two-way sync, of course it does — the recorder happily reports those back to the server too, and the system feeds on its own output.</p>
<p>The fix (commit <a href="https://github.com/justinf555/Moments/commit/f097174" target="_blank" rel="noopener"><code>f097174</code></a>) was a <code>Library::delete_permanently_from_sync</code> variant that takes the same code path <em>except</em> it skips the recorder. The <code>_from_sync</code> naming convention now appears wherever pull-side handlers mutate the library — there&rsquo;s a <code>_from_sync</code> for trash, for restore, for the album mutations, and so on. Each one is a tiny duplicate of its normal counterpart, with the recorder call removed. It feels redundant, and it is, but the redundancy is the whole point: pull is the local equivalent of &ldquo;don&rsquo;t echo back what you were just told&rdquo;, and the duplication makes the don&rsquo;t-echo explicit at every call site.</p>
<p>There were two alternatives I considered. One was a thread-local &ldquo;I am inside the sync handler&rdquo; flag that the recorder would check; that&rsquo;s a kind of magic that hides the directional question rather than answering it. The other was a parameter on every service method indicating origin; that pushes the question into every signature instead of into a single naming convention. The <code>_from_sync</code> approach won because it makes the asymmetry visible exactly where it matters and nowhere else.</p>
<p>The architectural lesson, stated generally: a recorder-based design implicitly assumes mutations have a single source. The moment a second source appears, you have to mark it in the code, or you get an infinite loop with a polite retry interval.</p>
<h2 id="2-retry-backoff-and-the-death-of-multi-id-rows">2. Retry, backoff, and the death of multi-id rows</h2>
<p>The second bug is one of substrate, not abstraction. It would have bitten any outbox-based design, regardless of how the recorder was shaped.</p>
<p>The first version of the outbox stored multi-id mutations as a single row. <code>AssetTrashed { ids: [a, b, c] }</code> produced one outbox row with a JSON-encoded array of ids. It was simpler. The mutation type kept its natural shape — &ldquo;the user trashed three photos&rdquo; is a single mental event, after all — and the database had three fewer rows to deal with.</p>
<p>It was also wrong, in three independent ways.</p>
<p>The first was <em>partial progress</em>. The push call for that row was, internally, a loop over the ids making per-asset API calls. If the server accepted three of five ids and then the connection dropped, there was no way to record the partial success. The next retry would re-send all five, and the server&rsquo;s behaviour on the already-trashed ones was implementation-defined enough that I didn&rsquo;t trust it.</p>
<p>The second was <em>blast radius</em>. If a single id in the batch was the cause of a permanent failure — say, the user trashed an asset, then permanently deleted it elsewhere, then triggered a sync — the batch would fail forever on that one bad asset. The other four perfectly good operations were dragged into the dead-letter queue alongside it.</p>
<p>The third was <em>per-batch backoff</em>. Exponential backoff on the batch row meant one bad asset could starve the entire trash pipeline behind it. Asset X fails three times, the row sleeps for ten minutes, all five operations wait. Asset X fails again, the row sleeps for an hour. Operations Y and Z, which would have succeeded immediately, are blocked on Asset X&rsquo;s bad behaviour.</p>
<p>The fix (commit <a href="https://github.com/justinf555/Moments/commit/77d9647" target="_blank" rel="noopener"><code>77d9647</code></a>) rewrote <code>Mutation::to_outbox_rows()</code> to produce one row per entity, and added per-row retry/backoff machinery: <code>attempts</code> and <code>next_attempt_at</code> columns, exponential backoff capped at one hour, a <code>DeadLetter</code> status after ten consecutive failures so a permanently bad row stops being retried. <code>AssetTrashed { ids: [a, b, c] }</code> now produces three rows. Each one is independently retryable, independently dead-letterable, independently backed off.</p>
<p>I want to make this lesson explicit because it took me three iterations to internalise: <strong>the unit of retry should be the unit of failure</strong>. If a single id can fail independently, each id needs its own row. The temptation to compact the outbox is real — fewer rows feels cleaner, the JSON arrays are tidier in the schema browser — but the cost shows up under exactly the conditions you can&rsquo;t easily reproduce in development.</p>
<p>This isn&rsquo;t a recorder bug. It&rsquo;s a queue-design bug, and would have happened with a channel, a Kafka topic, or a hand-rolled WAL. But it&rsquo;s still a bug the <em>architecture</em> made me responsible for, because the trait that the architecture promises is only as good as the queue underneath it.</p>
<h2 id="3-identity-across-the-round-trip">3. Identity across the round-trip</h2>
<p>The third bug was the one that taught me the difference between decoupling and ignoring.</p>
<p><code>MediaId</code> and <code>AlbumId</code> are local UUIDs that Moments generates when an asset or album is first created on the desktop. Immich also generates UUIDs for assets and albums on its end — those land in a column called <code>external_id</code>. The two are not the same. The local id is the row&rsquo;s primary key forever; the external id is stamped onto the row once the push succeeds and the pull cycle confirms the asset is now visible server-side.</p>
<p>So far so reasonable. Now consider the lifecycle of a freshly imported photo. The user drops a JPEG into the import folder. <code>MediaService::import</code> generates a fresh <code>MediaId</code>, writes a row, records an <code>AssetImported</code> mutation. The push manager picks up the outbox row, uploads the file, receives the Immich asset id from the response, stamps it into <code>media.external_id</code>. Beautiful. Then the next pull cycle, dutifully fetching everything the server knows about, streams an <code>AssetV1</code> event for that same asset.</p>
<p>The original handler for <code>AssetV1</code> did <code>INSERT OR REPLACE INTO media (id, external_id, …)</code>. The <code>id</code> it inserted was a freshly-generated local UUID — because that&rsquo;s how the pull handler always created assets. The pull handler had no way to know that <em>this</em> asset already existed locally under a different local id, because the only thing connecting them was the <code>external_id</code> column it was about to overwrite.</p>
<p>The result: a brand-new row, with a brand-new <code>MediaId</code>, replacing the original one. Every album-membership row pointing at the original <code>MediaId</code> now dangled. The asset appeared in the timeline grid but not in any of the albums the user had just added it to. Worse, the original row&rsquo;s on-disk thumbnail and cached EXIF data were now associated with an id that no longer existed in <code>media</code>.</p>
<p>Commits <a href="https://github.com/justinf555/Moments/commit/d43859f" target="_blank" rel="noopener"><code>d43859f</code></a> and <a href="https://github.com/justinf555/Moments/commit/1692881" target="_blank" rel="noopener"><code>1692881</code></a> fixed this by making <code>MediaId</code> and <code>AlbumId</code> <em>round-trip stable</em>. On inbound sync, every handler looks up the existing row by <code>external_id</code> first. If a row already exists with that external id, the handler reuses its <code>MediaId</code> and updates fields in place, never minting a new one. The local id becomes the durable identity; the external id is the bridge that lets inbound sync find it again.</p>
<p>The general lesson — and this one took me by surprise — is that &ldquo;an outbox decouples local mutations from remote sync&rdquo; is technically true and architecturally meaningless if the <em>identity</em> of an entity doesn&rsquo;t survive the round-trip. The decoupling is between the two <em>flows</em> of operations, not between the two <em>namespaces</em> of identifiers. Those namespaces still have to be reconciled somewhere, and the schema is the natural place. You need a column, or a constraint, or a lookup convention that lets inbound sync recognise &ldquo;this thing already exists&rdquo; rather than treating every server-side row as a first-time event.</p>
<p>In retrospect, the lesson is obvious. In practice, it costs you a weekend the first time, because the bug is invisible in any test where the asset is created locally <em>or</em> fetched from the server but never both. The minimum reproducer is a real round-trip, and you don&rsquo;t usually write tests that span import → push → pull on the same asset.</p>
<h2 id="4-reset-semantics-or-what-does-the-server-mean-by-start-over">4. Reset semantics, or: what does the server mean by &ldquo;start over&rdquo;</h2>
<p>The fourth bug is the one that taught me sync protocols are not what they appear to be.</p>
<p>Immich&rsquo;s sync protocol can send a <code>SyncResetV1</code> message when the last checkpoint is more than thirty days stale. The semantics, in the spec, are roughly: &ldquo;your understanding of my state is too old to be incrementally caught up. Treat what follows as a complete re-emission of everything I know about.&rdquo;</p>
<p>The naive interpretation is &ldquo;wipe the local cache and re-fetch everything.&rdquo; That interpretation is catastrophic for an offline-first store. The user has locally-imported, not-yet-pushed assets sitting in their library. Those have no <code>external_id</code> because the server has never heard of them. A blind wipe would delete the user&rsquo;s recent imports the moment the sync handshake decided their checkpoint was stale — and &ldquo;stale&rdquo; in this protocol can mean as little as a month of laptop-closed time.</p>
<p>The first version of the reset handler tried to be careful. It built a <code>HashSet&lt;MediaId&gt;</code> of every local asset at the start of the reset cycle, removed each id as the stream re-emitted it, and at end-of-stream deleted whatever was left over. That at least filtered to &ldquo;things the server should have known about&rdquo;. It still had three latent bugs, and I&rsquo;ll only name them briefly because the full catalogue is in the design doc.</p>
<p>First, namespace confusion. <code>media.id</code> is the local UUID; the stream&rsquo;s <code>entity_id</code> is the Immich UUID. The set was being keyed on one and reduced by the other, so the set never actually shrank — every reset cycle wiped the entire library. Second, locally-imported rows (<code>external_id IS NULL</code>) shouldn&rsquo;t have been candidates at all, but the set didn&rsquo;t filter them out. Third, albums and <code>asset_faces</code> had no orphan tracking, so they leaked across resets entirely.</p>
<p>Commits <a href="https://github.com/justinf555/Moments/commit/e54e037" target="_blank" rel="noopener"><code>e54e037</code></a> and <a href="https://github.com/justinf555/Moments/commit/3e24d9f" target="_blank" rel="noopener"><code>3e24d9f</code></a> rewrote the whole thing into a heartbeat-based reconciliation, which is the kind of state-machine design that&rsquo;s interesting on its own merits. On <code>SyncResetV1</code> the reset handler captures the current timestamp as a &ldquo;checkpoint&rdquo; and clears the per-entity-type ack cursors. Nothing gets deleted yet. Every entity-handler — <code>AssetV1</code>, <code>AlbumV1</code>, <code>PersonV1</code>, <code>AssetFaceV1</code> — has an <code>UPDATE table SET last_seen_at = now() WHERE id = ?</code> baked into it. Any locally-driven server interaction (a push completion, a favorite round-trip) bumps the same column.</p>
<p>On <code>SyncCompleteV1</code>, four targeted sweeps run — on <code>media</code>, <code>albums</code>, <code>people</code>, and <code>asset_faces</code> — each filtering rows where <code>last_seen_at &lt; checkpoint</code>. The <code>media</code> and <code>albums</code> sweeps add <code>external_id IS NOT NULL</code> as a guard, which is the single line of defence between a reset cycle and the user&rsquo;s local-only imports. <code>people</code> and <code>asset_faces</code> don&rsquo;t need that guard: nothing local creates them — they only exist once the server emits them. The whole scheme is crash-safe by accident: partial heartbeats persist across a process restart, so an interrupted reset cycle finishes correctly on the next pull rather than starting over.</p>
<p>The lesson is broader than reset. &ldquo;Reset&rdquo; in a sync protocol is not &ldquo;delete everything.&rdquo; It&rsquo;s &ldquo;re-establish ground truth without losing what only you know about.&rdquo; The set of things only-you-know-about is — for an offline-first store — every row without an <code>external_id</code> plus every locally-mutated field that hasn&rsquo;t yet been pushed. If your reset handler doesn&rsquo;t have a name for that set, your reset handler is going to delete it.</p>
<p>This is the bug that has the least to do with the recorder trait itself. It belongs to the broader category of &ldquo;things you only discover once you have the architecture, the outbox, and a real round-trip running long enough to encounter a thirty-day boundary.&rdquo; But it earned its place on this list because it follows the same shape as the others: the architecture didn&rsquo;t cause it, the architecture didn&rsquo;t prevent it, but the architecture is what gave us one well-defined place to fix it.</p>
<h2 id="what-the-four-have-in-common">What the four have in common</h2>
<p>The bugs span the two categories the intro set out — abstraction (1 and 3) and substrate (2 and 4) — and what they share, across both, is that none of them required changing the architecture. Bug 1 added a <code>_from_sync</code> variant alongside the user-facing method, not a new trait or a new flag on the recorder. Bug 2 rewrote <code>to_outbox_rows()</code> and added retry columns to the schema; the recorder trait was untouched. Bug 3 changed how inbound handlers look up existing rows; nothing above the schema cared. Bug 4 added a single column (<code>last_seen_at</code>) and four sweep queries; the trait, the enum, the outbox table layout, the library services — all unchanged.</p>
<p>That&rsquo;s the dividend the architecture actually pays. The bugs are real, expensive, and embarrassing in their own ways. None of them propagated upward. The library still doesn&rsquo;t know sync exists. It just describes what it changed, and lets the substrate — and the people maintaining it — pay the round-trip&rsquo;s price.</p>
]]></content:encoded></item><item><title>Recording Mutations, Not Events</title><link>https://fromthearchitect.dev/posts/recording-mutations-not-events/</link><pubDate>Sun, 17 May 2026 09:00:00 +1000</pubDate><guid>https://fromthearchitect.dev/posts/recording-mutations-not-events/</guid><description>An offline-first photo manager whose library code has no idea it has a sync backend. One enum, one trait, three implementations behind it, and the architectural property they buy together.</description><content:encoded><![CDATA[<p>Half of the library code in <a href="https://github.com/justinf555/Moments" target="_blank" rel="noopener">Moments</a>, my photo manager, calls a one-method trait after every database write. That trait has two implementations. One of them returns <code>Ok(())</code> and does nothing else. From inside the library, there is no way to tell which one is wired up.</p>
<p>That is the entire architecture of how Moments syncs to Immich — and the entire reason the local backend, which has no sync at all, is <em>the same program</em> as the Immich one.</p>
<p>This post is about that trait, the enum it consumes, the outbox table underneath it, and the architectural property they buy together: a library that has no idea it has a sync backend, and a sync backend that&rsquo;s purely additive to it.</p>
<h2 id="the-offline-first-problem">The offline-first problem</h2>
<p>Moments has two backends. The local backend stores photos on disk and indexes them in SQLite. The Immich backend talks to a self-hosted <a href="https://immich.app" target="_blank" rel="noopener">Immich</a> server but caches everything in the <em>same</em> SQLite schema — the UI reads from the local DB either way, and an Immich library works fully offline after initial sync.</p>
<p>That means local writes have two possible destinations. Always the local DB. Sometimes — if the backend happens to be Immich — also a remote server, eventually, when the network cooperates. &ldquo;Trash these three assets&rdquo; should commit locally, return immediately, and arrange for the server to be told later. &ldquo;Create this album&rdquo; should be visible in the UI the moment the row hits the database, regardless of whether the push call has happened, will happen, or will fail and retry six times.</p>
<p>The naive answer is to give every service a reference to the sync layer. <code>MediaService::trash</code> calls <code>sync.enqueue_trash(ids)</code> after writing to the DB. <code>AlbumService::create</code> calls <code>sync.enqueue_album_created(album)</code>. It works for the Immich backend. It also pollutes the local backend, which has no sync, with either a second constructor for every service or an <code>Option&lt;SyncHandle&gt;</code> carried everywhere it&rsquo;s irrelevant. Every service in the library starts to know that there is such a thing as sync.</p>
<p>What I wanted instead was: services produce a value describing what they changed, and someone else decides whether to do anything about it.</p>
<h2 id="the-vocabulary">The vocabulary</h2>
<p>The value is <code>Mutation</code>, in <code>src/library/mutation.rs</code>. It is an enum of state changes the library has just committed:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#75715e">#[derive(Debug, Clone)]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">enum</span> <span style="color:#a6e22e">Mutation</span> {
</span></span><span style="display:flex;"><span>    AssetTrashed       { ids: Vec<span style="color:#f92672">&lt;</span>MediaId<span style="color:#f92672">&gt;</span> },
</span></span><span style="display:flex;"><span>    AssetFavorited     { ids: Vec<span style="color:#f92672">&lt;</span>MediaId<span style="color:#f92672">&gt;</span>, favorite: <span style="color:#66d9ef">bool</span> },
</span></span><span style="display:flex;"><span>    AssetDeleted       { items: Vec<span style="color:#f92672">&lt;</span>(MediaId, Option<span style="color:#f92672">&lt;</span>String<span style="color:#f92672">&gt;</span>)<span style="color:#f92672">&gt;</span> },
</span></span><span style="display:flex;"><span>    AlbumCreated       { id: <span style="color:#a6e22e">AlbumId</span>, name: String },
</span></span><span style="display:flex;"><span>    AlbumMediaAdded    { album_id: <span style="color:#a6e22e">AlbumId</span>, media_ids: Vec<span style="color:#f92672">&lt;</span>MediaId<span style="color:#f92672">&gt;</span> },
</span></span><span style="display:flex;"><span>    AssetEditsApplied  { id: <span style="color:#a6e22e">MediaId</span> },
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// …
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>}
</span></span></code></pre></div><p>Eighteen variants in total — asset lifecycle, album lifecycle, edit application, stacks, tags, people. The shape matters more than the count: every variant names a state change the database has just committed.</p>
<p>The discipline that keeps this enum small is what it deliberately <em>isn&rsquo;t</em>. There are no <code>*Requested</code> variants — UI intent isn&rsquo;t a mutation, it&rsquo;s a method call on a service. There are no <code>*Result</code> variants — results are return values. There are no UI hint variants like <code>ThumbnailReady</code> — those are events on a specific service&rsquo;s event emitter and don&rsquo;t belong here.</p>
<p>A <code>Mutation</code> is one thing: a state change the database has just committed, that <em>might</em> need to leave the machine. Nothing more.</p>
<h2 id="the-trait">The trait</h2>
<p><code>MutationRecorder</code> is in <code>src/library/recorder.rs</code>. It is the entire interface between the library and whatever wants to know what changed:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#75715e">#[async_trait]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">trait</span> MutationRecorder: Send <span style="color:#f92672">+</span> Sync {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">async</span> <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">record</span>(<span style="color:#f92672">&amp;</span>self, mutation: <span style="color:#66d9ef">&amp;</span><span style="color:#a6e22e">Mutation</span>) -&gt; Result<span style="color:#f92672">&lt;</span>(), LibraryError<span style="color:#f92672">&gt;</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>That is it. One method, async, takes a borrowed <code>Mutation</code>, returns an error if recording itself failed. Services hold an <code>Arc&lt;dyn MutationRecorder&gt;</code> and call it after a successful write:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">async</span> <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">trash</span>(<span style="color:#f92672">&amp;</span>self, ids: Vec<span style="color:#f92672">&lt;</span>MediaId<span style="color:#f92672">&gt;</span>) -&gt; Result<span style="color:#f92672">&lt;</span>(), LibraryError<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>    self.repo.trash(<span style="color:#f92672">&amp;</span>ids).<span style="color:#66d9ef">await</span><span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span>    self.recorder.record(<span style="color:#f92672">&amp;</span>Mutation::AssetTrashed { ids }).<span style="color:#66d9ef">await</span><span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span>    Ok(())
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>A service has no idea what the recorder <em>does</em> with the mutation. It might write a row to a queue table. It might publish to a message broker. It might do absolutely nothing.</p>
<h2 id="two-strategies-one-of-which-is-empty">Two strategies, one of which is empty</h2>
<p>There are exactly two implementations, both in <code>src/sync/outbox/mod.rs</code>.</p>
<p>The first is the entire point of the design:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">struct</span> <span style="color:#a6e22e">NoOpRecorder</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[async_trait]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">impl</span> MutationRecorder <span style="color:#66d9ef">for</span> NoOpRecorder {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">async</span> <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">record</span>(<span style="color:#f92672">&amp;</span>self, _mutation: <span style="color:#66d9ef">&amp;</span><span style="color:#a6e22e">Mutation</span>) -&gt; Result<span style="color:#f92672">&lt;</span>(), LibraryError<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>        Ok(())
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The local backend takes <code>NoOpRecorder</code>. Every service in the library still calls <code>recorder.record(...)</code> after every mutation. Those calls do nothing.</p>
<p>The code path is identical to the Immich one. No <code>if backend == local</code> anywhere in the services. No second constructor. Just a vtable dispatch and an <code>Ok(())</code>.</p>
<p>The second is the implementation that does the actual work:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">struct</span> <span style="color:#a6e22e">QueueWriterOutbox</span> { repo: <span style="color:#a6e22e">OutboxRepository</span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[async_trait]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">impl</span> MutationRecorder <span style="color:#66d9ef">for</span> QueueWriterOutbox {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">async</span> <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">record</span>(<span style="color:#f92672">&amp;</span>self, mutation: <span style="color:#66d9ef">&amp;</span><span style="color:#a6e22e">Mutation</span>) -&gt; Result<span style="color:#f92672">&lt;</span>(), LibraryError<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> row <span style="color:#66d9ef">in</span> mutation.to_outbox_rows() {
</span></span><span style="display:flex;"><span>            self.repo.insert(<span style="color:#f92672">&amp;</span>row).<span style="color:#66d9ef">await</span><span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        Ok(())
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>QueueWriterOutbox</code> is used by the Immich backend. It serialises the mutation into one or more rows in a <code>sync_outbox</code> table — multi-id mutations like <code>AssetTrashed { ids: [a, b, c] }</code> fan out to three rows so retries can be per-entity rather than per-batch. The schema is unsurprising: <code>id</code>, <code>entity_type</code>, <code>entity_id</code>, <code>action</code>, <code>payload</code> (JSON), <code>created_at</code>, plus <code>status</code>, <code>attempts</code>, <code>next_attempt_at</code>, and <code>last_error</code> for retry bookkeeping. This is the <a href="https://microservices.io/patterns/data/transactional-outbox.html" target="_blank" rel="noopener">transactional outbox pattern</a>, more commonly seen in service-to-service messaging, applied here to client-server sync — with one small adaptation to the trait signature I&rsquo;ll come to in the next section.</p>
<p>The mapping from <code>Mutation</code> to <code>OutboxRow</code> lives in <code>src/sync/outbox/mutation.rs</code> and is genuinely boring — a match on the variant producing an <code>entity_type</code> string, an <code>entity_id</code>, an action verb, and an optional JSON payload. It is the kind of code that should be boring; the interesting part has already happened.</p>
<h2 id="my-favourite-test-in-this-codebase">My favourite test in this codebase</h2>
<p>My favourite test in Moments doesn&rsquo;t assert on database state. It doesn&rsquo;t assert on side effects. It asserts on a sequence of <code>Mutation</code>s the library produced — on what the library <em>said it did</em>, in its own vocabulary, in order. That test works because the third implementation of <code>MutationRecorder</code> lives in <code>src/library/recorder.rs</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#75715e">#[derive(Default)]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">pub</span>(<span style="color:#66d9ef">crate</span>) <span style="color:#66d9ef">struct</span> <span style="color:#a6e22e">CapturingRecorder</span> {
</span></span><span style="display:flex;"><span>    recorded: <span style="color:#a6e22e">Mutex</span><span style="color:#f92672">&lt;</span>Vec<span style="color:#f92672">&lt;</span>Mutation<span style="color:#f92672">&gt;&gt;</span>,
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">impl</span> CapturingRecorder {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">pub</span>(<span style="color:#66d9ef">crate</span>) <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">snapshot</span>(<span style="color:#f92672">&amp;</span>self) -&gt; Vec<span style="color:#f92672">&lt;</span>Mutation<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>        self.recorded.lock().unwrap().clone()
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[async_trait]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">impl</span> MutationRecorder <span style="color:#66d9ef">for</span> CapturingRecorder {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">async</span> <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">record</span>(<span style="color:#f92672">&amp;</span>self, mutation: <span style="color:#66d9ef">&amp;</span><span style="color:#a6e22e">Mutation</span>) -&gt; Result<span style="color:#f92672">&lt;</span>(), LibraryError<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>        self.recorded.lock().unwrap().push(mutation.clone());
</span></span><span style="display:flex;"><span>        Ok(())
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Service tests construct a <code>Library</code> with <code>Arc::new(CapturingRecorder::default())</code> and then assert on the recorded sequence after exercising a method. <em>&ldquo;When I trash three assets, then create an album from two of them, the recorder should see <code>AssetTrashed</code> followed by <code>AlbumCreated</code> followed by <code>AlbumMediaAdded</code>.&rdquo;</em> That assertion is a stronger property than &ldquo;the database ends up in the right state&rdquo;, because it pins down the <em>intent surface</em> the library exposes to anything that might eventually act on it — including providers that don&rsquo;t exist yet.</p>
<p>This is what the trait actually buys: not two production strategies, but three perspectives on the same library code — a no-op that proves the local backend doesn&rsquo;t care, a queue writer that ships changes upstream, and a capturing fixture that pins down what services promise. None of them required a single change to the services themselves.</p>
<h2 id="dependency-injection-sync-as-someone-elses-problem">Dependency injection: sync as someone else&rsquo;s problem</h2>
<p>The wiring happens in exactly one place. <code>Library::open</code> takes the recorder as a parameter and threads it into every service:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">async</span> <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">open</span>(
</span></span><span style="display:flex;"><span>    bundle: <span style="color:#a6e22e">Bundle</span>,
</span></span><span style="display:flex;"><span>    mode: <span style="color:#a6e22e">LocalStorageMode</span>,
</span></span><span style="display:flex;"><span>    db: <span style="color:#a6e22e">Database</span>,
</span></span><span style="display:flex;"><span>    recorder: <span style="color:#a6e22e">Arc</span><span style="color:#f92672">&lt;</span><span style="color:#66d9ef">dyn</span> MutationRecorder<span style="color:#f92672">&gt;</span>,
</span></span><span style="display:flex;"><span>    resolver: <span style="color:#a6e22e">Arc</span><span style="color:#f92672">&lt;</span><span style="color:#66d9ef">dyn</span> OriginalResolver<span style="color:#f92672">&gt;</span>,
</span></span><span style="display:flex;"><span>) -&gt; Result<span style="color:#f92672">&lt;</span>Self, LibraryError<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>    db.open(<span style="color:#f92672">&amp;</span>bundle.database.join(<span style="color:#e6db74">&#34;moments.db&#34;</span>)).<span style="color:#66d9ef">await</span><span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">let</span> albums  <span style="color:#f92672">=</span> AlbumService::new(db.clone(), Arc::clone(<span style="color:#f92672">&amp;</span>recorder));
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">let</span> faces   <span style="color:#f92672">=</span> FacesService::new(db.clone(), bundle.thumbnails.clone(), Arc::clone(<span style="color:#f92672">&amp;</span>recorder));
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">let</span> editing <span style="color:#f92672">=</span> EditingService::new(db.clone(), Arc::clone(<span style="color:#f92672">&amp;</span>recorder));
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">let</span> media   <span style="color:#f92672">=</span> MediaService::new(db.clone(), bundle.originals.clone(), mode,
</span></span><span style="display:flex;"><span>                                    Arc::clone(<span style="color:#f92672">&amp;</span>recorder), resolver);
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// …
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span>    Ok(Self { albums, faces, editing, media, <span style="color:#75715e">/* … */</span>, recorder })
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The local backend&rsquo;s caller hands in <code>Arc::new(NoOpRecorder)</code>. The Immich backend&rsquo;s caller hands in <code>Arc::new(QueueWriterOutbox::new(db.clone()))</code>, and separately starts a background <code>PushManager</code> that drains the same table.</p>
<p>That single substitution — the choice of recorder at the call site — is the only thing in the system that knows whether sync is happening. Everything below it is identical. From the library&rsquo;s point of view, sync is <em>someone else&rsquo;s problem</em>: it dutifully describes what it changed and trusts that someone, somewhere, may care.</p>
<h2 id="the-drain-side">The drain side</h2>
<p>For completeness: <code>src/sync/providers/immich/push.rs</code> is the <code>PushManager</code>. It is a long-running Tokio task that wakes up on an interval, reads up to a hundred pending rows from <code>sync_outbox</code>, deserialises each back into an <code>OutboxMutation</code>, makes the appropriate Immich API call, and marks the row done or failed. There is exponential backoff on failure, a <code>DeadLetter</code> status after ten consecutive failures, and a <code>MAX_BACKOFF_SECS</code> cap so attempt ten doesn&rsquo;t sleep for seventeen hours.</p>
<p>It is the side of the system that has to know about HTTP, retries, idempotency, and Immich-specific endpoints. It is also the side that doesn&rsquo;t exist at all when you&rsquo;re running the local backend, because nobody constructed it.</p>
<p>The pull side (<code>pull.rs</code> + <code>handlers/</code>) is the reverse direction — <code>POST /sync/stream</code> streams server-side changes back into the local DB. It does <em>not</em> go through the recorder: pull-driven changes are server-originated, and recording them would feed the server&rsquo;s own news straight back into the outbox heading the other way.</p>
<h2 id="adapting-the-pattern">Adapting the pattern</h2>
<p>The classical transactional outbox pattern — the one I borrowed the name and the table from — requires the business write and the outbox INSERT to share a single database transaction. That&rsquo;s the whole point of the pattern: if the entity write commits, the outbox row commits with it; if either fails, both roll back. Atomicity is what guarantees the consumer eventually sees every committed change.</p>
<p>Moments needs that guarantee, non-negotiably. If <code>trash</code> returns success to the caller, the outbox row must be there waiting for the push manager. Otherwise the local state silently drifts ahead of the server: the user trashes a photo, the desktop hides it, the server never hears about it, and the next pull cycle dutifully restores it. A sync layer that loses writes is worse than no sync layer at all — once the user catches it doing that, they stop trusting it for everything else.</p>
<p>The adaptation is small but load-bearing. The trait signature I&rsquo;ve shown so far is simplified for exposition; the real one takes a transaction handle, and the three implementations all thread it through. Services own the transaction lifecycle, and both the repository write and the recorder call happen inside the same transaction:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#75715e">#[async_trait]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">trait</span> MutationRecorder: Send <span style="color:#f92672">+</span> Sync {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">async</span> <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">record</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&amp;</span>self,
</span></span><span style="display:flex;"><span>        tx: <span style="color:#66d9ef">&amp;</span><span style="color:#a6e22e">mut</span> sqlx::Transaction<span style="color:#f92672">&lt;</span>&#39;_, Sqlite<span style="color:#f92672">&gt;</span>,
</span></span><span style="display:flex;"><span>        mutation: <span style="color:#66d9ef">&amp;</span><span style="color:#a6e22e">Mutation</span>,
</span></span><span style="display:flex;"><span>    ) -&gt; Result<span style="color:#f92672">&lt;</span>(), LibraryError<span style="color:#f92672">&gt;</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// in MediaService:
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">async</span> <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">trash</span>(<span style="color:#f92672">&amp;</span>self, ids: <span style="color:#66d9ef">&amp;</span>[MediaId]) -&gt; Result<span style="color:#f92672">&lt;</span>(), LibraryError<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">let</span> <span style="color:#66d9ef">mut</span> tx <span style="color:#f92672">=</span> self.db.begin().<span style="color:#66d9ef">await</span><span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span>    self.repo.trash_in_tx(<span style="color:#f92672">&amp;</span><span style="color:#66d9ef">mut</span> tx, ids).<span style="color:#66d9ef">await</span><span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span>    self.recorder
</span></span><span style="display:flex;"><span>        .record(<span style="color:#f92672">&amp;</span><span style="color:#66d9ef">mut</span> tx, <span style="color:#f92672">&amp;</span>Mutation::AssetTrashed { ids: <span style="color:#a6e22e">ids</span>.to_vec() })
</span></span><span style="display:flex;"><span>        .<span style="color:#66d9ef">await</span><span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span>    tx.commit().<span style="color:#66d9ef">await</span><span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span>    Ok(())
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>QueueWriterOutbox::record</code> uses the transaction handle to insert into <code>sync_outbox</code> inside the same transaction the service is about to commit. If the insert fails, the <code>?</code> propagates and <code>tx.commit()</code> is never called; the business write rolls back with it. The local database and the outbox share a fate by construction — neither can be ahead of the other.</p>
<p><code>NoOpRecorder</code> ignores the transaction parameter entirely. The local backend pays for opening and committing a transaction the recorder didn&rsquo;t touch — a small, fixed cost on every mutation, in exchange for the library never having to know which recorder it&rsquo;s been given. That&rsquo;s the price of the abstraction, and it&rsquo;s the only price the abstraction asks the local backend to pay.</p>
<p>An honest aside, because the timing is too good to leave out. I drafted this section assuming the code matched the design above. Then I went to check. It didn&rsquo;t. The actual service code commits the repository write and <em>then</em> calls the recorder in a second, separate await — and at most call sites the recorder error is logged and swallowed rather than propagated. A recorder failure can silently leave the local state ahead of the outbox, which is exactly the failure mode this section claims is impossible by construction. I filed <a href="https://github.com/justinf555/Moments/issues/655" target="_blank" rel="noopener">the bug</a> while finishing this paragraph.</p>
<p>The design above is the design. The code is catching up.</p>
<h2 id="adding-a-third-backend">Adding a third backend</h2>
<p>The shape of the substitution generalises beyond Immich. Adding a Nextcloud backend tomorrow would touch zero lines in <code>src/library/</code>. The services already produce <code>Mutation</code>s. The outbox table is provider-agnostic. The new code lives entirely in <code>src/sync/providers/nextcloud/</code>: an HTTP client, a push manager that drains the outbox into Nextcloud&rsquo;s API, and a pull manager that streams server-side changes back into the local DB. The wiring in <code>main</code> swaps <code>QueueWriterOutbox</code> in as the recorder, starts the Nextcloud managers, and is done.</p>
<p>That doesn&rsquo;t make a new backend free — push and pull are still real work, with their own retry semantics and identity gotchas — but it does make the cost proportional to what&rsquo;s actually different. The 99% of the app that doesn&rsquo;t care which server it&rsquo;s talking to doesn&rsquo;t have to be told.</p>
<h2 id="why-a-trait-and-not-a-channel">Why a trait, and not a channel</h2>
<p>The same job could be done with an <code>mpsc::UnboundedSender&lt;Mutation&gt;</code> — services hold a sender and push mutations into it; a consumer task somewhere pulls them out. A channel even handles <code>NoOpRecorder</code> naturally: open a channel and never spawn a consumer — the sends just buffer.</p>
<p>But a channel ties the producer and the consumer to a specific lifecycle. Who closes the sender? When? What happens when the buffer fills? Is the consumer guaranteed to run, and if not, are the dropped messages a bug or a feature? The trait sidesteps all of this by making &ldquo;what to do with a mutation&rdquo; a synchronous decision at the point of production. <code>NoOpRecorder</code> returns immediately. <code>QueueWriterOutbox</code> writes a row and returns. <code>CapturingRecorder</code> pushes onto a vec and returns. There is no pending state, no backpressure, no &ldquo;what if the consumer crashed&rdquo; failure mode lurking in the design. The service doesn&rsquo;t have to reason about <em>whether</em> its mutation was received, because reception is part of the same <code>await</code> as the rest of the operation.</p>
<p>A <code>Box&lt;dyn Fn(Mutation)&gt;</code> closure could work too, and would even be slightly cheaper. The reason the trait wins isn&rsquo;t performance; it&rsquo;s <em>naming</em>. <code>NoOpRecorder</code>, <code>QueueWriterOutbox</code>, <code>CapturingRecorder</code> — each one is a documented, importable type that says what role it plays. A closure is anonymous; it can do anything; you cannot grep for who&rsquo;s using it. The trait makes the contract a concrete thing that can be referred to in design documents and PR descriptions, and the production implementations a fixed, small set that anyone can enumerate.</p>
<p>The verb &ldquo;record&rdquo; matters too. &ldquo;Emit&rdquo; or &ldquo;publish&rdquo; or &ldquo;broadcast&rdquo; would have invited fan-out — and fan-out is exactly the property <code>Mutation</code> has been built to avoid. There is one recipient, statically chosen at <code>Library::open</code> time. &ldquo;Record&rdquo; implies durability and report-after-the-fact, which is what services actually do: they write to the database, then they describe what they wrote. The naming makes the contract harder to violate.</p>
<h2 id="what-id-tell-myself">What I&rsquo;d tell myself</h2>
<p>If I were starting the Immich backend again, I would write <code>MutationRecorder</code> and <code>NoOpRecorder</code> on day one, before there was anything that needed syncing, and have services call it from the start. The local backend would carry a <code>NoOpRecorder</code> it would never need, and when the Immich backend arrived months later, <em>nothing in the services would change</em>. That&rsquo;s the value the trait actually provides: not a separation of concerns at the API surface, but the freedom to add an entire concern later without rewriting what&rsquo;s already there.</p>
<p>The library doesn&rsquo;t know sync exists. <code>grep -rn 'crate::sync' src/library/</code> returns six matches: five are <code>#[cfg(test)]</code> imports of <code>NoOpRecorder</code> from test helpers, one a comment pointer. Production imports: zero. From the inside, the local backend and the Immich backend are the same program; the only difference is what an <code>Arc&lt;dyn …&gt;</code> happens to do.</p>
]]></content:encoded></item><item><title>The Shape of Decoupling</title><link>https://fromthearchitect.dev/posts/event-bus-postmortem/</link><pubDate>Sun, 10 May 2026 00:00:00 +1000</pubDate><guid>https://fromthearchitect.dev/posts/event-bus-postmortem/</guid><description>We designed an event bus, had it externally reviewed, shipped it, and a month later deleted it. Here&amp;#39;s why direct service-to-client channels turned out to be the honest answer.</description><content:encoded><![CDATA[<p>There is a moment in every codebase where someone draws a box on a whiteboard, labels it <code>EventBus</code>, and draws arrows fanning out to every other box. The room nods. It looks like architecture. It looks like the <em>right</em> architecture — loosely coupled, extensible, the textbook answer to &ldquo;how do we let many components react to one thing happening?&rdquo;</p>
<p>That diagram is a trap. I drew it for <a href="https://github.com/justinf555/Moments" target="_blank" rel="noopener">Moments</a>, my photo manager, in late March 2026. I had the design reviewed by an external UI architect. I shipped it in April across six phased PRs. And on the third of May I deleted the whole thing in a single commit titled, with some satisfaction:</p>
<blockquote>
<p><code>refactor: delete EventBus, AppEvent, library/commands, MediaClient v1 — closes #580</code></p></blockquote>
<p>This is what I learned about why the bus was wrong, what replaced it, and the broader pattern: an event bus is decoupling-by-indirection, and the indirection itself is the cost.</p>
<h2 id="what-the-bus-looked-like">What the bus looked like</h2>
<p>The original design was, by the standards of the form, a good one. A single <code>EventBus</code> lived on the GTK main thread. Background Tokio tasks pushed <code>AppEvent</code>s into it via a <code>Send + Clone</code> sender. <code>glib::idle_add_once</code> drained the queue and fanned out to subscribers. Subscriptions were RAII-handle-based with re-entrancy-safe deferred drops. It had tests. It had a design doc. It worked.</p>
<p>Here is the central type — the bit that everything else depended on:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#75715e">#[derive(Debug, Clone)]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">enum</span> <span style="color:#a6e22e">AppEvent</span> {
</span></span><span style="display:flex;"><span>    Error(String),
</span></span><span style="display:flex;"><span>    ThumbnailReady { media_id: <span style="color:#a6e22e">MediaId</span> },
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Commands: UI intent → library command handler
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    TrashRequested { ids: Vec<span style="color:#f92672">&lt;</span>MediaId<span style="color:#f92672">&gt;</span> },
</span></span><span style="display:flex;"><span>    FavoriteRequested { ids: Vec<span style="color:#f92672">&lt;</span>MediaId<span style="color:#f92672">&gt;</span>, state: <span style="color:#66d9ef">bool</span> },
</span></span><span style="display:flex;"><span>    AddToAlbumRequested { album_id: <span style="color:#a6e22e">AlbumId</span>, ids: Vec<span style="color:#f92672">&lt;</span>MediaId<span style="color:#f92672">&gt;</span> },
</span></span><span style="display:flex;"><span>    CreateAlbumRequested { name: String, ids: Vec<span style="color:#f92672">&lt;</span>MediaId<span style="color:#f92672">&gt;</span> },
</span></span><span style="display:flex;"><span>    DeleteAlbumRequested { ids: Vec<span style="color:#f92672">&lt;</span>AlbumId<span style="color:#f92672">&gt;</span> },
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ...
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Results: command handler → subscribers
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    MediaTrashed { ids: Vec<span style="color:#f92672">&lt;</span>MediaId<span style="color:#f92672">&gt;</span> },
</span></span><span style="display:flex;"><span>    AlbumCreated { album: <span style="color:#a6e22e">Album</span> },
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ...
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>}
</span></span></code></pre></div><p>By the time I deleted it, <code>AppEvent</code> had twenty-one variants in a single file, split into &ldquo;commands&rdquo; (UI intent) and &ldquo;results&rdquo; (library outcomes). Buttons emitted <code>*Requested</code> events. A <code>CommandHandler</code> trait dispatched each one to a struct in <code>src/commands/</code>. Library backends emitted <code>*Result</code> events. Widgets subscribed to whichever results they cared about and patched their <code>ListStore</code> models.</p>
<p>It was internally consistent, and it was wrong.</p>
<h2 id="where-it-started-hurting">Where it started hurting</h2>
<p>There was no single bug big enough to justify deleting it. There was just the same small bug, in slightly different costumes, every week.</p>
<p><strong>The enum became a junk drawer.</strong> Every new feature added a request variant <em>and</em> a result variant. The file grew. Understanding what could fire meant scanning the whole enum. Worse, dead variants accumulated: <code>refactor: remove dead AppEvent variants (#576)</code> is a real commit, removing variants that had no subscribers anywhere. Nobody had noticed because the bus made the absence of subscribers invisible.</p>
<p><strong>Double-update bugs.</strong> When the album-create button fires <code>CreateAlbumRequested</code>, the command handler calls the library, gets back an <code>Album</code>, and emits <code>AlbumCreated</code>. The album grid widget — which initiated the action — also subscribes to <code>AlbumCreated</code>, because how else would it learn? So the widget refreshes from the broadcast. But the widget&rsquo;s <em>own</em> code path knew the result first and tried to insert the row optimistically. Now you have two writers fighting over the same <code>ListStore</code>. The fix in any individual case is easy — guard with a flag, defer one path. The pattern of needing the fix everywhere is the smell.</p>
<p><strong>Re-entrancy hazards.</strong> One commit message that survives in the history reads <code>fix: defer navigate in ImportComplete handler to avoid bus re-entrancy</code>. A subscriber to <code>ImportComplete</code> navigated to a new view. Navigating triggered <code>unrealize</code> on the old view. <code>unrealize</code> dropped a <code>Subscription</code>. Dropping a <code>Subscription</code> mutated the bus&rsquo;s subscriber list — <em>while the bus was iterating it</em>. The original design had anticipated this and added a &ldquo;deferred removals&rdquo; mechanism. That mechanism was a tax on every drop, paid forever, to fix a problem the bus itself created.</p>
<p><strong>O(n) no-op events.</strong> During an Immich sync, the face-recognition service updates the <code>face_count</code> on every person row. There can be hundreds. Each update fired a result event. Each event hit every subscriber, even though <code>face_count</code> wasn&rsquo;t a property any GObject was bound to. The fix was to teach the service to <em>skip</em> emission for <code>update_face_count</code>. So now the service had to know which of its own state changes were &ldquo;broadcast-worthy&rdquo; — which is a leaky responsibility for a layer that shouldn&rsquo;t know who&rsquo;s listening at all.</p>
<p><strong>You can&rsquo;t follow it at runtime.</strong> Set a breakpoint on <code>bus.send()</code>. Hit it. Where does control end up? Twelve subscribers across four widgets and two background services, in some order, on the next idle tick. Now do that for an interaction that fires three events. The bus had given me decoupling at the cost of being unable to read the program.</p>
<p>The translation layer was the first thing to fall. Library events arrived as a separate <code>LibraryEvent</code> enum and were translated into <code>AppEvent</code> at the boundary. The translation was pure ceremony. It came out in <code>refactor: eliminate LibraryEvent → AppEvent translation loop (#520)</code> — about three weeks after the bus shipped. That should have been the warning sign: the bus&rsquo;s main job had been routing events from one layer to another, and as soon as the layers started talking directly, the bus&rsquo;s reason for existing started to thin out.</p>
<h2 id="what-replaced-it">What replaced it</h2>
<p>The replacement is, deliberately, almost boring. Each library service holds its own typed <code>EventEmitter&lt;T&gt;</code> and emits a small enum specific to that service:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#75715e">#[derive(Debug, Clone)]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">enum</span> <span style="color:#a6e22e">AlbumEvent</span> {
</span></span><span style="display:flex;"><span>    AlbumAdded(AlbumId),
</span></span><span style="display:flex;"><span>    AlbumUpdated(AlbumId),
</span></span><span style="display:flex;"><span>    AlbumRemoved(AlbumId),
</span></span><span style="display:flex;"><span>    AlbumMediaChanged(AlbumId),
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Four variants. Owned by the album feature. Nothing about media, faces, sync, errors, or UI intent.</p>
<p>The <code>EventEmitter&lt;T&gt;</code> itself is a single file:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">struct</span> <span style="color:#a6e22e">EventEmitter</span><span style="color:#f92672">&lt;</span>T: Clone<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>    senders: <span style="color:#a6e22e">Arc</span><span style="color:#f92672">&lt;</span>Mutex<span style="color:#f92672">&lt;</span>Vec<span style="color:#f92672">&lt;</span>mpsc::UnboundedSender<span style="color:#f92672">&lt;</span>T<span style="color:#f92672">&gt;&gt;&gt;&gt;</span>,
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">impl</span><span style="color:#f92672">&lt;</span>T: Clone<span style="color:#f92672">&gt;</span> EventEmitter<span style="color:#f92672">&lt;</span>T<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">subscribe</span>(<span style="color:#f92672">&amp;</span>self) -&gt; <span style="color:#a6e22e">mpsc</span>::UnboundedReceiver<span style="color:#f92672">&lt;</span>T<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">let</span> (tx, rx) <span style="color:#f92672">=</span> mpsc::unbounded_channel();
</span></span><span style="display:flex;"><span>        self.senders.lock().expect(<span style="color:#e6db74">&#34;poisoned&#34;</span>).push(tx);
</span></span><span style="display:flex;"><span>        rx
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">emit</span>(<span style="color:#f92672">&amp;</span>self, event: <span style="color:#a6e22e">T</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">let</span> <span style="color:#66d9ef">mut</span> senders <span style="color:#f92672">=</span> self.senders.lock().expect(<span style="color:#e6db74">&#34;poisoned&#34;</span>);
</span></span><span style="display:flex;"><span>        senders.retain(<span style="color:#f92672">|</span>tx<span style="color:#f92672">|</span> tx.send(event.clone()).is_ok());
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>A subscriber gets a fresh receiver. Dead receivers prune themselves on the next emit. Clones share the same subscriber set. That is the entire primitive.</p>
<p>The service uses it in two places — once to expose subscription, once to emit:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">struct</span> <span style="color:#a6e22e">AlbumService</span> {
</span></span><span style="display:flex;"><span>    db: <span style="color:#a6e22e">Arc</span><span style="color:#f92672">&lt;</span>Database<span style="color:#f92672">&gt;</span>,
</span></span><span style="display:flex;"><span>    events: <span style="color:#a6e22e">EventEmitter</span><span style="color:#f92672">&lt;</span>AlbumEvent<span style="color:#f92672">&gt;</span>,
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">impl</span> AlbumService {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">subscribe</span>(<span style="color:#f92672">&amp;</span>self) -&gt; <span style="color:#a6e22e">mpsc</span>::UnboundedReceiver<span style="color:#f92672">&lt;</span>AlbumEvent<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>        self.events.subscribe()
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">async</span> <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">delete_album</span>(<span style="color:#f92672">&amp;</span>self, id: <span style="color:#66d9ef">&amp;</span><span style="color:#a6e22e">AlbumId</span>) -&gt; Result<span style="color:#f92672">&lt;</span>()<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>        self.db.delete_album(id).<span style="color:#66d9ef">await</span><span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span>        self.events.emit(AlbumEvent::AlbumRemoved(id.clone()));
</span></span><span style="display:flex;"><span>        Ok(())
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>On the GTK side, the client subscribes once at configure time and spawns a Tokio listener that dispatches model patches back to the main thread:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">configure</span>(<span style="color:#f92672">&amp;</span>self, library: <span style="color:#a6e22e">Arc</span><span style="color:#f92672">&lt;</span>Library<span style="color:#f92672">&gt;</span>, tokio: <span style="color:#a6e22e">tokio</span>::runtime::Handle,
</span></span><span style="display:flex;"><span>                 events_rx: <span style="color:#a6e22e">mpsc</span>::UnboundedReceiver<span style="color:#f92672">&lt;</span>AlbumEvent<span style="color:#f92672">&gt;</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">let</span> client_weak: <span style="color:#a6e22e">glib</span>::SendWeakRef<span style="color:#f92672">&lt;</span>AlbumClientV2<span style="color:#f92672">&gt;</span> <span style="color:#f92672">=</span> self.downgrade().into();
</span></span><span style="display:flex;"><span>    tokio.spawn(Self::listen(events_rx, library, client_weak));
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">listen</span>(<span style="color:#66d9ef">mut</span> rx: <span style="color:#a6e22e">mpsc</span>::UnboundedReceiver<span style="color:#f92672">&lt;</span>AlbumEvent<span style="color:#f92672">&gt;</span>,
</span></span><span style="display:flex;"><span>                library: <span style="color:#a6e22e">Arc</span><span style="color:#f92672">&lt;</span>Library<span style="color:#f92672">&gt;</span>,
</span></span><span style="display:flex;"><span>                client_weak: <span style="color:#a6e22e">glib</span>::SendWeakRef<span style="color:#f92672">&lt;</span>AlbumClientV2<span style="color:#f92672">&gt;</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">while</span> <span style="color:#66d9ef">let</span> Some(event) <span style="color:#f92672">=</span> rx.recv().<span style="color:#66d9ef">await</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">match</span> event {
</span></span><span style="display:flex;"><span>            AlbumEvent::AlbumRemoved(id) <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">let</span> weak <span style="color:#f92672">=</span> client_weak.clone();
</span></span><span style="display:flex;"><span>                glib::idle_add_once(<span style="color:#66d9ef">move</span> <span style="color:#f92672">||</span> {
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">if</span> <span style="color:#66d9ef">let</span> Some(client) <span style="color:#f92672">=</span> weak.upgrade() {
</span></span><span style="display:flex;"><span>                        client.remove_from_models(<span style="color:#f92672">&amp;</span>id);
</span></span><span style="display:flex;"><span>                    }
</span></span><span style="display:flex;"><span>                });
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// ...
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>That is the whole architecture. There is no central registry. There is no <code>AppEvent</code> enum. The album feature defines its events, owns its emitter, and hands receivers to whoever asks. A new feature adds a new service with its own emitter; nothing in the existing code has to change.</p>
<h2 id="the-double-update-fix-that-was-a-design-fix">The double-update fix that was a design fix</h2>
<p>The most interesting thing about the new pattern isn&rsquo;t the channel itself. It&rsquo;s a small contract documented on every command method:</p>
<blockquote>
<p>Client-initiated mutations don&rsquo;t emit events — the client patches its model directly in its callback.</p></blockquote>
<p>When <code>AlbumClientV2::create_album</code> runs, it calls the service, gets back the new album, inserts it into its tracked models <em>itself</em>, and that&rsquo;s that. The service does <em>not</em> fire <code>AlbumAdded</code> for client-initiated work. The bus-era double-update bug cannot exist, because the broadcast path and the initiator path are no longer the same path.</p>
<p>The bus had pushed me toward &ldquo;everyone reacts to everything uniformly&rdquo;, which sounds clean and is a lie — initiators always know more than subscribers. The new design lets the initiator use what it knows. Events are reserved for state changes the initiator <em>didn&rsquo;t</em> cause: a remote sync arriving, a background scan completing, a peer process writing the database.</p>
<h2 id="what-id-tell-myself-in-2025">What I&rsquo;d tell myself in 2025</h2>
<p>Three things, in order of how useful they would have been.</p>
<p><strong>An event bus is decoupling-by-indirection.</strong> It feels like you&rsquo;ve removed a dependency. You haven&rsquo;t. You&rsquo;ve moved it into a giant enum that nobody owns and that everyone has to read to understand the program. The dependency is still there; you&rsquo;ve just made it untyped, ungrep-able, and impossible to follow at runtime. A direct <code>mpsc::UnboundedSender&lt;AlbumEvent&gt;</code> from the album service to the album client is <em>more</em> coupled than a bus message of type <code>AppEvent::AlbumCreated</code>, and that coupling is exactly what makes the program legible.</p>
<p><strong>The fan-out problem is rarer than you think.</strong> I had reached for a bus because &ldquo;many subscribers might react to one event.&rdquo; In practice, almost every event in Moments has one or two subscribers, and the subscribers are statically known. A bus&rsquo;s variable, late-bound subscriber list buys you very little when the real subscriber set is &ldquo;the album client and nothing else.&rdquo;</p>
<p><strong>Architecture review can validate internal consistency but not problem fit.</strong> The bus design was reviewed and approved. The reviewer was right that, <em>given the bus,</em> the design was sound. They couldn&rsquo;t have told me — and I couldn&rsquo;t have asked — whether a bus was the right shape for the problem in the first place. That&rsquo;s a question only a few weeks of using it can answer, and in this case a few weeks were enough. Build the smallest version of the boring thing first. Earn the right to a bus.</p>
<p>The deletion commit removed about 1,200 lines net. The replacement, including <code>EventEmitter</code> and four service-specific event enums, is closer to 200. The bus had been doing what a thin wrapper around <code>mpsc</code> does, dressed up as architecture.</p>
<p>I don&rsquo;t regret building it — without the bus there&rsquo;d be nothing concrete to compare against, and the lessons would have stayed in the realm of opinion. But if I were starting again, I would write <code>pub fn subscribe(&amp;self) -&gt; mpsc::UnboundedReceiver&lt;AlbumEvent&gt;</code> first and not draw the diagram at all.</p>
]]></content:encoded></item></channel></rss>