<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://sot.dev/feed.xml" rel="self" type="application/atom+xml" /><link href="https://sot.dev/" rel="alternate" type="text/html" /><updated>2026-05-02T19:32:48+00:00</updated><id>https://sot.dev/feed.xml</id><title type="html">Samuel Onoja</title><subtitle>Samuel Onoja - Rust software engineer.</subtitle><author><name>Samuel Onoja</name></author><entry><title type="html">I Forked Helix to Make Agentic Coding Feel Native</title><link href="https://sot.dev/i-forked-helix-to-make-agentic-coding-feel-native.html" rel="alternate" type="text/html" title="I Forked Helix to Make Agentic Coding Feel Native" /><published>2026-05-02T00:00:00+00:00</published><updated>2026-05-02T00:00:00+00:00</updated><id>https://sot.dev/i-forked-helix-to-make-agentic-coding-feel-native</id><content type="html" xml:base="https://sot.dev/i-forked-helix-to-make-agentic-coding-feel-native.html"><![CDATA[<p>I have been working on <a href="https://github.com/borngraced/doom-helix">DoomHelix</a>, my fork of Helix with an agent panel built in. The idea is simple: keep the things I love about Helix, like modal editing, speed, themes, LSP, multiple selections, and simple config, then put the agent inside the editor instead of bolting it on through a terminal pane.</p>

<p>DoomHelix uses ACP, the Agent Client Protocol behind Zed’s agent integrations, so the editor does not have to be tied to one model or backend. I mostly use it with Codex ACP right now, but the editor side is built around the protocol, so other compatible backends can fit into the same flow.</p>

<p>I recorded a short walkthrough showing installation and a few places where the agent is useful while editing.</p>

<iframe width="100%" height="400" src="https://www.youtube.com/embed/8Yl6dZ5LhP4" title="I Forked Helix to Add an AI Agent" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen="">
</iframe>

<p><a href="https://youtu.be/8Yl6dZ5LhP4">Watch on YouTube</a></p>

<h2 id="installing-doomhelix">Installing DoomHelix</h2>

<p>The installer builds from source when a prebuilt release is not available, installs the <code class="language-plaintext highlighter-rouge">dhx</code> launcher, installs the runtime files, and writes a small agent config if one does not already exist. The first run is mostly about getting the editor and the agent backend configured.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-fsSL</span> https://raw.githubusercontent.com/borngraced/doom-helix/main/install.sh | sh
</code></pre></div></div>

<p>After installation:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dhx <span class="nt">--health</span>
</code></pre></div></div>

<p>If you are checking a specific language setup:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dhx <span class="nt">--health</span> go
dhx <span class="nt">--health</span> rust
</code></pre></div></div>

<p>The binary you run is:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dhx
</code></pre></div></div>

<p>DoomHelix still uses the normal Helix config and runtime locations, so I do not need to maintain a totally separate editor setup just because I am using the fork. Agent-specific config lives in:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/.config/helix/agent.toml
</code></pre></div></div>

<p>A minimal Codex ACP config looks like this:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="py">enable</span> <span class="p">=</span> <span class="kc">true</span>
<span class="py">name</span> <span class="p">=</span> <span class="s">"codex"</span>
<span class="py">command</span> <span class="p">=</span> <span class="s">"codex-acp"</span>
<span class="py">args</span> <span class="p">=</span> <span class="p">[]</span>
<span class="py">panel-position</span> <span class="p">=</span> <span class="s">"right"</span>
<span class="py">panel-size</span> <span class="p">=</span> <span class="mi">30</span>
<span class="py">auto-context-on-open</span> <span class="p">=</span> <span class="kc">true</span>
<span class="py">include-theme</span> <span class="p">=</span> <span class="kc">true</span>
<span class="py">include-command-history</span> <span class="p">=</span> <span class="kc">true</span>
<span class="py">include-visible-buffer</span> <span class="p">=</span> <span class="kc">true</span>
<span class="py">include-diagnostics</span> <span class="p">=</span> <span class="kc">true</span>
</code></pre></div></div>

<p>The installer does not try to own your full Helix config, which is important to me because I want DoomHelix to feel like Helix with an agent added, not a separate editor that forces me to rebuild all my habits from scratch. Your theme, keymaps, languages, and normal editor settings stay in <code class="language-plaintext highlighter-rouge">~/.config/helix/config.toml</code> and <code class="language-plaintext highlighter-rouge">~/.config/helix/languages.toml</code>.</p>

<h2 id="starting-the-agent">Starting the Agent</h2>

<p>Inside the editor, I keep the agent commands under <code class="language-plaintext highlighter-rouge">Space a</code>, so chat, explain, fix, refactor, panel focus, restart, status, and resizing all live in one place instead of being scattered across random commands.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Space a s   start agent
Space a S   status
Space a c   chat
Space a e   explain selection
Space a f   fix selection
Space a r   refactor selection
Space a E   edit
Space a P   show/focus panel
Space a +   grow agent panel
Space a -   shrink agent panel
Space a R   restart agent
Space a x   clear panel
</code></pre></div></div>

<p>The command form is also available when I want to type the action directly:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>:agent start
:agent status
:agent chat
:agent explain
:agent fix
:agent refactor
:agent panel
:agent restart
</code></pre></div></div>

<p>The panel can be resized with the keyboard and moved between the sides of the editor, which matters more than it sounds because the right layout depends on the task. If I am reading a longer response, bottom panel feels better; if I am editing code and want the agent beside me, right panel is usually better.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>:agent panel position right
:agent panel position bottom
:agent panel position left
:agent panel position top
</code></pre></div></div>

<h2 id="the-important-part-editor-context">The Important Part: Editor Context</h2>

<p>The thing I care about most is context, because an agent in the editor should know what I am looking at without making me manually paste half my workspace into a chat box. If I select code and ask the agent to explain it, DoomHelix sends the selected code, active file path, language, cursor position, diagnostics, and other useful editor state, and the chat view also shows the selected block so I can see exactly what the agent received.</p>

<p>That matters because “review this” is useless if the agent does not know what “this” means. I want the editor to collect the obvious context for me, while still making it clear what was sent.</p>

<p>For example, I can select a Go handler and run:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Space a e
</code></pre></div></div>

<p>Then ask:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>does this handler have any edge cases?
</code></pre></div></div>

<p>The agent sees the selection and responds in the DoomHelix panel, so I do not need to copy the file path, paste the code into a browser, or manually explain where I am in the project before asking the actual question.</p>

<h2 id="fixing-and-refactoring-code">Fixing and Refactoring Code</h2>

<p>The second use case is asking the agent to change code. I can select a function and run:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Space a f
</code></pre></div></div>

<p>or:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Space a r
</code></pre></div></div>

<p>DoomHelix sends the editor context through ACP and lets the backend request tool permissions. That was the direction I wanted from the beginning: the editor should not just be a text box for prompts; it should understand enough about the current file, selection, and diagnostics to make the agent useful.</p>

<p>There is still a lot of work to do. Rendering, permission prompts, session restore, and support for multiple agents can all get better, but the basic loop already feels different from using a separate terminal. I can stay inside the editor, select code, ask for explanations, request edits, resize or move the panel when the response needs more room, and keep working without switching tools.</p>

<h2 id="why-fork-helix">Why Fork Helix?</h2>

<p>I like Helix because it is fast and pragmatic, with strong defaults, good LSP support, tree-sitter, multiple selections, and a clean modal editing model, so I did not want to build a new editor from scratch. I wanted to see what happens when agent-assisted coding is part of a serious modal editor instead of living beside it as another chat window.</p>

<p>DoomHelix is still early, but the direction is simple:</p>

<ul>
  <li>Keep Helix as the foundation.</li>
  <li>Use ACP as the agent protocol.</li>
  <li>Keep context visible and inspectable.</li>
  <li>Make agent work feel like editing, not a separate chat window beside the editor.</li>
</ul>

<p>That is the experiment.</p>

<p>Repo:</p>

<p><a href="https://github.com/borngraced/doom-helix">github.com/borngraced/doom-helix</a></p>]]></content><author><name>Samuel Onoja</name></author><category term="editors" /><summary type="html"><![CDATA[Installing DoomHelix, configuring ACP, and using an agent panel for explain, chat, fix, and refactor commands inside Helix.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://img.youtube.com/vi/8Yl6dZ5LhP4/maxresdefault.jpg" /><media:content medium="image" url="https://img.youtube.com/vi/8Yl6dZ5LhP4/maxresdefault.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">What Happens When You Build an Inode-Style Vector in Rust</title><link href="https://sot.dev/inode-style-vector-in-rust.html" rel="alternate" type="text/html" title="What Happens When You Build an Inode-Style Vector in Rust" /><published>2026-04-22T00:00:00+00:00</published><updated>2026-04-22T00:00:00+00:00</updated><id>https://sot.dev/inode-style-vector-in-rust</id><content type="html" xml:base="https://sot.dev/inode-style-vector-in-rust.html"><![CDATA[<p>While taking some lectures on the Linux filesystem, I got particularly interested in how the inode stores metadata and points to data blocks. I find that whole design very elegant. It solves a real storage problem with a layout that is clearly optimized for the constraints of the filesystem rather than for raw contiguous access. After staring at it for a while, I had the obvious bad idea: what if I translated the same storage model into a custom Rust array? Would it be faster than <code class="language-plaintext highlighter-rouge">SmallVec</code>? Faster than native <code class="language-plaintext highlighter-rouge">Vec</code>? Or would it be the exact kind of cache-unfriendly pointer-chasing machine I already suspected it would be?</p>

<p>The higher-level answer was already obvious to me before I wrote any code. A contiguous array exists for a reason. CPU caches exist for a reason. Filesystem storage layouts and in-memory array layouts are solving very different problems. But some ideas are worth implementing precisely because you know they are probably wrong. They force you to stop hand-waving and actually measure where the machine starts getting annoyed.</p>

<h2 id="the-basic-idea">The Basic Idea</h2>

<p>The shape I wanted was:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">struct</span> <span class="n">PagedSmallVec</span><span class="o">&lt;</span>
    <span class="n">T</span><span class="p">,</span>
    <span class="k">const</span> <span class="n">INLINE</span><span class="p">:</span> <span class="nb">usize</span><span class="p">,</span>
    <span class="k">const</span> <span class="n">DIRECT</span><span class="p">:</span> <span class="nb">usize</span><span class="p">,</span>
    <span class="k">const</span> <span class="n">CHUNK</span><span class="p">:</span> <span class="nb">usize</span><span class="p">,</span>
<span class="o">&gt;</span> <span class="p">{</span>
    <span class="n">direct</span><span class="p">:</span> <span class="p">[</span><span class="nb">Option</span><span class="o">&lt;</span><span class="nb">Box</span><span class="o">&lt;</span><span class="p">[</span><span class="n">MaybeUninit</span><span class="o">&lt;</span><span class="n">T</span><span class="o">&gt;</span><span class="p">;</span> <span class="n">CHUNK</span><span class="p">]</span><span class="o">&gt;&gt;</span><span class="p">;</span> <span class="n">DIRECT</span><span class="p">],</span>
    <span class="n">inline</span><span class="p">:</span> <span class="p">[</span><span class="n">MaybeUninit</span><span class="o">&lt;</span><span class="n">T</span><span class="o">&gt;</span><span class="p">;</span> <span class="n">INLINE</span><span class="p">],</span>
    <span class="n">len</span><span class="p">:</span> <span class="nb">usize</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The plan was simple. Keep a small inline buffer for the first few elements, once that fills up, spill into fixed-size direct chunks instead of one large contiguous heap allocation. Later, if the experiment felt promising, I could extend it into double-indirect and triple-indirect tiers like an inode table.</p>

<p>At a conceptual level, it sounds reasonable: small data stays inline, growth past inline avoids large contiguous reallocations, and the design can scale to multiple indirection levels. It is exactly the kind of idea that sounds smarter in theory than it turns out to be on a CPU.</p>

<h2 id="stage-one-just-inline--direct-chunks">Stage One: Just Inline + Direct Chunks</h2>

<p>I intentionally kept the first version simple. So there’s no double-indirect and triple-indirect. Just enough to see whether the first stage had any chance at all.</p>

<p>The hot indexing path after the inline region is basically:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="n">paged_index</span> <span class="o">=</span> <span class="n">index</span> <span class="o">-</span> <span class="n">INLINE</span><span class="p">;</span>
<span class="k">let</span> <span class="n">chunk_index</span> <span class="o">=</span> <span class="n">paged_index</span> <span class="o">/</span> <span class="n">CHUNK</span><span class="p">;</span>
<span class="k">let</span> <span class="n">offset</span> <span class="o">=</span> <span class="n">paged_index</span> <span class="o">%</span> <span class="n">CHUNK</span><span class="p">;</span>
</code></pre></div></div>

<p>Then read from:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">self</span><span class="py">.direct</span><span class="p">[</span><span class="n">chunk_index</span><span class="p">][</span><span class="n">offset</span><span class="p">]</span>
</code></pre></div></div>

<p>So stage one is not conceptually complicated. It is just an inline array plus a direct chunk table. That means if performance is bad, it is not because the logic is too intellectually deep. It is because every extra indirection, branch, and lookup is costing something real.</p>

<h2 id="the-first-real-problem-unsafe-code">The First Real Problem: Unsafe Code</h2>

<p>The moment you back a container with <code class="language-plaintext highlighter-rouge">MaybeUninit&lt;T&gt;</code>, the project stops being “write a cute data structure” and becomes “maintain invariants without lying to yourself.”</p>

<p>The early versions had all the predictable issues:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">fn</span> <span class="nf">push</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">val</span><span class="p">:</span> <span class="n">T</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">self</span><span class="nf">.write_slot</span><span class="p">(</span><span class="k">self</span><span class="py">.len</span><span class="p">,</span> <span class="n">val</span><span class="p">);</span>
    <span class="k">self</span><span class="py">.len</span> <span class="o">+=</span> <span class="mi">1</span><span class="p">;</span>
<span class="p">}</span>

<span class="k">fn</span> <span class="nf">write_slot</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">index</span><span class="p">:</span> <span class="nb">usize</span><span class="p">,</span> <span class="n">val</span><span class="p">:</span> <span class="n">T</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// ...</span>
    <span class="k">self</span><span class="py">.len</span> <span class="o">+=</span> <span class="mi">1</span><span class="p">;</span> <span class="c1">// bug</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That kind of thing immediately corrupts logical state.</p>

<p>I also had the usual problems around:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fn</span> <span class="nf">take_slot</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">index</span><span class="p">:</span> <span class="nb">usize</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="n">T</span> <span class="p">{</span>
    <span class="k">unsafe</span> <span class="p">{</span> <span class="k">self</span><span class="py">.inline</span><span class="p">[</span><span class="n">index</span><span class="p">]</span><span class="nf">.assume_init_read</span><span class="p">()</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That is only sound if <code class="language-plaintext highlighter-rouge">index &lt; len</code>, the slot is initialized, and length tracking is correct.</p>

<p>And drop is another place where this gets real very quickly:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">impl</span><span class="o">&lt;</span><span class="n">T</span><span class="p">,</span> <span class="k">const</span> <span class="n">INLINE</span><span class="p">:</span> <span class="nb">usize</span><span class="p">,</span> <span class="k">const</span> <span class="n">DIRECT</span><span class="p">:</span> <span class="nb">usize</span><span class="p">,</span> <span class="k">const</span> <span class="n">CHUNK</span><span class="p">:</span> <span class="nb">usize</span><span class="o">&gt;</span> <span class="nb">Drop</span>
    <span class="k">for</span> <span class="n">PagedSmallVec</span><span class="o">&lt;</span><span class="n">T</span><span class="p">,</span> <span class="n">INLINE</span><span class="p">,</span> <span class="n">DIRECT</span><span class="p">,</span> <span class="n">CHUNK</span><span class="o">&gt;</span>
<span class="p">{</span>
    <span class="k">fn</span> <span class="nf">drop</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">for</span> <span class="n">i</span> <span class="k">in</span> <span class="mi">0</span><span class="o">..</span><span class="k">self</span><span class="py">.len</span> <span class="p">{</span>
            <span class="c1">// drop initialized slots only</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Once I started fixing those bugs, it became obvious that the unsafe part of this project was not some tiny implementation detail. It was the foundation. If length tracking is wrong by even one slot, everything after that becomes suspect.</p>

<h2 id="the-initial-api-looked-like-a-normal-vec">The Initial API Looked Like a Normal Vec</h2>

<p>The first public API looked like exactly what you would expect:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">impl</span><span class="o">&lt;</span><span class="n">T</span><span class="p">,</span> <span class="k">const</span> <span class="n">INLINE</span><span class="p">:</span> <span class="nb">usize</span><span class="p">,</span> <span class="k">const</span> <span class="n">DIRECT</span><span class="p">:</span> <span class="nb">usize</span><span class="p">,</span> <span class="k">const</span> <span class="n">CHUNK</span><span class="p">:</span> <span class="nb">usize</span><span class="o">&gt;</span>
    <span class="n">PagedSmallVec</span><span class="o">&lt;</span><span class="n">T</span><span class="p">,</span> <span class="n">INLINE</span><span class="p">,</span> <span class="n">DIRECT</span><span class="p">,</span> <span class="n">CHUNK</span><span class="o">&gt;</span>
<span class="p">{</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">push</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">val</span><span class="p">:</span> <span class="n">T</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">}</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">pop</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">Option</span><span class="o">&lt;</span><span class="n">T</span><span class="o">&gt;</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">}</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">get</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">,</span> <span class="n">index</span><span class="p">:</span> <span class="nb">usize</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">Option</span><span class="o">&lt;&amp;</span><span class="n">T</span><span class="o">&gt;</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">}</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">remove</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">index</span><span class="p">:</span> <span class="nb">usize</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">Option</span><span class="o">&lt;</span><span class="n">T</span><span class="o">&gt;</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">}</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">swap_remove</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">index</span><span class="p">:</span> <span class="nb">usize</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">Option</span><span class="o">&lt;</span><span class="n">T</span><span class="o">&gt;</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That was fine for usability, but it hid the real issue: this structure is not naturally contiguous. So whenever I used it exactly like <code class="language-plaintext highlighter-rouge">Vec</code>, I was forcing it into an access pattern that favored the standard containers.</p>

<h2 id="benchmarks-vec-usually-won-smallvec-usually-came-second">Benchmarks: Vec Usually Won, SmallVec Usually Came Second</h2>

<p>I benchmarked the structure against <code class="language-plaintext highlighter-rouge">Vec&lt;T&gt;</code>, <code class="language-plaintext highlighter-rouge">SmallVec&lt;[T; N]&gt;</code>, and <code class="language-plaintext highlighter-rouge">PagedSmallVec&lt;T, INLINE, DIRECT, CHUNK&gt;</code> across <code class="language-plaintext highlighter-rouge">append_only</code>, <code class="language-plaintext highlighter-rouge">append_pop</code>, <code class="language-plaintext highlighter-rouge">full_iteration</code>, <code class="language-plaintext highlighter-rouge">random_indexing</code>, <code class="language-plaintext highlighter-rouge">remove</code>, and <code class="language-plaintext highlighter-rouge">swap_remove</code>, using <code class="language-plaintext highlighter-rouge">u32</code>, <code class="language-plaintext highlighter-rouge">[u8; 64]</code>, and <code class="language-plaintext highlighter-rouge">String</code> as the test element types. The benchmark harnesses are in the repo too, mainly <a href="https://github.com/borngraced/paged-small-vec/blob/master/benches/compare.rs"><code class="language-plaintext highlighter-rouge">benches/compare.rs</code></a> and <a href="https://github.com/borngraced/paged-small-vec/blob/master/benches/push_u32.rs"><code class="language-plaintext highlighter-rouge">benches/push_u32.rs</code></a>.</p>

<p>The broad result was not subtle.</p>

<p>For normal vector-shaped workloads, <code class="language-plaintext highlighter-rouge">Vec</code> usually came first, <code class="language-plaintext highlighter-rouge">SmallVec</code> usually came second, and the paged structure usually came third. For cheap scalar types like <code class="language-plaintext highlighter-rouge">u32</code>, the gap was especially ugly. That was not surprising once I thought about it properly. A contiguous array is doing exactly what the CPU wants: predictable memory access, fewer pointer hops, fewer branches, fewer cache misses. My paged structure was paying for chunk math and chunk lookups just to reach data that <code class="language-plaintext highlighter-rouge">Vec</code> could reach by simple pointer arithmetic.</p>

<h3 id="focused-u32-microbenchmarks">Focused <code class="language-plaintext highlighter-rouge">u32</code> Microbenchmarks</h3>

<p>To get a cleaner signal, I also split out the <code class="language-plaintext highlighter-rouge">u32</code> append-path microbenchmarks into <a href="https://github.com/borngraced/paged-small-vec/blob/master/benches/push_u32.rs"><code class="language-plaintext highlighter-rouge">benches/push_u32.rs</code></a>. These run at <code class="language-plaintext highlighter-rouge">N = 4096</code> and only measure <code class="language-plaintext highlighter-rouge">push</code>, <code class="language-plaintext highlighter-rouge">push + pop</code>, and <code class="language-plaintext highlighter-rouge">extend_from_slice</code>.</p>

<table>
  <thead>
    <tr>
      <th>Benchmark</th>
      <th style="text-align: right">Vec</th>
      <th style="text-align: right">SmallVec</th>
      <th style="text-align: right">PagedSmallVec&lt;256&gt;</th>
      <th style="text-align: right">PagedSmallVec&lt;512&gt;</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">push_only_micro/u32</code></td>
      <td style="text-align: right">~1.55 Gelem/s</td>
      <td style="text-align: right">~1.09 Gelem/s</td>
      <td style="text-align: right">~0.46 Gelem/s</td>
      <td style="text-align: right">~0.47 Gelem/s</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">push_pop_micro/u32</code></td>
      <td style="text-align: right">~1.20 Gelem/s</td>
      <td style="text-align: right">~0.85 Gelem/s</td>
      <td style="text-align: right">~0.45 Gelem/s</td>
      <td style="text-align: right">~0.46 Gelem/s</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">extend_micro/u32</code></td>
      <td style="text-align: right">~17.58 Gelem/s</td>
      <td style="text-align: right">~14.96 Gelem/s</td>
      <td style="text-align: right">~0.82 Gelem/s</td>
      <td style="text-align: right">~0.86 Gelem/s</td>
    </tr>
  </tbody>
</table>

<p>That table makes the trade-off very explicit. <code class="language-plaintext highlighter-rouge">Vec</code> still wins the normal append-shaped paths comfortably, <code class="language-plaintext highlighter-rouge">SmallVec</code> still does very well, and the paged layouts are only competitive with each other, not with the contiguous ones. But the chunk size result is useful: <code class="language-plaintext highlighter-rouge">512</code> edges out <code class="language-plaintext highlighter-rouge">256</code> for batch append, while both are basically in the same class for the single-element microbenches.</p>

<h3 id="memory-profile">Memory Profile</h3>

<p>I also added a tiny memory probe at <a href="https://github.com/borngraced/paged-small-vec/blob/master/examples/memory_profile.rs"><code class="language-plaintext highlighter-rouge">examples/memory_profile.rs</code></a> and ran it on a one-shot build of 1,000,000 <code class="language-plaintext highlighter-rouge">u32</code> values. The stack footprint numbers come from <code class="language-plaintext highlighter-rouge">size_of::&lt;...&gt;()</code>, and the peak memory numbers come from <code class="language-plaintext highlighter-rouge">/usr/bin/time -l</code>.</p>

<table>
  <thead>
    <tr>
      <th>Container</th>
      <th style="text-align: right">Stack footprint</th>
      <th style="text-align: right">Peak memory footprint</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Vec&lt;u32&gt;</code></td>
      <td style="text-align: right">24 B</td>
      <td style="text-align: right">~7.34 MB</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">SmallVec&lt;[u32; 32]&gt;</code></td>
      <td style="text-align: right">144 B</td>
      <td style="text-align: right">~7.34 MB</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">PagedSmallVec&lt;u32, 32, 4096, 256&gt;</code></td>
      <td style="text-align: right">32,928 B</td>
      <td style="text-align: right">~5.11 MB</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">PagedSmallVec&lt;u32, 32, 4096, 512&gt;</code></td>
      <td style="text-align: right">32,928 B</td>
      <td style="text-align: right">~5.11 MB</td>
    </tr>
  </tbody>
</table>

<p>That result is interesting for the exact reason the throughput tables are bad: the paged layout pays a much larger structural footprint up front, but in this one-shot build it also avoids some of the peak memory growth that comes from contiguous reallocation. So the design is not free, but the cost is not one-dimensional either. You pay for it in raw access speed and container size, and you sometimes get something back in growth behavior.</p>

<h2 id="random-access-was-exactly-as-bad-as-it-should-have-been">Random Access Was Exactly As Bad As It Should Have Been</h2>

<p>The indexed access path looked like this:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">unsafe</span> <span class="k">fn</span> <span class="nf">get_unchecked</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">,</span> <span class="n">index</span><span class="p">:</span> <span class="nb">usize</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="o">&amp;</span><span class="n">T</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">index</span> <span class="o">&lt;</span> <span class="n">INLINE</span> <span class="p">{</span>
        <span class="k">return</span> <span class="k">self</span><span class="py">.inline</span><span class="nf">.get_unchecked</span><span class="p">(</span><span class="n">index</span><span class="p">)</span><span class="nf">.assume_init_ref</span><span class="p">();</span>
    <span class="p">}</span>

    <span class="k">let</span> <span class="n">paged_index</span> <span class="o">=</span> <span class="n">index</span> <span class="o">-</span> <span class="n">INLINE</span><span class="p">;</span>
    <span class="k">let</span> <span class="n">chunk_index</span> <span class="o">=</span> <span class="n">paged_index</span> <span class="o">/</span> <span class="n">CHUNK</span><span class="p">;</span>
    <span class="k">let</span> <span class="n">offset</span> <span class="o">=</span> <span class="n">paged_index</span> <span class="o">%</span> <span class="n">CHUNK</span><span class="p">;</span>

    <span class="k">self</span><span class="py">.direct</span>
        <span class="nf">.get_unchecked</span><span class="p">(</span><span class="n">chunk_index</span><span class="p">)</span>
        <span class="nf">.as_ref</span><span class="p">()</span>
        <span class="nf">.unwrap_unchecked</span><span class="p">()</span>
        <span class="nf">.get_unchecked</span><span class="p">(</span><span class="n">offset</span><span class="p">)</span>
        <span class="nf">.assume_init_ref</span><span class="p">()</span>
<span class="p">}</span>
</code></pre></div></div>

<p>There is nothing shocking about why this loses to <code class="language-plaintext highlighter-rouge">Vec</code> on random indexing. Even the “simple” stage-one version still has an inline-vs-paged branch, subtraction, division, modulo, a chunk lookup, and a slot lookup. That is simply more work than contiguous memory.</p>

<h2 id="ordered-remove-did-not-magically-get-better">Ordered Remove Did Not Magically Get Better</h2>

<p>This was one place where I think it is easy to fool yourself.</p>

<p>You might look at paged storage and think, “maybe middle removal is cheaper because I am moving chunks, not a whole buffer.”</p>

<p>Not really.</p>

<p>If you want order-preserving <code class="language-plaintext highlighter-rouge">remove</code>, you still conceptually shift the whole tail:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">fn</span> <span class="nf">remove</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">index</span><span class="p">:</span> <span class="nb">usize</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">Option</span><span class="o">&lt;</span><span class="n">T</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="k">let</span> <span class="n">removed</span> <span class="o">=</span> <span class="k">self</span><span class="nf">.take_slot</span><span class="p">(</span><span class="n">index</span><span class="p">);</span>

    <span class="k">for</span> <span class="n">i</span> <span class="k">in</span> <span class="p">(</span><span class="n">index</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span><span class="o">..</span><span class="k">self</span><span class="py">.len</span> <span class="p">{</span>
        <span class="k">let</span> <span class="n">val</span> <span class="o">=</span> <span class="k">self</span><span class="nf">.take_slot</span><span class="p">(</span><span class="n">i</span><span class="p">);</span>
        <span class="k">self</span><span class="nf">.write_slot</span><span class="p">(</span><span class="n">i</span> <span class="o">-</span> <span class="mi">1</span><span class="p">,</span> <span class="n">val</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">self</span><span class="py">.len</span> <span class="o">-=</span> <span class="mi">1</span><span class="p">;</span>
    <span class="nf">Some</span><span class="p">(</span><span class="n">removed</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That is still O(n), and in my case it was slower than <code class="language-plaintext highlighter-rouge">Vec</code> and <code class="language-plaintext highlighter-rouge">SmallVec</code> because the contiguous containers get to do their movement in a layout that is much friendlier to the CPU.</p>

<p><code class="language-plaintext highlighter-rouge">swap_remove</code> was less terrible, but again the paged structure did not become some surprise winner. It just became less disadvantaged.</p>

<h2 id="the-first-thing-that-actually-helped-chunk-native-iteration">The First Thing That Actually Helped: Chunk-Native Iteration</h2>

<p>The first genuinely good result came when I stopped traversing the structure through repeated <code class="language-plaintext highlighter-rouge">get(i)</code>.</p>

<p>My original full iteration benchmark looked like this:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="n">i</span> <span class="k">in</span> <span class="mi">0</span><span class="o">..</span><span class="n">vec</span><span class="nf">.len</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">sum</span> <span class="o">+=</span> <span class="n">vec</span><span class="nf">.get</span><span class="p">(</span><span class="n">i</span><span class="p">)</span><span class="nf">.unwrap</span><span class="p">()</span><span class="nf">.score</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That was a bad fit for the layout. Sequential traversal through a paged structure should not pretend to be repeated random indexing.</p>

<p>So I added:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">fn</span> <span class="nf">for_each_chunk</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">,</span> <span class="k">mut</span> <span class="n">f</span><span class="p">:</span> <span class="k">impl</span> <span class="nf">FnMut</span><span class="p">(</span><span class="o">&amp;</span><span class="p">[</span><span class="n">T</span><span class="p">]))</span> <span class="p">{</span>
    <span class="k">for</span> <span class="n">chunk</span> <span class="k">in</span> <span class="k">self</span><span class="nf">.chunks</span><span class="p">()</span> <span class="p">{</span>
        <span class="nf">f</span><span class="p">(</span><span class="n">chunk</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>and:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">fn</span> <span class="nf">for_each_ref</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">,</span> <span class="k">mut</span> <span class="n">f</span><span class="p">:</span> <span class="k">impl</span> <span class="nf">FnMut</span><span class="p">(</span><span class="o">&amp;</span><span class="n">T</span><span class="p">))</span> <span class="p">{</span>
    <span class="k">self</span><span class="nf">.for_each_chunk</span><span class="p">(|</span><span class="n">chunk</span><span class="p">|</span> <span class="p">{</span>
        <span class="k">for</span> <span class="n">value</span> <span class="k">in</span> <span class="n">chunk</span> <span class="p">{</span>
            <span class="nf">f</span><span class="p">(</span><span class="n">value</span><span class="p">);</span>
        <span class="p">}</span>
    <span class="p">});</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That changed the picture a lot. Once full scans were expressed chunk-by-chunk, the structure became much more competitive for sequential traversal. That was the point where the design stopped feeling like “a bad Vec” and started feeling like “a different structure with a different natural API.”</p>

<h2 id="i-added-chunk-iterators-and-batch-append-too">I Added Chunk Iterators and Batch Append Too</h2>

<p>Once chunk traversal proved useful, it made sense to expose it directly:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">struct</span> <span class="n">ChunkIter</span><span class="o">&lt;</span><span class="nv">'a</span><span class="p">,</span> <span class="n">T</span><span class="p">,</span> <span class="k">const</span> <span class="n">INLINE</span><span class="p">:</span> <span class="nb">usize</span><span class="p">,</span> <span class="k">const</span> <span class="n">DIRECT</span><span class="p">:</span> <span class="nb">usize</span><span class="p">,</span> <span class="k">const</span> <span class="n">CHUNK</span><span class="p">:</span> <span class="nb">usize</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="n">vec</span><span class="p">:</span> <span class="o">&amp;</span><span class="nv">'a</span> <span class="n">PagedSmallVec</span><span class="o">&lt;</span><span class="n">T</span><span class="p">,</span> <span class="n">INLINE</span><span class="p">,</span> <span class="n">DIRECT</span><span class="p">,</span> <span class="n">CHUNK</span><span class="o">&gt;</span><span class="p">,</span>
    <span class="n">remaining</span><span class="p">:</span> <span class="nb">usize</span><span class="p">,</span>
    <span class="n">next_chunk_index</span><span class="p">:</span> <span class="nb">usize</span><span class="p">,</span>
    <span class="n">yielded_inline</span><span class="p">:</span> <span class="nb">bool</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And for append-heavy work, I added:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">fn</span> <span class="nf">extend_from_slice</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">values</span><span class="p">:</span> <span class="o">&amp;</span><span class="p">[</span><span class="n">T</span><span class="p">])</span>
<span class="k">where</span>
    <span class="n">T</span><span class="p">:</span> <span class="nb">Clone</span><span class="p">,</span>
<span class="p">{</span>
    <span class="c1">// fill inline first, then write chunk by chunk</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This fit the layout better than repeating <code class="language-plaintext highlighter-rouge">push()</code> in a loop, even when it was not yet a dramatic performance win.</p>

<p>That was another important lesson: a good API does not need to beat the standard one immediately to still be the right API for the structure.</p>

<h2 id="then-i-optimized-the-hot-append-path">Then I Optimized the Hot Append Path</h2>

<p>Append was an obvious place to focus next, so I added:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">tail_chunk_index</span><span class="p">:</span> <span class="nb">usize</span><span class="p">,</span>
<span class="n">tail_offset</span><span class="p">:</span> <span class="nb">usize</span><span class="p">,</span>
<span class="n">current_chunk_ptr</span><span class="p">:</span> <span class="o">*</span><span class="k">mut</span> <span class="n">MaybeUninit</span><span class="o">&lt;</span><span class="n">T</span><span class="o">&gt;</span><span class="p">,</span>
</code></pre></div></div>

<p>The idea was to cache the logical tail position, cache a raw pointer to the current chunk, only allocate the next chunk when entering it, and make steady-state append look like this:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">unsafe</span> <span class="p">{</span> <span class="p">(</span><span class="o">*</span><span class="k">self</span><span class="py">.current_chunk_ptr</span><span class="nf">.add</span><span class="p">(</span><span class="k">self</span><span class="py">.tail_offset</span><span class="p">))</span><span class="nf">.write</span><span class="p">(</span><span class="n">val</span><span class="p">)</span> <span class="p">};</span>
</code></pre></div></div>

<p>That helped append-heavy paths. Not enough to beat <code class="language-plaintext highlighter-rouge">Vec</code>, but enough to remove some of the overhead from repeatedly going through more abstract chunk lookup logic on every write.</p>

<h2 id="chunk-size-was-not-a-one-size-fits-all-story">Chunk Size Was Not a One-Size-Fits-All Story</h2>

<p>I also turned chunk size into a const generic and benchmarked <code class="language-plaintext highlighter-rouge">64</code>, <code class="language-plaintext highlighter-rouge">128</code>, <code class="language-plaintext highlighter-rouge">256</code>, and <code class="language-plaintext highlighter-rouge">512</code>.</p>

<p>What I found was not “bigger is always better.” For <code class="language-plaintext highlighter-rouge">u32</code>, bigger chunks often helped batch append and sometimes helped append-heavy workloads. In <a href="https://github.com/borngraced/paged-small-vec/blob/master/benches/push_u32.rs"><code class="language-plaintext highlighter-rouge">extend_micro/u32</code></a>, for example, <code class="language-plaintext highlighter-rouge">256</code> reached about <code class="language-plaintext highlighter-rouge">576 Melem/s</code> while <code class="language-plaintext highlighter-rouge">512</code> reached about <code class="language-plaintext highlighter-rouge">609 Melem/s</code>. But for pure <code class="language-plaintext highlighter-rouge">push</code> and <code class="language-plaintext highlighter-rouge">push+pop</code>, <code class="language-plaintext highlighter-rouge">256</code> came out as the better compromise: in <a href="https://github.com/borngraced/paged-small-vec/blob/master/benches/push_u32.rs"><code class="language-plaintext highlighter-rouge">push_only_micro/u32</code></a>, <code class="language-plaintext highlighter-rouge">256</code> was about <code class="language-plaintext highlighter-rouge">382 Melem/s</code> while <code class="language-plaintext highlighter-rouge">512</code> was about <code class="language-plaintext highlighter-rouge">342 Melem/s</code>, and in <a href="https://github.com/borngraced/paged-small-vec/blob/master/benches/push_u32.rs"><code class="language-plaintext highlighter-rouge">push_pop_micro/u32</code></a>, <code class="language-plaintext highlighter-rouge">256</code> was about <code class="language-plaintext highlighter-rouge">326 Melem/s</code> while <code class="language-plaintext highlighter-rouge">512</code> was about <code class="language-plaintext highlighter-rouge">314 Melem/s</code>.</p>

<p>For <code class="language-plaintext highlighter-rouge">[u8; 64]</code>, larger chunks did not help nearly as much, and for some paths they were worse. For <code class="language-plaintext highlighter-rouge">String</code>, the differences were smaller and more workload-dependent.</p>

<p>So I settled on <code class="language-plaintext highlighter-rouge">256</code> as the default chunk size because the current container is still more push/pop-shaped than purely bulk-append-shaped.</p>

<h2 id="where-this-can-actually-beat-vec">Where This Can Actually Beat <code class="language-plaintext highlighter-rouge">Vec</code></h2>

<p>The honest answer is: not in the generic “just use it like a normal vector” case. But there are real workloads where a chunked layout like this can outperform <code class="language-plaintext highlighter-rouge">Vec</code>, or at least make a much stronger trade.</p>

<p>The first is append-heavy systems where growth to large sizes is common and the cost of repeated contiguous reallocation actually matters. Think large in-memory logs, event buffers, tracing pipelines, or ingestion queues that mostly grow, occasionally flush, and rarely do random indexing in the middle. In those systems, avoiding a full-buffer move during growth can be more important than having the absolute cheapest indexed load.</p>

<p>The second is chunk-native processing. If your workload naturally operates in runs instead of individual elements, the layout starts to fit the problem much better. A good example is streaming analytics or batch transforms where you want to scan a buffer chunk by chunk, apply a transform, serialize a chunk, compress a chunk, or hand a chunk to another stage. That is exactly why <code class="language-plaintext highlighter-rouge">for_each_chunk</code> and chunk iterators ended up being much more natural APIs than trying to force everything through <code class="language-plaintext highlighter-rouge">get(i)</code>.</p>

<p>The third is memory behavior under allocator pressure. <code class="language-plaintext highlighter-rouge">Vec</code> is excellent when it can keep getting larger contiguous regions cheaply, but that assumption gets weaker for very large or long-lived allocations. In fragmented heaps or systems with many growing buffers alive at once, allocating one more fixed-size chunk can be a better trade than asking the allocator for a much larger contiguous block and then copying everything into it.</p>

<p>The fourth is reference and chunk stability. If a system wants to hold onto chunk-local slices, process pages independently, or hand off partially filled chunks between stages, this structure is much friendlier than a <code class="language-plaintext highlighter-rouge">Vec</code> that may relocate its entire backing buffer on growth. In other words, once the unit of work becomes “chunk” instead of “element”, the design stops looking strange and starts looking deliberate.</p>

<p>So I would not sell this as “better than <code class="language-plaintext highlighter-rouge">Vec</code>.” I would sell it as “better than <code class="language-plaintext highlighter-rouge">Vec</code> for append-heavy, chunk-oriented workloads where contiguous reallocation is one of the real costs.”</p>

<h2 id="the-real-lesson">The Real Lesson</h2>

<p>At this point I think the biggest mistake would be judging the structure only by how well it mimics <code class="language-plaintext highlighter-rouge">Vec</code>.</p>

<p>Its natural APIs are not primarily <code class="language-plaintext highlighter-rouge">get</code>, <code class="language-plaintext highlighter-rouge">remove</code>, and random indexing. They are closer to <code class="language-plaintext highlighter-rouge">for_each_chunk</code>, chunk iterators, batch append, and chunk-wise transforms. That is where the layout starts making sense. Once I stopped forcing the structure through access patterns optimized for contiguous memory, the experiment became much more informative.</p>

<h2 id="my-verdict">My Verdict</h2>

<p>If your goal is to beat <code class="language-plaintext highlighter-rouge">Vec</code>, beat <code class="language-plaintext highlighter-rouge">SmallVec</code>, win scalar <code class="language-plaintext highlighter-rouge">push</code> and <code class="language-plaintext highlighter-rouge">pop</code>, or win random indexing, inode-style array storage in Rust is a bad idea.</p>

<p>If your goal is to understand low-level container invariants, see exactly where pointer-heavy layouts lose, experiment with chunk-native APIs, and learn where contiguous storage wins and why, then it is a very good experiment.</p>

<p>So my final verdict is this: judged as a traditional vector, it did not do well but judged as an experiment, it went very well, because it made the trade-offs impossible to ignore. And honestly, that is usually the most useful outcome from a bad idea that was worth building anyway.</p>

<p>If you want to read through the full implementation, here is the code on GitHub: <a href="https://github.com/borngraced/paged-small-vec">borngraced/paged-small-vec</a>.</p>]]></content><author><name>Samuel Onoja</name></author><summary type="html"><![CDATA[I built an inode-inspired paged array in Rust, benchmarked it against Vec and SmallVec, and found exactly where the idea falls apart.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sot.dev/assets/images/inode-style-vector-in-rust.png" /><media:content medium="image" url="https://sot.dev/assets/images/inode-style-vector-in-rust.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Everything Should Be Typed: Scalar Types Are Not Enough</title><link href="https://sot.dev/everything-should-be-typed.html" rel="alternate" type="text/html" title="Everything Should Be Typed: Scalar Types Are Not Enough" /><published>2026-04-13T00:00:00+00:00</published><updated>2026-04-13T00:00:00+00:00</updated><id>https://sot.dev/everything-should-be-typed</id><content type="html" xml:base="https://sot.dev/everything-should-be-typed.html"><![CDATA[<p>Recently while working in a codebase, I had a serious bug originating from a very small and minor misuse of arguments to a function which also led to my issue in <a href="https://github.com/rust-lang/rust-clippy/issues/16735">rust-clippy</a>. I was ranting about it on X too:</p>

<blockquote class="twitter-tweet"><p><a href="https://twitter.com/sotdev_/status/2034796732366667932">Tweet</a></p></blockquote>

<p>So I said why not write on why developers shouldn’t stop at scalar types. Imagine a function takes a <code class="language-plaintext highlighter-rouge">string</code>, returns a <code class="language-plaintext highlighter-rouge">number</code>, and we call it “typed.” But this is a shallow form of type safety, one that gives us a false sense of security while letting entire categories of bugs slip through unnoticed.</p>

<p>Let me walk you through why scalar types fail us, and what I believe a truly typed codebase should look like.</p>

<h2 id="the-positional-parameter-problem">The Positional Parameter Problem</h2>

<p>Say I have a function that processes a seller payout after an order is delivered. It takes a shop ID, a customer ID, an order ID, the gross amount, platform fee, transaction fee, and net amount.</p>

<p><strong>JavaScript:</strong></p>
<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">processOrderPayout</span><span class="p">(</span><span class="nx">shopId</span><span class="p">,</span> <span class="nx">customerId</span><span class="p">,</span> <span class="nx">orderId</span><span class="p">,</span> <span class="nx">amount</span><span class="p">,</span> <span class="nx">platformFee</span><span class="p">,</span> <span class="nx">txFee</span><span class="p">,</span> <span class="nx">netAmount</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>Go:</strong></p>
<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">ProcessOrderPayout</span><span class="p">(</span><span class="n">shopID</span> <span class="kt">string</span><span class="p">,</span> <span class="n">customerID</span> <span class="kt">string</span><span class="p">,</span> <span class="n">orderID</span> <span class="kt">string</span><span class="p">,</span> <span class="n">amount</span> <span class="kt">int64</span><span class="p">,</span> <span class="n">platformFee</span> <span class="kt">int64</span><span class="p">,</span> <span class="n">txFee</span> <span class="kt">int64</span><span class="p">,</span> <span class="n">netAmount</span> <span class="kt">int64</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="c">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>Rust:</strong></p>
<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fn</span> <span class="nf">process_order_payout</span><span class="p">(</span><span class="n">shop_id</span><span class="p">:</span> <span class="nb">String</span><span class="p">,</span> <span class="n">customer_id</span><span class="p">:</span> <span class="nb">String</span><span class="p">,</span> <span class="n">order_id</span><span class="p">:</span> <span class="nb">String</span><span class="p">,</span> <span class="n">amount</span><span class="p">:</span> <span class="nb">i64</span><span class="p">,</span> <span class="n">platform_fee</span><span class="p">:</span> <span class="nb">i64</span><span class="p">,</span> <span class="n">tx_fee</span><span class="p">:</span> <span class="nb">i64</span><span class="p">,</span> <span class="n">net_amount</span><span class="p">:</span> <span class="nb">i64</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Seven parameters. Three IDs that are all strings. Four money values that are all integers. Now imagine somewhere in my codebase, a caller writes this:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">process_order_payout</span><span class="p">(</span><span class="n">customer_id</span><span class="p">,</span> <span class="n">shop_id</span><span class="p">,</span> <span class="n">order_id</span><span class="p">,</span> <span class="n">net_amount</span><span class="p">,</span> <span class="n">tx_fee</span><span class="p">,</span> <span class="n">platform_fee</span><span class="p">,</span> <span class="n">amount</span><span class="p">);</span>
</code></pre></div></div>

<p>The customer ID went in place of the shop ID. The net amount went in place of the gross amount. The fees are swapped. The compiler doesn’t complain. Tests probably pass too. The application runs, pays out the wrong entity, credits the wrong amount, and nobody notices until a seller asks why they received ₦350 instead of ₦54,000.</p>

<p>This is what bit me. The compiler checks the <em>shape</em> of the data, not the <em>meaning</em>. A <code class="language-plaintext highlighter-rouge">String</code> is a <code class="language-plaintext highlighter-rouge">String</code> is a <code class="language-plaintext highlighter-rouge">String</code>, and an <code class="language-plaintext highlighter-rouge">i64</code> is an <code class="language-plaintext highlighter-rouge">i64</code> is an <code class="language-plaintext highlighter-rouge">i64</code>. The type system has no way to tell a shop ID apart from a customer ID, or a gross amount from a net amount, when they share the same underlying type.</p>

<h2 id="just-use-a-struct-better-but-not-enough">“Just Use a Struct.” Better, But Not Enough</h2>

<p>The natural next step is grouping parameters into a struct or object.</p>

<p><strong>JavaScript:</strong></p>
<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">processOrderPayout</span><span class="p">({</span> <span class="nx">shopId</span><span class="p">,</span> <span class="nx">customerId</span><span class="p">,</span> <span class="nx">orderId</span><span class="p">,</span> <span class="nx">amount</span><span class="p">,</span> <span class="nx">platformFee</span><span class="p">,</span> <span class="nx">txFee</span><span class="p">,</span> <span class="nx">netAmount</span> <span class="p">})</span> <span class="p">{</span>
  <span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>Go:</strong></p>
<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">OrderPayoutParams</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">ShopID</span>      <span class="kt">string</span>
    <span class="n">CustomerID</span>  <span class="kt">string</span>
    <span class="n">OrderID</span>     <span class="kt">string</span>
    <span class="n">Amount</span>      <span class="kt">int64</span>
    <span class="n">PlatformFee</span> <span class="kt">int64</span>
    <span class="n">TxFee</span>       <span class="kt">int64</span>
    <span class="n">NetAmount</span>   <span class="kt">int64</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">ProcessOrderPayout</span><span class="p">(</span><span class="n">params</span> <span class="n">OrderPayoutParams</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="c">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>Rust:</strong></p>
<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">OrderPayoutParams</span> <span class="p">{</span>
    <span class="n">shop_id</span><span class="p">:</span> <span class="nb">String</span><span class="p">,</span>
    <span class="n">customer_id</span><span class="p">:</span> <span class="nb">String</span><span class="p">,</span>
    <span class="n">order_id</span><span class="p">:</span> <span class="nb">String</span><span class="p">,</span>
    <span class="n">amount</span><span class="p">:</span> <span class="nb">i64</span><span class="p">,</span>
    <span class="n">platform_fee</span><span class="p">:</span> <span class="nb">i64</span><span class="p">,</span>
    <span class="n">tx_fee</span><span class="p">:</span> <span class="nb">i64</span><span class="p">,</span>
    <span class="n">net_amount</span><span class="p">:</span> <span class="nb">i64</span><span class="p">,</span>
<span class="p">}</span>

<span class="k">fn</span> <span class="nf">process_order_payout</span><span class="p">(</span><span class="n">params</span><span class="p">:</span> <span class="n">OrderPayoutParams</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This is better. Named fields eliminate positional confusion. You can’t accidentally swap <code class="language-plaintext highlighter-rouge">shop_id</code> and <code class="language-plaintext highlighter-rouge">customer_id</code> when you’re explicitly naming them at the call site.</p>

<p>But we’ve only solved one problem. Look at what the struct <em>doesn’t</em> prevent:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="n">params</span> <span class="o">=</span> <span class="n">OrderPayoutParams</span> <span class="p">{</span>
    <span class="n">shop_id</span><span class="p">:</span> <span class="n">customer_id</span><span class="p">,</span>       <span class="c1">// oops, customer ID assigned to shop field</span>
    <span class="n">customer_id</span><span class="p">:</span> <span class="n">shop_id</span><span class="p">,</span>       <span class="c1">// oops, shop ID assigned to customer field</span>
    <span class="n">order_id</span><span class="p">:</span> <span class="n">order_id</span><span class="p">,</span>
    <span class="n">amount</span><span class="p">:</span> <span class="n">net_amount</span><span class="p">,</span>         <span class="c1">// oops, net amount assigned to gross amount field</span>
    <span class="n">platform_fee</span><span class="p">:</span> <span class="n">tx_fee</span><span class="p">,</span>       <span class="c1">// oops, fees are swapped</span>
    <span class="n">tx_fee</span><span class="p">:</span> <span class="n">platform_fee</span><span class="p">,</span>
    <span class="n">net_amount</span><span class="p">:</span> <span class="n">amount</span><span class="p">,</span>         <span class="c1">// oops, gross amount assigned to net field</span>
<span class="p">};</span>
</code></pre></div></div>

<p>The compiler is perfectly happy. Every string field got a <code class="language-plaintext highlighter-rouge">String</code>. Every integer field got an <code class="language-plaintext highlighter-rouge">i64</code>. The fact that <code class="language-plaintext highlighter-rouge">customer_id</code> contains a customer identifier and not a shop identifier? Invisible to the type system.</p>

<p>This might seem contrived, but it happens all the time in real codebases. Variables get renamed. Data flows through multiple layers. A function receives values from a database row and passes them into a struct, and nobody remembers which column mapped to which field. Someone refactors and swaps two fields, and the compiler catches zero of the call sites that now pass data into the wrong slots.</p>

<p>The struct gave us named assignment, but not semantic correctness. The types are still lying. They say “this field accepts a string” when what we actually mean is “this field accepts a <em>shop identifier</em>.” They say “this field accepts an integer” when what we actually mean is “this field accepts a <em>platform fee in kobo</em>.”</p>

<h2 id="the-deeper-problem-primitives-erase-meaning">The Deeper Problem: Primitives Erase Meaning</h2>

<p>This goes way beyond my payout example. Scalar types like <code class="language-plaintext highlighter-rouge">string</code>, <code class="language-plaintext highlighter-rouge">int</code>, <code class="language-plaintext highlighter-rouge">float</code>, and <code class="language-plaintext highlighter-rouge">bool</code> are building blocks, but they carry no domain meaning. When your codebase passes around raw primitives everywhere, you lose the ability to reason about what data actually <em>means</em> at the type level.</p>

<p>Here are bugs I’ve either hit or seen others hit that scalar types will never catch:</p>

<p><strong>Passing a user ID where an order ID is expected.</strong> Both are <code class="language-plaintext highlighter-rouge">int</code> or <code class="language-plaintext highlighter-rouge">string</code>. Both represent identifiers. But mixing them up means you’re querying the wrong table, charging the wrong customer, or deleting the wrong record.</p>

<p><strong>Mixing up units.</strong> A distance in meters passed to a function expecting kilometers. A price in cents passed to a function expecting dollars. A duration in seconds stored in a field labeled “minutes.” All the same type, <code class="language-plaintext highlighter-rouge">f64</code> or <code class="language-plaintext highlighter-rouge">int</code>, and the compiler won’t say a word.</p>

<p><strong>Confusing sanitized and unsanitized input.</strong> A raw user-provided string passed directly into a SQL query or HTML template. The type system sees <code class="language-plaintext highlighter-rouge">String</code>. It doesn’t know “this string hasn’t been escaped yet.” This is how injection vulnerabilities happen.</p>

<p><strong>Swapping latitude and longitude.</strong> Both <code class="language-plaintext highlighter-rouge">f64</code>. Both coordinates. Swap them and your map renders on the wrong continent.</p>

<p>All of these compile. All of them might pass tests. All of them have caused real production incidents. And all of them are preventable.</p>

<h2 id="the-solution-make-invalid-states-unrepresentable">The Solution: Make Invalid States Unrepresentable</h2>

<p>The fix is simpler than you’d think. Stop using scalar types for domain concepts. Wrap every meaningful value in its own type.</p>

<p><strong>Rust:</strong></p>
<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="nf">ShopId</span><span class="p">(</span><span class="nb">String</span><span class="p">);</span>
<span class="k">struct</span> <span class="nf">CustomerId</span><span class="p">(</span><span class="nb">String</span><span class="p">);</span>
<span class="k">struct</span> <span class="nf">OrderId</span><span class="p">(</span><span class="nb">String</span><span class="p">);</span>
<span class="k">struct</span> <span class="nf">Amount</span><span class="p">(</span><span class="nb">i64</span><span class="p">);</span>
<span class="k">struct</span> <span class="nf">PlatformFee</span><span class="p">(</span><span class="nb">i64</span><span class="p">);</span>
<span class="k">struct</span> <span class="nf">TxFee</span><span class="p">(</span><span class="nb">i64</span><span class="p">);</span>
<span class="k">struct</span> <span class="nf">NetAmount</span><span class="p">(</span><span class="nb">i64</span><span class="p">);</span>

<span class="k">struct</span> <span class="n">OrderPayoutParams</span> <span class="p">{</span>
    <span class="n">shop_id</span><span class="p">:</span> <span class="n">ShopId</span><span class="p">,</span>
    <span class="n">customer_id</span><span class="p">:</span> <span class="n">CustomerId</span><span class="p">,</span>
    <span class="n">order_id</span><span class="p">:</span> <span class="n">OrderId</span><span class="p">,</span>
    <span class="n">amount</span><span class="p">:</span> <span class="n">Amount</span><span class="p">,</span>
    <span class="n">platform_fee</span><span class="p">:</span> <span class="n">PlatformFee</span><span class="p">,</span>
    <span class="n">tx_fee</span><span class="p">:</span> <span class="n">TxFee</span><span class="p">,</span>
    <span class="n">net_amount</span><span class="p">:</span> <span class="n">NetAmount</span><span class="p">,</span>
<span class="p">}</span>

<span class="k">fn</span> <span class="nf">process_order_payout</span><span class="p">(</span><span class="n">params</span><span class="p">:</span> <span class="n">OrderPayoutParams</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now try to swap them:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="n">params</span> <span class="o">=</span> <span class="n">OrderPayoutParams</span> <span class="p">{</span>
    <span class="n">shop_id</span><span class="p">:</span> <span class="n">customer_id</span><span class="p">,</span>       <span class="c1">// ERROR: expected `ShopId`, found `CustomerId`</span>
    <span class="n">customer_id</span><span class="p">:</span> <span class="n">shop_id</span><span class="p">,</span>       <span class="c1">// ERROR: expected `CustomerId`, found `ShopId`</span>
    <span class="n">order_id</span><span class="p">:</span> <span class="n">order_id</span><span class="p">,</span>
    <span class="n">amount</span><span class="p">:</span> <span class="n">net_amount</span><span class="p">,</span>         <span class="c1">// ERROR: expected `Amount`, found `NetAmount`</span>
    <span class="n">platform_fee</span><span class="p">:</span> <span class="n">tx_fee</span><span class="p">,</span>       <span class="c1">// ERROR: expected `PlatformFee`, found `TxFee`</span>
    <span class="n">tx_fee</span><span class="p">:</span> <span class="n">platform_fee</span><span class="p">,</span>       <span class="c1">// ERROR: expected `TxFee`, found `PlatformFee`</span>
    <span class="n">net_amount</span><span class="p">:</span> <span class="n">amount</span><span class="p">,</span>         <span class="c1">// ERROR: expected `NetAmount`, found `Amount`</span>
<span class="p">};</span>
</code></pre></div></div>

<p>The compiler refuses. Not because the data is shaped wrong, but because the <em>meaning</em> is wrong. <code class="language-plaintext highlighter-rouge">CustomerId</code> is not <code class="language-plaintext highlighter-rouge">ShopId</code>, even though both wrap a <code class="language-plaintext highlighter-rouge">String</code> underneath. <code class="language-plaintext highlighter-rouge">NetAmount</code> is not <code class="language-plaintext highlighter-rouge">Amount</code>, even though both wrap an <code class="language-plaintext highlighter-rouge">i64</code>.</p>

<p><strong>Go:</strong></p>
<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">ShopID</span> <span class="kt">string</span>
<span class="k">type</span> <span class="n">CustomerID</span> <span class="kt">string</span>
<span class="k">type</span> <span class="n">OrderID</span> <span class="kt">string</span>
<span class="k">type</span> <span class="n">Amount</span> <span class="kt">int64</span>
<span class="k">type</span> <span class="n">PlatformFee</span> <span class="kt">int64</span>
<span class="k">type</span> <span class="n">TxFee</span> <span class="kt">int64</span>
<span class="k">type</span> <span class="n">NetAmount</span> <span class="kt">int64</span>

<span class="k">type</span> <span class="n">OrderPayoutParams</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">ShopID</span>      <span class="n">ShopID</span>
    <span class="n">CustomerID</span>  <span class="n">CustomerID</span>
    <span class="n">OrderID</span>     <span class="n">OrderID</span>
    <span class="n">Amount</span>      <span class="n">Amount</span>
    <span class="n">PlatformFee</span> <span class="n">PlatformFee</span>
    <span class="n">TxFee</span>       <span class="n">TxFee</span>
    <span class="n">NetAmount</span>   <span class="n">NetAmount</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">ProcessOrderPayout</span><span class="p">(</span><span class="n">params</span> <span class="n">OrderPayoutParams</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="c">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Go’s type definitions are not aliases. <code class="language-plaintext highlighter-rouge">ShopID</code> and <code class="language-plaintext highlighter-rouge">CustomerID</code> are distinct types. Passing one where the other is expected is a compile-time error. Same for <code class="language-plaintext highlighter-rouge">Amount</code> vs <code class="language-plaintext highlighter-rouge">NetAmount</code> vs <code class="language-plaintext highlighter-rouge">PlatformFee</code>.</p>

<p><strong>TypeScript:</strong></p>
<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">type</span> <span class="nx">ShopId</span> <span class="o">=</span> <span class="kr">string</span> <span class="o">&amp;</span> <span class="p">{</span> <span class="k">readonly</span> <span class="na">__brand</span><span class="p">:</span> <span class="dl">"</span><span class="s2">ShopId</span><span class="dl">"</span> <span class="p">};</span>
<span class="kd">type</span> <span class="nx">CustomerId</span> <span class="o">=</span> <span class="kr">string</span> <span class="o">&amp;</span> <span class="p">{</span> <span class="k">readonly</span> <span class="na">__brand</span><span class="p">:</span> <span class="dl">"</span><span class="s2">CustomerId</span><span class="dl">"</span> <span class="p">};</span>
<span class="kd">type</span> <span class="nx">OrderId</span> <span class="o">=</span> <span class="kr">string</span> <span class="o">&amp;</span> <span class="p">{</span> <span class="k">readonly</span> <span class="na">__brand</span><span class="p">:</span> <span class="dl">"</span><span class="s2">OrderId</span><span class="dl">"</span> <span class="p">};</span>
<span class="kd">type</span> <span class="nx">Amount</span> <span class="o">=</span> <span class="kr">number</span> <span class="o">&amp;</span> <span class="p">{</span> <span class="k">readonly</span> <span class="na">__brand</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Amount</span><span class="dl">"</span> <span class="p">};</span>
<span class="kd">type</span> <span class="nx">PlatformFee</span> <span class="o">=</span> <span class="kr">number</span> <span class="o">&amp;</span> <span class="p">{</span> <span class="k">readonly</span> <span class="na">__brand</span><span class="p">:</span> <span class="dl">"</span><span class="s2">PlatformFee</span><span class="dl">"</span> <span class="p">};</span>
<span class="kd">type</span> <span class="nx">TxFee</span> <span class="o">=</span> <span class="kr">number</span> <span class="o">&amp;</span> <span class="p">{</span> <span class="k">readonly</span> <span class="na">__brand</span><span class="p">:</span> <span class="dl">"</span><span class="s2">TxFee</span><span class="dl">"</span> <span class="p">};</span>
<span class="kd">type</span> <span class="nx">NetAmount</span> <span class="o">=</span> <span class="kr">number</span> <span class="o">&amp;</span> <span class="p">{</span> <span class="k">readonly</span> <span class="na">__brand</span><span class="p">:</span> <span class="dl">"</span><span class="s2">NetAmount</span><span class="dl">"</span> <span class="p">};</span>

<span class="kr">interface</span> <span class="nx">OrderPayoutParams</span> <span class="p">{</span>
  <span class="nl">shopId</span><span class="p">:</span> <span class="nx">ShopId</span><span class="p">;</span>
  <span class="nl">customerId</span><span class="p">:</span> <span class="nx">CustomerId</span><span class="p">;</span>
  <span class="nl">orderId</span><span class="p">:</span> <span class="nx">OrderId</span><span class="p">;</span>
  <span class="nl">amount</span><span class="p">:</span> <span class="nx">Amount</span><span class="p">;</span>
  <span class="nl">platformFee</span><span class="p">:</span> <span class="nx">PlatformFee</span><span class="p">;</span>
  <span class="nl">txFee</span><span class="p">:</span> <span class="nx">TxFee</span><span class="p">;</span>
  <span class="nl">netAmount</span><span class="p">:</span> <span class="nx">NetAmount</span><span class="p">;</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nx">processOrderPayout</span><span class="p">(</span><span class="nx">params</span><span class="p">:</span> <span class="nx">OrderPayoutParams</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>TypeScript doesn’t have native newtype wrappers, so we use branded types. It’s a well-established pattern that adds a phantom property to prevent accidental interchange. A small amount of ceremony that pays for itself immediately.</p>

<h2 id="living-with-newtypes">Living With Newtypes</h2>

<p>The first thing people ask when they see this is “okay but now I can’t do anything with my data.” That’s fair. A <code class="language-plaintext highlighter-rouge">ShopId(String)</code> doesn’t have <code class="language-plaintext highlighter-rouge">.len()</code> or <code class="language-plaintext highlighter-rouge">.contains()</code> or any of the methods you’re used to calling on <code class="language-plaintext highlighter-rouge">String</code>. You’d have to write <code class="language-plaintext highlighter-rouge">shop_id.0.len()</code> everywhere, and that’s ugly.</p>

<p>This is where <code class="language-plaintext highlighter-rouge">Deref</code> comes in. In Rust, you can implement <code class="language-plaintext highlighter-rouge">Deref</code> to let your newtype transparently expose the inner type’s methods:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">use</span> <span class="nn">std</span><span class="p">::</span><span class="nn">ops</span><span class="p">::</span><span class="n">Deref</span><span class="p">;</span>

<span class="k">struct</span> <span class="nf">ShopId</span><span class="p">(</span><span class="nb">String</span><span class="p">);</span>

<span class="k">impl</span> <span class="n">Deref</span> <span class="k">for</span> <span class="n">ShopId</span> <span class="p">{</span>
    <span class="k">type</span> <span class="n">Target</span> <span class="o">=</span> <span class="nb">String</span><span class="p">;</span>

    <span class="k">fn</span> <span class="nf">deref</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="o">&amp;</span><span class="nb">String</span> <span class="p">{</span>
        <span class="o">&amp;</span><span class="k">self</span><span class="na">.0</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="k">let</span> <span class="n">shop</span> <span class="o">=</span> <span class="nf">ShopId</span><span class="p">(</span><span class="s">"shop_abc123"</span><span class="nf">.to_string</span><span class="p">());</span>
<span class="nd">println!</span><span class="p">(</span><span class="s">"{}"</span><span class="p">,</span> <span class="n">shop</span><span class="nf">.len</span><span class="p">());</span>        <span class="c1">// works, delegates to String::len()</span>
<span class="nd">println!</span><span class="p">(</span><span class="s">"{}"</span><span class="p">,</span> <span class="n">shop</span><span class="nf">.to_uppercase</span><span class="p">());</span> <span class="c1">// works too</span>
</code></pre></div></div>

<p>You get full access to all <code class="language-plaintext highlighter-rouge">String</code> methods without unwrapping. But the type system still prevents you from passing a <code class="language-plaintext highlighter-rouge">ShopId</code> where a <code class="language-plaintext highlighter-rouge">CustomerId</code> is expected. Best of both worlds.</p>

<p>You’ll also want <code class="language-plaintext highlighter-rouge">Display</code> and <code class="language-plaintext highlighter-rouge">From</code> so your types play nicely with the rest of your code:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">use</span> <span class="nn">std</span><span class="p">::</span><span class="n">fmt</span><span class="p">;</span>

<span class="k">impl</span> <span class="nn">fmt</span><span class="p">::</span><span class="n">Display</span> <span class="k">for</span> <span class="n">ShopId</span> <span class="p">{</span>
    <span class="k">fn</span> <span class="nf">fmt</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">,</span> <span class="n">f</span><span class="p">:</span> <span class="o">&amp;</span><span class="k">mut</span> <span class="nn">fmt</span><span class="p">::</span><span class="n">Formatter</span><span class="o">&lt;</span><span class="nv">'_</span><span class="o">&gt;</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nn">fmt</span><span class="p">::</span><span class="nb">Result</span> <span class="p">{</span>
        <span class="nd">write!</span><span class="p">(</span><span class="n">f</span><span class="p">,</span> <span class="s">"{}"</span><span class="p">,</span> <span class="k">self</span><span class="na">.0</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="k">impl</span> <span class="nb">From</span><span class="o">&lt;</span><span class="nb">String</span><span class="o">&gt;</span> <span class="k">for</span> <span class="n">ShopId</span> <span class="p">{</span>
    <span class="k">fn</span> <span class="nf">from</span><span class="p">(</span><span class="n">s</span><span class="p">:</span> <span class="nb">String</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="k">Self</span> <span class="p">{</span>
        <span class="nf">ShopId</span><span class="p">(</span><span class="n">s</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="c1">// Now you can do:</span>
<span class="k">let</span> <span class="n">shop</span><span class="p">:</span> <span class="n">ShopId</span> <span class="o">=</span> <span class="s">"shop_abc123"</span><span class="nf">.to_string</span><span class="p">()</span><span class="nf">.into</span><span class="p">();</span>
<span class="nd">println!</span><span class="p">(</span><span class="s">"Processing payout for {shop}"</span><span class="p">);</span>
</code></pre></div></div>

<p>And here’s where it gets really powerful. You can add validation directly in the constructor, so invalid data can never become a <code class="language-plaintext highlighter-rouge">ShopId</code> in the first place:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">impl</span> <span class="n">ShopId</span> <span class="p">{</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">new</span><span class="p">(</span><span class="n">id</span><span class="p">:</span> <span class="nb">String</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">Result</span><span class="o">&lt;</span><span class="k">Self</span><span class="p">,</span> <span class="nb">String</span><span class="o">&gt;</span> <span class="p">{</span>
        <span class="k">if</span> <span class="n">id</span><span class="nf">.is_empty</span><span class="p">()</span> <span class="p">{</span>
            <span class="k">return</span> <span class="nf">Err</span><span class="p">(</span><span class="s">"Shop ID cannot be empty"</span><span class="nf">.into</span><span class="p">());</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="o">!</span><span class="n">id</span><span class="nf">.starts_with</span><span class="p">(</span><span class="s">"shop_"</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">return</span> <span class="nf">Err</span><span class="p">(</span><span class="s">"Shop ID must start with 'shop_'"</span><span class="nf">.into</span><span class="p">());</span>
        <span class="p">}</span>
        <span class="nf">Ok</span><span class="p">(</span><span class="nf">ShopId</span><span class="p">(</span><span class="n">id</span><span class="p">))</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Once a <code class="language-plaintext highlighter-rouge">ShopId</code> exists in your system, you <em>know</em> it’s valid. Every function that receives a <code class="language-plaintext highlighter-rouge">ShopId</code> can skip validation entirely. The constructor already did the work.</p>

<p>In Go, defined types start with an empty method set, but built-in operations like <code class="language-plaintext highlighter-rouge">len()</code> still work and you can add your own methods:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">ShopID</span> <span class="kt">string</span>

<span class="n">id</span> <span class="o">:=</span> <span class="n">ShopID</span><span class="p">(</span><span class="s">"shop_abc123"</span><span class="p">)</span>
<span class="n">fmt</span><span class="o">.</span><span class="n">Println</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">id</span><span class="p">))</span> <span class="c">// works, len() is a built-in function</span>

<span class="c">// Add your own methods</span>
<span class="k">func</span> <span class="p">(</span><span class="n">id</span> <span class="n">ShopID</span><span class="p">)</span> <span class="n">Validate</span><span class="p">()</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">id</span> <span class="o">==</span> <span class="s">""</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">errors</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"shop ID cannot be empty"</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>In TypeScript, branded types are just structural, so all <code class="language-plaintext highlighter-rouge">string</code> or <code class="language-plaintext highlighter-rouge">number</code> operations work without any extra code. The brand only exists at compile time.</p>

<h2 id="what-you-actually-gain">What You Actually Gain</h2>

<p>This isn’t just about catching swapped arguments. It changes how you think about your code.</p>

<p><strong>Self-documenting code.</strong> When a function takes <code class="language-plaintext highlighter-rouge">ShopId</code> instead of <code class="language-plaintext highlighter-rouge">String</code>, you don’t need a doc comment explaining what that parameter is. The type <em>is</em> the documentation.</p>

<p><strong>Refactoring confidence.</strong> When you rename a field or change a data flow, the compiler traces every usage of that type across your entire codebase. Nothing slips through.</p>

<p><strong>Validation at the boundary.</strong> When you construct a <code class="language-plaintext highlighter-rouge">ShopId</code>, you can enforce invariants: must be a valid ObjectID format, can’t be empty, must exist in the database. Every <code class="language-plaintext highlighter-rouge">ShopId</code> in your system is guaranteed valid. Not because every function checks, but because the constructor checked once and the type system carries that guarantee forward.</p>

<p><strong>Grep-ability.</strong> Searching for <code class="language-plaintext highlighter-rouge">ShopId</code> in your codebase shows you every place a shop identifier is created, passed, stored, or transformed. Searching for <code class="language-plaintext highlighter-rouge">String</code> shows you everything.</p>

<p><strong>Security.</strong> A <code class="language-plaintext highlighter-rouge">RawUserInput</code> type that must be explicitly converted to <code class="language-plaintext highlighter-rouge">SanitizedHtml</code> before rendering? That’s injection prevention enforced by the compiler, not by code review discipline.</p>

<h2 id="the-cost-is-lower-than-you-think">The Cost Is Lower Than You Think</h2>

<p>The most common objection is ceremony. “I don’t want to wrap every string in a newtype.” But think about the alternative: you’re trusting that every developer on your team, across every PR, in every late-night hotfix, will correctly match unnamed strings to their intended purpose. That’s not engineering. That’s hope.</p>

<p>The wrapper types are typically two to five lines each. You write them once. The compiler enforces them forever.</p>

<hr />

<p>Scalar types describe what data <em>looks like</em>. A sequence of characters, a 64-bit integer, a boolean flag. Domain types describe what data <em>means</em>. A title, a price in USD, a sanitized HTML fragment, a user ID.</p>

<p>The gap between these two is where bugs live. I learned that the hard way. Wrap your primitives. Make your types mean something. Let the compiler do the work that code review and testing never will.</p>

<p>—Samuel</p>

<hr />

<p>If you want a very different kind of type-and-layout experiment, I also wrote about building an <a href="/inode-style-vector-in-rust.html">inode-style vector in Rust</a> and benchmarking where it loses to <code class="language-plaintext highlighter-rouge">Vec</code>.</p>

<hr />

<p><em>Edits:</em></p>
<ul>
  <li><em>2026-04-15: Corrected Go section on defined types and method sets based on readers feedback.</em></li>
</ul>]]></content><author><name>Samuel Onoja</name></author><summary type="html"><![CDATA[Why scalar types give us a false sense of safety, and how wrapping primitives in domain types catches bugs the compiler never could.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sot.dev/assets/images/everything-should-be-typed.png" /><media:content medium="image" url="https://sot.dev/assets/images/everything-should-be-typed.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Building Khoomi - Week 3: Multi-Vendor Order Architecture</title><link href="https://sot.dev/building-khoomi-week-3.html" rel="alternate" type="text/html" title="Building Khoomi - Week 3: Multi-Vendor Order Architecture" /><published>2026-01-30T00:00:00+00:00</published><updated>2026-01-30T00:00:00+00:00</updated><id>https://sot.dev/building-khoomi-week-3</id><content type="html" xml:base="https://sot.dev/building-khoomi-week-3.html"><![CDATA[<p>Last week I documented <a href="/building-khoomi-week-2.html">shop architecture</a>. This week, I will be writing on  how orders work when a customer buys from multiple shops in one checkout on Khoomi.</p>

<p>On Khoomi, a single cart can contain items from different sellers. At checkout, that becomes one payment but multiple fulfillment flows. Shop A might ship tomorrow while Shop B takes a week. The order system needs to handle this gracefully.</p>

<hr />

<h2 id="the-parent-child-model">The Parent-Child Model</h2>

<p>An order has embedded shop orders. One document, multiple fulfillment units:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Order</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">ID</span>            <span class="n">primitive</span><span class="o">.</span><span class="n">ObjectID</span> <span class="s">`bson:"_id"`</span>
    <span class="n">OrderNumber</span>   <span class="kt">string</span>             <span class="s">`bson:"order_number"`</span>
    <span class="n">CustomerID</span>    <span class="n">primitive</span><span class="o">.</span><span class="n">ObjectID</span> <span class="s">`bson:"customer_id"`</span>
    <span class="n">CustomerEmail</span> <span class="kt">string</span>             <span class="s">`bson:"customer_email"`</span>

    <span class="n">ShopOrders</span> <span class="p">[]</span><span class="n">ShopOrder</span> <span class="s">`bson:"shop_orders"`</span> <span class="c">// Embedded</span>

    <span class="n">Pricing</span>         <span class="n">OrderPricing</span>       <span class="s">`bson:"pricing"`</span>
    <span class="n">ShippingAddress</span> <span class="n">UserAddressExcerpt</span> <span class="s">`bson:"shipping_address"`</span>

    <span class="n">Status</span>        <span class="n">OrderStatus</span> <span class="s">`bson:"status"`</span>        <span class="c">// Overall</span>
    <span class="n">PaymentStatus</span> <span class="kt">string</span>      <span class="s">`bson:"payment_status"`</span>

    <span class="n">CreatedAt</span>   <span class="n">time</span><span class="o">.</span><span class="n">Time</span>  <span class="s">`bson:"created_at"`</span>
    <span class="n">PaidAt</span>      <span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Time</span> <span class="s">`bson:"paid_at,omitempty"`</span>
    <span class="n">CancelledAt</span> <span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Time</span> <span class="s">`bson:"cancelled_at,omitempty"`</span>
<span class="p">}</span>

<span class="k">type</span> <span class="n">ShopOrder</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">OrderID</span>         <span class="n">primitive</span><span class="o">.</span><span class="n">ObjectID</span> <span class="s">`bson:"order_id"`</span>
    <span class="n">ShopID</span>          <span class="n">primitive</span><span class="o">.</span><span class="n">ObjectID</span> <span class="s">`bson:"shop_id"`</span>
    <span class="n">ShopName</span>        <span class="kt">string</span>             <span class="s">`bson:"shop_name"`</span>
    <span class="n">Items</span>           <span class="p">[]</span><span class="n">OrderItem</span>        <span class="s">`bson:"items"`</span>

    <span class="n">Subtotal</span>     <span class="kt">int64</span> <span class="s">`bson:"subtotal"`</span>      <span class="c">// in kobo</span>
    <span class="n">ShippingCost</span> <span class="kt">int64</span> <span class="s">`bson:"shipping_cost"`</span> <span class="c">// in kobo</span>
    <span class="n">ShopTotal</span>    <span class="kt">int64</span> <span class="s">`bson:"shop_total"`</span>    <span class="c">// in kobo</span>

    <span class="n">ShopOrderStatus</span> <span class="n">OrderStatus</span> <span class="s">`bson:"shop_order_status"`</span> <span class="c">// Independent</span>
    <span class="n">TrackingNumber</span>  <span class="kt">string</span>      <span class="s">`bson:"tracking_number,omitempty"`</span>

    <span class="n">SellerPayout</span> <span class="n">SellerPayout</span>      <span class="s">`bson:"seller_payout"`</span>
    <span class="n">Refund</span>       <span class="o">*</span><span class="n">ShopOrderRefund</span>  <span class="s">`bson:"refund,omitempty"`</span> <span class="c">// Created on cancellation</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The parent <code class="language-plaintext highlighter-rouge">Order</code> tracks payment. Each <code class="language-plaintext highlighter-rouge">ShopOrder</code> tracks its own fulfillment. For an example, Shop <code class="language-plaintext highlighter-rouge">A</code> marking their portion as shipped doesn’t affect Shop <code class="language-plaintext highlighter-rouge">B's</code> status(stays as “processing.”, “paid” or whatever status it is)</p>

<p>Why embed instead of separate collections? Atomic reads. Fetching an order for display is one query, not a join. The customer sees everything at once: their items, each shop’s status, the overall payment state.</p>

<p>The trade-off? Updating a single shop’s status requires updating the entire order document. MongoDB’s positional operator (<code class="language-plaintext highlighter-rouge">$[elem]</code>) handles this efficiently, but it’s still a larger write than updating a separate document.</p>

<hr />

<h2 id="atomic-checkout">Atomic Checkout</h2>

<p>Creating an order touches multiple collections. Inventory must be reserved. The cart must be cleared. The order must be inserted. All or nothing:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">orderService</span><span class="p">)</span> <span class="n">CreateOrderFromCart</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">userID</span> <span class="n">ObjectID</span><span class="p">,</span> <span class="n">req</span> <span class="n">CreateOrderRequest</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="n">Order</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="c">// Build order from cart items (grouping by shop)</span>
    <span class="n">order</span> <span class="o">:=</span> <span class="n">buildOrderFromCart</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>

    <span class="n">callback</span> <span class="o">:=</span> <span class="k">func</span><span class="p">(</span><span class="n">sessCtx</span> <span class="n">mongo</span><span class="o">.</span><span class="n">SessionContext</span><span class="p">)</span> <span class="p">(</span><span class="n">any</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
        <span class="c">// Reserve inventory for each item</span>
        <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">shop</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">order</span><span class="o">.</span><span class="n">ShopOrders</span> <span class="p">{</span>
            <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">item</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">shop</span><span class="o">.</span><span class="n">Items</span> <span class="p">{</span>
                <span class="n">filter</span> <span class="o">:=</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
                    <span class="s">"_id"</span><span class="o">:</span>                <span class="n">item</span><span class="o">.</span><span class="n">ListingID</span><span class="p">,</span>
                    <span class="s">"inventory.quantity"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"$gte"</span><span class="o">:</span> <span class="n">item</span><span class="o">.</span><span class="n">Quantity</span><span class="p">},</span>
                <span class="p">}</span>
                <span class="n">update</span> <span class="o">:=</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
                    <span class="s">"$inc"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"inventory.quantity"</span><span class="o">:</span> <span class="o">-</span><span class="n">item</span><span class="o">.</span><span class="n">Quantity</span><span class="p">},</span>
                <span class="p">}</span>

                <span class="n">result</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">listingColl</span><span class="o">.</span><span class="n">UpdateOne</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">filter</span><span class="p">,</span> <span class="n">update</span><span class="p">)</span>
                <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
                    <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span>
                <span class="p">}</span>
                <span class="k">if</span> <span class="n">result</span><span class="o">.</span><span class="n">ModifiedCount</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
                    <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"insufficient inventory for '%s'"</span><span class="p">,</span> <span class="n">item</span><span class="o">.</span><span class="n">Title</span><span class="p">)</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">}</span>

        <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">orderColl</span><span class="o">.</span><span class="n">InsertOne</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">order</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
            <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span>
        <span class="p">}</span>

        <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="n">cart</span><span class="o">.</span><span class="n">ClearCartItems</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">userID</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
            <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span>
        <span class="p">}</span>

        <span class="k">return</span> <span class="n">order</span><span class="p">,</span> <span class="no">nil</span>
    <span class="p">}</span>

    <span class="n">result</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">database</span><span class="o">.</span><span class="n">ExecuteTransaction</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">MongoClient</span><span class="p">,</span> <span class="n">callback</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="n">result</span><span class="o">.</span><span class="p">(</span><span class="o">*</span><span class="n">Order</span><span class="p">),</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The filter <code class="language-plaintext highlighter-rouge">"inventory.quantity": bson.M{"$gte": item.Quantity}</code> is critical. It ensures stock exists <em>before</em> decrementing. If two customers checkout simultaneously and only one item remains, exactly one succeeds. The other gets a clear error.</p>

<p>Why reserve at checkout instead of cart add? I covered this decision in <a href="/building-khoomi-week-1.html#stock-reservation-later-not-sooner">Week 1</a>. The short version: carts are abandoned constantly, and reserving at checkout avoids expiry logic and hold timers.</p>

<hr />

<h2 id="independent-fulfillment">Independent Fulfillment</h2>

<p>Each shop order has its own lifecycle:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PENDING → PAID → PROCESSING → SHIPPED → DELIVERED
                                ↓
                            CANCELLED
</code></pre></div></div>

<p>When a shop updates their status, only the order belonging to their shop gets updated:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">orderService</span><span class="p">)</span> <span class="n">UpdateShopOrderStatus</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">params</span> <span class="n">UpdateShopOrderParams</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="n">filter</span> <span class="o">:=</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
        <span class="s">"_id"</span><span class="o">:</span>                 <span class="n">params</span><span class="o">.</span><span class="n">OrderID</span><span class="p">,</span>
        <span class="s">"shop_orders.shop_id"</span><span class="o">:</span> <span class="n">params</span><span class="o">.</span><span class="n">ShopID</span><span class="p">,</span>
    <span class="p">}</span>

    <span class="n">update</span> <span class="o">:=</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
        <span class="s">"$set"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
            <span class="s">"shop_orders.$.shop_order_status"</span><span class="o">:</span> <span class="n">params</span><span class="o">.</span><span class="n">Status</span><span class="p">,</span>
            <span class="s">"updated_at"</span><span class="o">:</span>                      <span class="n">time</span><span class="o">.</span><span class="n">Now</span><span class="p">(),</span>
        <span class="p">},</span>
    <span class="p">}</span>

    <span class="k">if</span> <span class="n">params</span><span class="o">.</span><span class="n">Status</span> <span class="o">==</span> <span class="n">OrderStatusShipped</span> <span class="p">{</span>
        <span class="n">update</span><span class="p">[</span><span class="s">"$set"</span><span class="p">]</span><span class="o">.</span><span class="p">(</span><span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">)[</span><span class="s">"shop_orders.$.shipped_at"</span><span class="p">]</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">Now</span><span class="p">()</span>
    <span class="p">}</span>

    <span class="n">callback</span> <span class="o">:=</span> <span class="k">func</span><span class="p">(</span><span class="n">sessCtx</span> <span class="n">mongo</span><span class="o">.</span><span class="n">SessionContext</span><span class="p">)</span> <span class="p">(</span><span class="n">any</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Orders</span><span class="o">.</span><span class="n">FindOneAndUpdate</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">filter</span><span class="p">,</span> <span class="n">update</span><span class="p">)</span><span class="o">.</span><span class="n">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="n">order</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
            <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span>
        <span class="p">}</span>

        <span class="c">// On delivery, release earnings to seller</span>
        <span class="k">if</span> <span class="n">params</span><span class="o">.</span><span class="n">Status</span> <span class="o">==</span> <span class="n">OrderStatusDelivered</span> <span class="p">{</span>
            <span class="n">err</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">wallet</span><span class="o">.</span><span class="n">ReleaseEarnings</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">params</span><span class="o">.</span><span class="n">ShopID</span><span class="p">,</span> <span class="n">params</span><span class="o">.</span><span class="n">OrderID</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
                <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span>
            <span class="p">}</span>
        <span class="p">}</span>

        <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="no">nil</span>
    <span class="p">}</span>

    <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">database</span><span class="o">.</span><span class="n">ExecuteTransaction</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">MongoClient</span><span class="p">,</span> <span class="n">callback</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">err</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">shop_orders.$</code> positional operator updates only the matching shop order within the parent document.</p>

<p>Why not separate order documents per shop? The customer paid once. They expect one order number, one receipt, one tracking page. If I split orders into separate documents per shop, I’d need to reassemble them for every customer-facing view. Extra queries, extra complexity.</p>

<hr />

<h2 id="escrow-via-wallet">Escrow via Wallet</h2>

<p>Sellers don’t get paid immediately. The money sits in escrow until delivery:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// Package wallet manages seller earnings on Khoomi.</span>
<span class="c">//</span>
<span class="c">// The wallet has two balances:</span>
<span class="c">//   - Pending: from orders not yet delivered (cannot withdraw)</span>
<span class="c">//   - Available: from delivered orders (can withdraw)</span>
<span class="c">//</span>
<span class="c">// Money Flow:</span>
<span class="c">//  1. CreditPending: Payment received -&gt; +pending, +total_earnings</span>
<span class="c">//  2. ReleaseEarnings: Order delivered -&gt; -pending, +available</span>
<span class="c">//  3. ProcessWithdrawal: Seller withdraws -&gt; -available, +total_withdrawn</span>
</code></pre></div></div>

<p>When payment succeeds, the seller’s pending balance increases:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">walletService</span><span class="p">)</span> <span class="n">CreditPendingBalance</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">shopID</span><span class="p">,</span> <span class="n">orderID</span> <span class="n">ObjectID</span><span class="p">,</span> <span class="n">amount</span> <span class="kt">int64</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="n">callback</span> <span class="o">:=</span> <span class="k">func</span><span class="p">(</span><span class="n">sessCtx</span> <span class="n">mongo</span><span class="o">.</span><span class="n">SessionContext</span><span class="p">)</span> <span class="p">(</span><span class="n">any</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
        <span class="c">// Check idempotency - already credited for this order?</span>
        <span class="n">count</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">WalletTransactions</span><span class="o">.</span><span class="n">CountDocuments</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
            <span class="s">"shop_id"</span><span class="o">:</span>  <span class="n">shopID</span><span class="p">,</span>
            <span class="s">"order_id"</span><span class="o">:</span> <span class="n">orderID</span><span class="p">,</span>
            <span class="s">"type"</span><span class="o">:</span>     <span class="n">TxTypeOrderEarning</span><span class="p">,</span>
        <span class="p">})</span>
        <span class="k">if</span> <span class="n">count</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="p">{</span>
             <span class="c">// Already credited</span>
            <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="no">nil</span>
        <span class="p">}</span>

        <span class="n">tx</span> <span class="o">:=</span> <span class="n">WalletTransaction</span><span class="p">{</span>
            <span class="n">ShopID</span><span class="o">:</span>  <span class="n">shopID</span><span class="p">,</span>
            <span class="n">Type</span><span class="o">:</span>    <span class="n">TxTypeOrderEarning</span><span class="p">,</span>
            <span class="n">Amount</span><span class="o">:</span>  <span class="n">amount</span><span class="p">,</span>
            <span class="n">OrderID</span><span class="o">:</span> <span class="o">&amp;</span><span class="n">orderID</span><span class="p">,</span>
            <span class="c">// ...</span>
        <span class="p">}</span>
        <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">WalletTransactions</span><span class="o">.</span><span class="n">InsertOne</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">tx</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
            <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span>
        <span class="p">}</span>

        <span class="n">update</span> <span class="o">:=</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
            <span class="s">"$inc"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
                <span class="s">"pending_balance"</span><span class="o">:</span> <span class="n">amount</span><span class="p">,</span>
                <span class="s">"total_earnings"</span><span class="o">:</span>  <span class="n">amount</span><span class="p">,</span>
            <span class="p">},</span>
        <span class="p">}</span>
        <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Wallets</span><span class="o">.</span><span class="n">UpdateOne</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"shop_id"</span><span class="o">:</span> <span class="n">shopID</span><span class="p">},</span> <span class="n">update</span><span class="p">)</span>
        <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span>
    <span class="p">}</span>

    <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">database</span><span class="o">.</span><span class="n">ExecuteTransaction</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">MongoClient</span><span class="p">,</span> <span class="n">callback</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">err</span>
<span class="p">}</span>
</code></pre></div></div>

<p>When the order is delivered, pending becomes available:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">walletService</span><span class="p">)</span> <span class="n">ReleaseEarnings</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">shopID</span><span class="p">,</span> <span class="n">orderID</span> <span class="n">ObjectID</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="n">callback</span> <span class="o">:=</span> <span class="k">func</span><span class="p">(</span><span class="n">sessCtx</span> <span class="n">mongo</span><span class="o">.</span><span class="n">SessionContext</span><span class="p">)</span> <span class="p">(</span><span class="n">any</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
        <span class="c">// Find the pending transaction</span>
        <span class="k">var</span> <span class="n">pendingTx</span> <span class="n">WalletTransaction</span>
        <span class="n">err</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">WalletTransactions</span><span class="o">.</span><span class="n">FindOne</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
            <span class="s">"shop_id"</span><span class="o">:</span>  <span class="n">shopID</span><span class="p">,</span>
            <span class="s">"order_id"</span><span class="o">:</span> <span class="n">orderID</span><span class="p">,</span>
            <span class="s">"type"</span><span class="o">:</span>     <span class="n">TxTypeOrderEarning</span><span class="p">,</span>
        <span class="p">})</span><span class="o">.</span><span class="n">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="n">pendingTx</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
            <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">ErrPendingEarningsNotFound</span>
        <span class="p">}</span>

        <span class="n">amount</span> <span class="o">:=</span> <span class="n">pendingTx</span><span class="o">.</span><span class="n">Amount</span>

        <span class="n">releaseTx</span> <span class="o">:=</span> <span class="n">WalletTransaction</span><span class="p">{</span>
            <span class="n">ShopID</span><span class="o">:</span>      <span class="n">shopID</span><span class="p">,</span>
            <span class="n">Type</span><span class="o">:</span>        <span class="n">TxTypeEarningReleased</span><span class="p">,</span>
            <span class="n">Amount</span><span class="o">:</span>      <span class="n">amount</span><span class="p">,</span>
            <span class="n">OrderID</span><span class="o">:</span>     <span class="o">&amp;</span><span class="n">orderID</span><span class="p">,</span>
            <span class="n">Description</span><span class="o">:</span> <span class="s">"Earnings released - order delivered"</span><span class="p">,</span>
        <span class="p">}</span>
        <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">WalletTransactions</span><span class="o">.</span><span class="n">InsertOne</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">releaseTx</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
            <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span>
        <span class="p">}</span>

        <span class="c">// Move from pending to available</span>
        <span class="n">walletUpdate</span> <span class="o">:=</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
            <span class="s">"$inc"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
                <span class="s">"pending_balance"</span><span class="o">:</span>   <span class="o">-</span><span class="n">amount</span><span class="p">,</span>
                <span class="s">"available_balance"</span><span class="o">:</span> <span class="n">amount</span><span class="p">,</span>
            <span class="p">},</span>
        <span class="p">}</span>
        <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Wallets</span><span class="o">.</span><span class="n">UpdateOne</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"shop_id"</span><span class="o">:</span> <span class="n">shopID</span><span class="p">},</span> <span class="n">walletUpdate</span><span class="p">)</span>
        <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span>
    <span class="p">}</span>

    <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">database</span><span class="o">.</span><span class="n">ExecuteTransaction</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">MongoClient</span><span class="p">,</span> <span class="n">callback</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">err</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Why escrow? Buyer protection. If a seller never ships or order status is “paid”, the money can be refunded. If the item arrives damaged, there’s a dispute window before the seller can withdraw.</p>

<p>The trade-off? Sellers wait for their money. Cash flow suffers. But for a marketplace with unknown sellers, trust requires holding funds until delivery is confirmed.</p>

<hr />

<h2 id="partial-cancellation">Partial Cancellation</h2>

<p>One shop can cancel their order without affecting others:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">orderService</span><span class="p">)</span> <span class="n">CancelOrderByShop</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">orderID</span><span class="p">,</span> <span class="n">shopID</span> <span class="n">ObjectID</span><span class="p">,</span> <span class="n">reason</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="n">order</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">GetOrderByID</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">orderID</span><span class="p">)</span>

    <span class="c">// Find this shop's order</span>
    <span class="k">var</span> <span class="n">shopOrder</span> <span class="o">*</span><span class="n">ShopOrder</span>
    <span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">order</span><span class="o">.</span><span class="n">ShopOrders</span> <span class="p">{</span>
        <span class="k">if</span> <span class="n">order</span><span class="o">.</span><span class="n">ShopOrders</span><span class="p">[</span><span class="n">i</span><span class="p">]</span><span class="o">.</span><span class="n">ShopID</span> <span class="o">==</span> <span class="n">shopID</span> <span class="p">{</span>
            <span class="n">shopOrder</span> <span class="o">=</span> <span class="o">&amp;</span><span class="n">order</span><span class="o">.</span><span class="n">ShopOrders</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
            <span class="k">break</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="n">needsRefund</span> <span class="o">:=</span> <span class="n">order</span><span class="o">.</span><span class="n">Status</span> <span class="o">==</span> <span class="n">OrderStatusPaid</span> <span class="o">||</span> <span class="n">order</span><span class="o">.</span><span class="n">Status</span> <span class="o">==</span> <span class="n">OrderStatusProcessing</span>

    <span class="n">callback</span> <span class="o">:=</span> <span class="k">func</span><span class="p">(</span><span class="n">sessCtx</span> <span class="n">mongo</span><span class="o">.</span><span class="n">SessionContext</span><span class="p">)</span> <span class="p">(</span><span class="n">any</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">updateFields</span> <span class="o">:=</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
            <span class="s">"shop_orders.$[elem].shop_order_status"</span><span class="o">:</span> <span class="n">OrderStatusCancelled</span><span class="p">,</span>
            <span class="s">"shop_orders.$[elem].updated_at"</span><span class="o">:</span>        <span class="n">time</span><span class="o">.</span><span class="n">Now</span><span class="p">(),</span>
        <span class="p">}</span>

        <span class="c">// Create refund record if order was paid</span>
        <span class="k">if</span> <span class="n">needsRefund</span> <span class="p">{</span>
            <span class="n">refund</span> <span class="o">:=</span> <span class="n">ShopOrderRefund</span><span class="p">{</span>
                <span class="n">Status</span><span class="o">:</span>      <span class="n">RefundStatusPending</span><span class="p">,</span>
                <span class="n">Amount</span><span class="o">:</span>      <span class="n">shopOrder</span><span class="o">.</span><span class="n">ShopTotal</span><span class="p">,</span>
                <span class="n">InitiatedBy</span><span class="o">:</span> <span class="n">RefundByShop</span><span class="p">,</span>
                <span class="n">Reason</span><span class="o">:</span>      <span class="n">reason</span><span class="p">,</span>
                <span class="n">RequestedAt</span><span class="o">:</span> <span class="n">time</span><span class="o">.</span><span class="n">Now</span><span class="p">(),</span>
                <span class="n">RetryCount</span><span class="o">:</span>  <span class="m">0</span><span class="p">,</span>
            <span class="p">}</span>
            <span class="n">updateFields</span><span class="p">[</span><span class="s">"shop_orders.$[elem].refund"</span><span class="p">]</span> <span class="o">=</span> <span class="n">refund</span>
        <span class="p">}</span>

        <span class="n">update</span> <span class="o">:=</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"$set"</span><span class="o">:</span> <span class="n">updateFields</span><span class="p">}</span>
        <span class="n">arrayFilters</span> <span class="o">:=</span> <span class="n">options</span><span class="o">.</span><span class="n">Update</span><span class="p">()</span><span class="o">.</span><span class="n">SetArrayFilters</span><span class="p">(</span><span class="n">options</span><span class="o">.</span><span class="n">ArrayFilters</span><span class="p">{</span>
            <span class="n">Filters</span><span class="o">:</span> <span class="p">[]</span><span class="n">any</span><span class="p">{</span><span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"elem.shop_id"</span><span class="o">:</span> <span class="n">shopID</span><span class="p">}},</span>
        <span class="p">})</span>

        <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Orders</span><span class="o">.</span><span class="n">UpdateOne</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"_id"</span><span class="o">:</span> <span class="n">orderID</span><span class="p">},</span> <span class="n">update</span><span class="p">,</span> <span class="n">arrayFilters</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
            <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span>
        <span class="p">}</span>

        <span class="c">// Restore inventory</span>
        <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">item</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">shopOrder</span><span class="o">.</span><span class="n">Items</span> <span class="p">{</span>
            <span class="n">inventoryUpdate</span> <span class="o">:=</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
                <span class="s">"$inc"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"inventory.quantity"</span><span class="o">:</span> <span class="n">item</span><span class="o">.</span><span class="n">Quantity</span><span class="p">},</span>
            <span class="p">}</span>
            <span class="n">_</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Listings</span><span class="o">.</span><span class="n">UpdateOne</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"_id"</span><span class="o">:</span> <span class="n">item</span><span class="o">.</span><span class="n">ListingID</span><span class="p">},</span> <span class="n">inventoryUpdate</span><span class="p">)</span>
        <span class="p">}</span>

        <span class="c">// Check if ALL shops are now cancelled</span>
        <span class="n">updatedOrder</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">GetOrderByID</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">orderID</span><span class="p">)</span>
        <span class="n">allCancelled</span> <span class="o">:=</span> <span class="no">true</span>
        <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">so</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">updatedOrder</span><span class="o">.</span><span class="n">ShopOrders</span> <span class="p">{</span>
            <span class="k">if</span> <span class="n">so</span><span class="o">.</span><span class="n">ShopOrderStatus</span> <span class="o">!=</span> <span class="n">OrderStatusCancelled</span> <span class="p">{</span>
                <span class="n">allCancelled</span> <span class="o">=</span> <span class="no">false</span>
                <span class="k">break</span>
            <span class="p">}</span>
        <span class="p">}</span>

        <span class="c">// If all cancelled, cancel the whole order</span>
        <span class="k">if</span> <span class="n">allCancelled</span> <span class="p">{</span>
            <span class="n">mainUpdate</span> <span class="o">:=</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
                <span class="s">"$set"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
                    <span class="s">"status"</span><span class="o">:</span>         <span class="n">OrderStatusCancelled</span><span class="p">,</span>
                    <span class="s">"payment_status"</span><span class="o">:</span> <span class="s">"refund_pending"</span><span class="p">,</span>
                    <span class="s">"cancelled_at"</span><span class="o">:</span>   <span class="n">time</span><span class="o">.</span><span class="n">Now</span><span class="p">(),</span>
                <span class="p">},</span>
            <span class="p">}</span>
            <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Orders</span><span class="o">.</span><span class="n">UpdateOne</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"_id"</span><span class="o">:</span> <span class="n">orderID</span><span class="p">},</span> <span class="n">mainUpdate</span><span class="p">)</span>
        <span class="p">}</span>

        <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span>
    <span class="p">}</span>

    <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">database</span><span class="o">.</span><span class="n">ExecuteTransaction</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">MongoClient</span><span class="p">,</span> <span class="n">callback</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">err</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">$[elem]</code> array filter lets me target a specific shop order by its <code class="language-plaintext highlighter-rouge">shop_id</code>. If Shop A cancels but Shop B is still fulfilling, the parent order stays active. Only Shop A’s portion shows as cancelled.</p>

<p>Why allow partial cancellation? Stock issues happen. A seller might realize they can’t fulfill an item after accepting the order. Cancelling the entire order (including items from other shops that are ready to ship) would punish buyers and other sellers for one shop’s mistake.</p>

<p>When a paid order is cancelled, it also creates a refund record:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">ShopOrderRefund</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Status</span>        <span class="n">RefundStatus</span>    <span class="s">`bson:"status"`</span>         <span class="c">// pending/processing/completed/failed</span>
    <span class="n">Amount</span>        <span class="kt">int64</span>           <span class="s">`bson:"amount"`</span>         <span class="c">// in kobo</span>
    <span class="n">InitiatedBy</span>   <span class="n">RefundInitiator</span> <span class="s">`bson:"initiated_by"`</span>   <span class="c">// customer/shop/system</span>
    <span class="n">Reason</span>        <span class="kt">string</span>          <span class="s">`bson:"reason,omitempty"`</span>
    <span class="n">RequestedAt</span>   <span class="n">time</span><span class="o">.</span><span class="n">Time</span>       <span class="s">`bson:"requested_at"`</span>
    <span class="n">ProcessedAt</span>   <span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Time</span>      <span class="s">`bson:"processed_at,omitempty"`</span>
    <span class="n">CompletedAt</span>   <span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Time</span>      <span class="s">`bson:"completed_at,omitempty"`</span>
    <span class="n">Reference</span>     <span class="kt">string</span>          <span class="s">`bson:"reference,omitempty"`</span>
    <span class="n">FailureReason</span> <span class="kt">string</span>          <span class="s">`bson:"failure_reason,omitempty"`</span>
    <span class="n">RetryCount</span>    <span class="kt">int</span>             <span class="s">`bson:"retry_count"`</span>
    <span class="n">LastRetryAt</span>   <span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Time</span>      <span class="s">`bson:"last_retry_at,omitempty"`</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The refund starts as <code class="language-plaintext highlighter-rouge">pending</code>. A background job picks it up.</p>

<hr />

<h2 id="automated-refund-processing">Automated Refund Processing</h2>

<p>Refunds run on a scheduler, not inline with cancellation:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">j</span> <span class="o">*</span><span class="n">RefundsJob</span><span class="p">)</span> <span class="n">Run</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">orders</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">j</span><span class="o">.</span><span class="n">orderService</span><span class="o">.</span><span class="n">GetPendingRefunds</span><span class="p">(</span><span class="n">ctx</span><span class="p">)</span>

    <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">order</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">orders</span> <span class="p">{</span>
        <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">shopOrder</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">order</span><span class="o">.</span><span class="n">ShopOrders</span> <span class="p">{</span>
            <span class="k">if</span> <span class="n">shopOrder</span><span class="o">.</span><span class="n">Refund</span> <span class="o">==</span> <span class="no">nil</span> <span class="o">||</span> <span class="n">shopOrder</span><span class="o">.</span><span class="n">Refund</span><span class="o">.</span><span class="n">Status</span> <span class="o">!=</span> <span class="n">RefundStatusPending</span> <span class="p">{</span>
                <span class="k">continue</span>
            <span class="p">}</span>

            <span class="c">// Max 5 retries before marking as failed</span>
            <span class="k">if</span> <span class="n">shopOrder</span><span class="o">.</span><span class="n">Refund</span><span class="o">.</span><span class="n">RetryCount</span> <span class="o">&gt;=</span> <span class="n">MaxRefundRetries</span> <span class="p">{</span>
                <span class="n">j</span><span class="o">.</span><span class="n">orderService</span><span class="o">.</span><span class="n">MarkRefundAsFailed</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">order</span><span class="o">.</span><span class="n">ID</span><span class="p">,</span> <span class="n">shopOrder</span><span class="o">.</span><span class="n">ShopID</span><span class="p">,</span>
                    <span class="s">"Maximum retry attempts exceeded"</span><span class="p">)</span>
                <span class="k">continue</span>
            <span class="p">}</span>

            <span class="n">j</span><span class="o">.</span><span class="n">orderService</span><span class="o">.</span><span class="n">ProcessShopOrderRefund</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">order</span><span class="o">.</span><span class="n">ID</span><span class="p">,</span> <span class="n">shopOrder</span><span class="o">.</span><span class="n">ShopID</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Why async? Payment provider APIs fail. Network timeouts happen. If I processed refunds inline with cancellation, a Paystack outage would block the entire cancellation flow. With a scheduler, the cancellation succeeds immediately. The customer sees “cancelled” right away, and the actual money movement retries in the background until it works.</p>

<p>Processing a refund deducts from the seller’s pending balance:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">orderService</span><span class="p">)</span> <span class="n">ProcessShopOrderRefund</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">orderID</span><span class="p">,</span> <span class="n">shopID</span> <span class="n">ObjectID</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="c">// Mark as processing (with retry count increment)</span>
    <span class="n">updateProcessing</span> <span class="o">:=</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
        <span class="s">"$set"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
            <span class="s">"shop_orders.$.refund.status"</span><span class="o">:</span>       <span class="n">RefundStatusProcessing</span><span class="p">,</span>
            <span class="s">"shop_orders.$.refund.processed_at"</span><span class="o">:</span> <span class="n">time</span><span class="o">.</span><span class="n">Now</span><span class="p">(),</span>
        <span class="p">},</span>
        <span class="s">"$inc"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
            <span class="s">"shop_orders.$.refund.retry_count"</span><span class="o">:</span> <span class="m">1</span><span class="p">,</span>
        <span class="p">},</span>
    <span class="p">}</span>
    <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Orders</span><span class="o">.</span><span class="n">UpdateOne</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">filter</span><span class="p">,</span> <span class="n">updateProcessing</span><span class="p">)</span>

    <span class="c">// Deduct from seller wallet</span>
    <span class="n">err</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">wallet</span><span class="o">.</span><span class="n">DeductForRefund</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">shopID</span><span class="p">,</span> <span class="n">orderID</span><span class="p">,</span> <span class="n">shopOrder</span><span class="o">.</span><span class="n">Refund</span><span class="o">.</span><span class="n">Amount</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="c">// Revert to pending for retry</span>
        <span class="n">revertUpdate</span> <span class="o">:=</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
            <span class="s">"$set"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
                <span class="s">"shop_orders.$.refund.status"</span><span class="o">:</span>         <span class="n">RefundStatusPending</span><span class="p">,</span>
                <span class="s">"shop_orders.$.refund.failure_reason"</span><span class="o">:</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"Wallet deduction failed: %v"</span><span class="p">,</span> <span class="n">err</span><span class="p">),</span>
            <span class="p">},</span>
        <span class="p">}</span>
        <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Orders</span><span class="o">.</span><span class="n">UpdateOne</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="o">...</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">err</span>
    <span class="p">}</span>

    <span class="n">completeUpdate</span> <span class="o">:=</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
        <span class="s">"$set"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
            <span class="s">"shop_orders.$.refund.status"</span><span class="o">:</span>       <span class="n">RefundStatusCompleted</span><span class="p">,</span>
            <span class="s">"shop_orders.$.refund.completed_at"</span><span class="o">:</span> <span class="n">time</span><span class="o">.</span><span class="n">Now</span><span class="p">(),</span>
        <span class="p">},</span>
    <span class="p">}</span>
    <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Orders</span><span class="o">.</span><span class="n">UpdateOne</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="o">...</span><span class="p">)</span>
    <span class="k">return</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The wallet deduction reverses the original credit:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">walletService</span><span class="p">)</span> <span class="n">DeductForRefund</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">shopID</span><span class="p">,</span> <span class="n">orderID</span> <span class="n">ObjectID</span><span class="p">,</span> <span class="n">amount</span> <span class="kt">int64</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="n">callback</span> <span class="o">:=</span> <span class="k">func</span><span class="p">(</span><span class="n">sessCtx</span> <span class="n">mongo</span><span class="o">.</span><span class="n">SessionContext</span><span class="p">)</span> <span class="p">(</span><span class="n">any</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
        <span class="c">// Idempotency check</span>
        <span class="n">count</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">WalletTransactions</span><span class="o">.</span><span class="n">CountDocuments</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
            <span class="s">"shop_id"</span><span class="o">:</span>  <span class="n">shopID</span><span class="p">,</span>
            <span class="s">"order_id"</span><span class="o">:</span> <span class="n">orderID</span><span class="p">,</span>
            <span class="s">"type"</span><span class="o">:</span>     <span class="n">TxTypeRefundDeduction</span><span class="p">,</span>
        <span class="p">})</span>
        <span class="k">if</span> <span class="n">count</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="p">{</span>
            <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">ErrRefundAlreadyProcessed</span>
        <span class="p">}</span>

        <span class="c">// Verify sufficient pending balance</span>
        <span class="k">var</span> <span class="n">w</span> <span class="n">SellerWallet</span>
        <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Wallets</span><span class="o">.</span><span class="n">FindOne</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"shop_id"</span><span class="o">:</span> <span class="n">shopID</span><span class="p">})</span><span class="o">.</span><span class="n">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="n">w</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">w</span><span class="o">.</span><span class="n">PendingBalance</span> <span class="o">&lt;</span> <span class="n">amount</span> <span class="p">{</span>
            <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">ErrInsufficientPendingBalance</span>
        <span class="p">}</span>

        <span class="n">tx</span> <span class="o">:=</span> <span class="n">WalletTransaction</span><span class="p">{</span>
            <span class="n">ShopID</span><span class="o">:</span>      <span class="n">shopID</span><span class="p">,</span>
            <span class="n">Type</span><span class="o">:</span>        <span class="n">TxTypeRefundDeduction</span><span class="p">,</span>
            <span class="n">Amount</span><span class="o">:</span>      <span class="n">amount</span><span class="p">,</span>
            <span class="n">OrderID</span><span class="o">:</span>     <span class="o">&amp;</span><span class="n">orderID</span><span class="p">,</span>
            <span class="n">Description</span><span class="o">:</span> <span class="s">"Refund deduction for cancelled order"</span><span class="p">,</span>
        <span class="p">}</span>
        <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">WalletTransactions</span><span class="o">.</span><span class="n">InsertOne</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">tx</span><span class="p">)</span>

        <span class="c">// Deduct from pending and total earnings</span>
        <span class="n">walletUpdate</span> <span class="o">:=</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
            <span class="s">"$inc"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
                <span class="s">"pending_balance"</span><span class="o">:</span> <span class="o">-</span><span class="n">amount</span><span class="p">,</span>
                <span class="s">"total_earnings"</span><span class="o">:</span>  <span class="o">-</span><span class="n">amount</span><span class="p">,</span>
            <span class="p">},</span>
        <span class="p">}</span>
        <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Wallets</span><span class="o">.</span><span class="n">UpdateOne</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"shop_id"</span><span class="o">:</span> <span class="n">shopID</span><span class="p">},</span> <span class="n">walletUpdate</span><span class="p">)</span>
        <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="no">nil</span>
    <span class="p">}</span>

    <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">database</span><span class="o">.</span><span class="n">ExecuteTransaction</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">MongoClient</span><span class="p">,</span> <span class="n">callback</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">err</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The refund deducts from both <code class="language-plaintext highlighter-rouge">pending_balance</code> and <code class="language-plaintext highlighter-rouge">total_earnings</code>. The seller never actually earned this money. It’s going back to the customer.</p>

<p><img src="/assets/images/order-refund-complete.png" alt="Order refund completed" /></p>

<p>The trade-off? If the seller somehow withdrew before delivery (which shouldn’t happen, since pending balance can’t be withdrawn), the deduction fails. After 5 retries, the refund is marked as failed, and I get an alert to handle it manually.</p>

<hr />

<h2 id="cleanup-expired-orders">Cleanup Expired Orders</h2>

<p>Pending orders that never get paid should release their inventory:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">orderService</span><span class="p">)</span> <span class="n">CleanupExpiredPendingOrders</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">expirationHours</span> <span class="kt">int</span><span class="p">)</span> <span class="p">(</span><span class="kt">int64</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">expiredTime</span> <span class="o">:=</span> <span class="n">time</span><span class="o">.</span><span class="n">Now</span><span class="p">()</span><span class="o">.</span><span class="n">Add</span><span class="p">(</span><span class="o">-</span><span class="n">time</span><span class="o">.</span><span class="n">Duration</span><span class="p">(</span><span class="n">expirationHours</span><span class="p">)</span> <span class="o">*</span> <span class="n">time</span><span class="o">.</span><span class="n">Hour</span><span class="p">)</span>

    <span class="n">filter</span> <span class="o">:=</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
        <span class="s">"status"</span><span class="o">:</span>     <span class="n">OrderStatusPending</span><span class="p">,</span>
        <span class="s">"created_at"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"$lt"</span><span class="o">:</span> <span class="n">expiredTime</span><span class="p">},</span>
    <span class="p">}</span>

    <span class="n">cursor</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Orders</span><span class="o">.</span><span class="n">Find</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">filter</span><span class="p">)</span>
    <span class="k">var</span> <span class="n">expiredOrders</span> <span class="p">[]</span><span class="n">Order</span>
    <span class="n">cursor</span><span class="o">.</span><span class="n">All</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">expiredOrders</span><span class="p">)</span>

    <span class="k">var</span> <span class="n">cleanedCount</span> <span class="kt">int64</span>
    <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">order</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">expiredOrders</span> <span class="p">{</span>
        <span class="n">callback</span> <span class="o">:=</span> <span class="k">func</span><span class="p">(</span><span class="n">sessCtx</span> <span class="n">mongo</span><span class="o">.</span><span class="n">SessionContext</span><span class="p">)</span> <span class="p">(</span><span class="n">any</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
            <span class="c">// Cancel the order</span>
            <span class="n">update</span> <span class="o">:=</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
                <span class="s">"$set"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
                    <span class="s">"status"</span><span class="o">:</span>                            <span class="n">OrderStatusCancelled</span><span class="p">,</span>
                    <span class="s">"payment_status"</span><span class="o">:</span>                    <span class="s">"expired"</span><span class="p">,</span>
                    <span class="s">"internal_note"</span><span class="o">:</span>                     <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"Auto-cancelled: Payment not received within %d hours"</span><span class="p">,</span> <span class="n">expirationHours</span><span class="p">),</span>
                    <span class="s">"shop_orders.$[].shop_order_status"</span><span class="o">:</span> <span class="n">OrderStatusCancelled</span><span class="p">,</span>
                <span class="p">},</span>
            <span class="p">}</span>
            <span class="n">result</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Orders</span><span class="o">.</span><span class="n">UpdateOne</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span>
                <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"_id"</span><span class="o">:</span> <span class="n">order</span><span class="o">.</span><span class="n">ID</span><span class="p">,</span> <span class="s">"status"</span><span class="o">:</span> <span class="n">OrderStatusPending</span><span class="p">},</span> <span class="n">update</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">result</span><span class="o">.</span><span class="n">ModifiedCount</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
                <span class="c">// Already processed</span>
                <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="no">nil</span> 
            <span class="p">}</span>

            <span class="c">// Restore all inventory</span>
            <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">shop</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">order</span><span class="o">.</span><span class="n">ShopOrders</span> <span class="p">{</span>
                <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">item</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">shop</span><span class="o">.</span><span class="n">Items</span> <span class="p">{</span>
                    <span class="n">inventoryUpdate</span> <span class="o">:=</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
                        <span class="s">"$inc"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"inventory.quantity"</span><span class="o">:</span> <span class="n">item</span><span class="o">.</span><span class="n">Quantity</span><span class="p">},</span>
                    <span class="p">}</span>
                    <span class="n">_</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Listings</span><span class="o">.</span><span class="n">UpdateOne</span><span class="p">(</span><span class="n">sessCtx</span><span class="p">,</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"_id"</span><span class="o">:</span> <span class="n">item</span><span class="o">.</span><span class="n">ListingID</span><span class="p">},</span> <span class="n">inventoryUpdate</span><span class="p">)</span>
                <span class="p">}</span>
            <span class="p">}</span>

            <span class="k">return</span> <span class="n">result</span><span class="o">.</span><span class="n">ModifiedCount</span><span class="p">,</span> <span class="no">nil</span>
        <span class="p">}</span>

        <span class="n">result</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">database</span><span class="o">.</span><span class="n">ExecuteTransaction</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">MongoClient</span><span class="p">,</span> <span class="n">callback</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">count</span><span class="p">,</span> <span class="n">ok</span> <span class="o">:=</span> <span class="n">result</span><span class="o">.</span><span class="p">(</span><span class="kt">int64</span><span class="p">);</span> <span class="n">ok</span> <span class="p">{</span>
            <span class="n">cleanedCount</span> <span class="o">+=</span> <span class="n">count</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="n">cleanedCount</span><span class="p">,</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This runs as a background job every 24 hours. That window gives customers enough time to complete bank transfers (which can take hours in Nigeria), but not long enough to hold inventory hostage indefinitely.</p>

<p>The double-check filter <code class="language-plaintext highlighter-rouge">"status": OrderStatusPending</code> in the update ensures idempotency. If payment came through between the find and update, the order won’t be cancelled. The filter simply won’t match.</p>

<hr />

<h2 id="seller-payout-calculation">Seller Payout Calculation</h2>

<p>Each shop order tracks exactly what the seller receives:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">SellerPayout</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Amount</span>         <span class="kt">int64</span> <span class="s">`bson:"amount"`</span>          <span class="c">// Customer paid (kobo)</span>
    <span class="n">PlatformFee</span>    <span class="kt">int64</span> <span class="s">`bson:"platform_fee"`</span>    <span class="c">// Khoomi's cut (kobo)</span>
    <span class="n">TransactionFee</span> <span class="kt">int64</span> <span class="s">`bson:"transaction_fee"`</span> <span class="c">// Payment processor (kobo)</span>
    <span class="n">NetAmount</span>      <span class="kt">int64</span> <span class="s">`bson:"net_amount"`</span>      <span class="c">// Seller receives (kobo)</span>
    <span class="n">PayoutStatus</span>   <span class="kt">string</span> <span class="s">`bson:"payout_status"`</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Calculated at checkout:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">shopTotal</span> <span class="o">:=</span> <span class="n">shopSubtotal</span> <span class="o">+</span> <span class="n">shippingCost</span> <span class="o">+</span> <span class="n">handlingFee</span> <span class="o">-</span> <span class="n">shopDiscount</span>

<span class="n">platformFee</span> <span class="o">:=</span> <span class="n">shopTotal</span> <span class="o">*</span> <span class="n">PLATFORM_FEE_RATE</span> <span class="o">/</span> <span class="n">PERCENT_DIVISOR</span> <span class="o">/</span> <span class="m">100</span>
<span class="n">transactionFee</span> <span class="o">:=</span> <span class="n">shopTotal</span> <span class="o">*</span> <span class="n">TRANSACTION_FEE_RATE</span> <span class="o">/</span> <span class="n">PERCENT_DIVISOR</span> <span class="o">/</span> <span class="m">100</span>
<span class="n">netAmount</span> <span class="o">:=</span> <span class="n">shopTotal</span> <span class="o">-</span> <span class="n">platformFee</span> <span class="o">-</span> <span class="n">transactionFee</span>

<span class="n">shopOrder</span><span class="o">.</span><span class="n">SellerPayout</span> <span class="o">=</span> <span class="n">SellerPayout</span><span class="p">{</span>
    <span class="n">Amount</span><span class="o">:</span>         <span class="n">shopTotal</span><span class="p">,</span>
    <span class="n">PlatformFee</span><span class="o">:</span>    <span class="n">platformFee</span><span class="p">,</span>
    <span class="n">TransactionFee</span><span class="o">:</span> <span class="n">transactionFee</span><span class="p">,</span>
    <span class="n">NetAmount</span><span class="o">:</span>      <span class="n">netAmount</span><span class="p">,</span>
    <span class="n">PayoutStatus</span><span class="o">:</span>   <span class="s">"pending"</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Why calculate at checkout instead of delivery? Transparency. When a seller views an incoming order, they see exactly what they’ll receive. No surprises when withdrawal time comes. “You’ll get ₦54,000 from this ₦60,000 order” is clear.</p>

<p>The trade-off? Fee changes don’t apply retroactively. If I lower the platform fee tomorrow, orders placed today still use today’s rate. For accounting consistency, that’s actually what I want.</p>

<hr />

<h2 id="what-id-do-differently">What I’d Do Differently</h2>

<p><strong>The parent order status.</strong> Currently <code class="language-plaintext highlighter-rouge">Order.Status</code> is supposed to be the “overall” status, but what does “processing” mean when Shop A is shipped and Shop B is still packing? I should probably derive it from shop order statuses instead of storing it separately. Or at least define clearer rules for when the parent status changes.</p>

<p><strong>Refund status visibility.</strong> Refund status lives inside <code class="language-plaintext highlighter-rouge">ShopOrder.Refund</code>. That’s fine for displaying a single order, but when customer support asks “show me all failed refunds this week,” I have to scan every order document. A separate refunds collection with proper indexing would make those queries instant.</p>

<hr />

<h2 id="the-pattern">The Pattern</h2>

<p>Same as previous weeks: optimize for the common case.</p>

<ul>
  <li>Most orders have 1-2 shops → embed, don’t normalize</li>
  <li>Most checkouts succeed → reserve at checkout, not cart add</li>
  <li>Most shops fulfill successfully → escrow by default, release on delivery</li>
  <li>Most cancellations are partial → per-shop status, not all-or-nothing</li>
  <li>Most refunds succeed on first try → async processing with retry, not inline</li>
</ul>

<p>The multi-vendor order system handles the 90% case elegantly. The 10% (complex disputes, multi-shop returns, refunds that fail after 5 retries) requires me to step in manually. At Khoomi’s current scale, that’s maybe one or two cases a week. Acceptable.</p>

<hr />

<p><em>Next week: Wallets and seller withdrawals.</em></p>

<p>—Samuel</p>]]></content><author><name>Samuel Onoja</name></author><category term="khoomi" /><summary type="html"><![CDATA[How orders work when a single checkout spans multiple sellers: parent-child structure, atomic inventory reservation, independent fulfillment, and escrow via wallet.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sot.dev/assets/images/order-refund-complete.png" /><media:content medium="image" url="https://sot.dev/assets/images/order-refund-complete.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Building Khoomi - Week 2: Shop Architecture</title><link href="https://sot.dev/building-khoomi-week-2.html" rel="alternate" type="text/html" title="Building Khoomi - Week 2: Shop Architecture" /><published>2026-01-23T00:00:00+00:00</published><updated>2026-01-23T00:00:00+00:00</updated><id>https://sot.dev/building-khoomi-week-2</id><content type="html" xml:base="https://sot.dev/building-khoomi-week-2.html"><![CDATA[<p>Last week I documented <a href="/building-khoomi-week-1.html">listing architecture</a>. This week: the shops that own those listings.</p>

<p>On Khoomi, a shop is a seller’s storefront—distinct from the user account that handles authentication. This separation lets the same person buy (via their user account) and sell (via their shop) while keeping concerns cleanly separated.</p>

<hr />

<h2 id="one-user-one-shop">One User, One Shop</h2>

<p>Every user can own exactly one shop. This constraint is intentional:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Shop</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">ID</span>                 <span class="n">primitive</span><span class="o">.</span><span class="n">ObjectID</span> <span class="s">`bson:"_id"`</span>
    <span class="n">UserID</span>             <span class="n">primitive</span><span class="o">.</span><span class="n">ObjectID</span> <span class="s">`bson:"user_id"`</span>  <span class="c">// Owner</span>
    <span class="n">Name</span>               <span class="kt">string</span>             <span class="s">`bson:"name"`</span>
    <span class="n">Username</span>           <span class="kt">string</span>             <span class="s">`bson:"username"`</span> <span class="c">// Unique handle</span>
    <span class="n">Status</span>             <span class="n">ShopStatus</span>         <span class="s">`bson:"status"`</span>
    <span class="n">ListingActiveCount</span> <span class="kt">int64</span>              <span class="s">`bson:"listing_active_count"`</span>
    <span class="n">FollowerCount</span>      <span class="kt">int</span>                <span class="s">`bson:"follower_count"`</span>
    <span class="n">Rating</span>             <span class="n">Rating</span>             <span class="s">`bson:"rating"`</span>
    <span class="c">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Why one-to-one? Earnings are unambiguously credited. Tax reporting is straightforward. Sellers focus on building a single strong brand rather than fragmenting their efforts across multiple storefronts.</p>

<p>The trade-off? Users who genuinely need multiple shops (say, one for jewelry and one for clothing) must create separate accounts. For Khoomi’s target market—individual artisans—this rarely comes up. Large multi-brand sellers would need a different architecture.</p>

<hr />

<h2 id="atomic-shop-creation">Atomic Shop Creation</h2>

<p>Creating a shop touches four collections in one transaction:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">ShopServiceImpl</span><span class="p">)</span> <span class="n">CreateShop</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">req</span> <span class="n">CreateShopRequest</span><span class="p">,</span> <span class="n">ownerID</span> <span class="n">ObjectID</span><span class="p">)</span> <span class="p">(</span><span class="n">ObjectID</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">shopID</span> <span class="o">:=</span> <span class="n">primitive</span><span class="o">.</span><span class="n">NewObjectID</span><span class="p">()</span>

    <span class="n">callback</span> <span class="o">:=</span> <span class="k">func</span><span class="p">(</span><span class="n">ctx</span> <span class="n">mongo</span><span class="o">.</span><span class="n">SessionContext</span><span class="p">)</span> <span class="p">(</span><span class="n">any</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
        <span class="c">// 1. Insert the shop</span>
        <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Shops</span><span class="o">.</span><span class="n">InsertOne</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">shop</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
            <span class="k">return</span> <span class="n">primitive</span><span class="o">.</span><span class="n">NilObjectID</span><span class="p">,</span> <span class="n">err</span>
        <span class="p">}</span>

        <span class="c">// 2. Update user to seller status</span>
        <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Users</span><span class="o">.</span><span class="n">UpdateOne</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span>
            <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"_id"</span><span class="o">:</span> <span class="n">ownerID</span><span class="p">},</span>
            <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"$set"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
                <span class="s">"is_seller"</span><span class="o">:</span>              <span class="no">true</span><span class="p">,</span>
                <span class="s">"seller_onboarding_level"</span><span class="o">:</span> <span class="n">OnboardingLevelCreatedShop</span><span class="p">,</span>
                <span class="s">"shop_id"</span><span class="o">:</span>                <span class="n">shopID</span><span class="p">,</span>
            <span class="p">}})</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
            <span class="k">return</span> <span class="n">primitive</span><span class="o">.</span><span class="n">NilObjectID</span><span class="p">,</span> <span class="n">err</span>
        <span class="p">}</span>

        <span class="c">// 3. Create notification settings</span>
        <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="n">CreateShopNotificationSettings</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">shopID</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
            <span class="k">return</span> <span class="n">primitive</span><span class="o">.</span><span class="n">NilObjectID</span><span class="p">,</span> <span class="n">err</span>
        <span class="p">}</span>

        <span class="c">// 4. Create wallet for earnings</span>
        <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="n">wallet</span><span class="o">.</span><span class="n">CreateWallet</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">shopID</span><span class="p">,</span> <span class="n">ownerID</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
            <span class="k">return</span> <span class="n">primitive</span><span class="o">.</span><span class="n">NilObjectID</span><span class="p">,</span> <span class="n">err</span>
        <span class="p">}</span>

        <span class="k">return</span> <span class="n">shopID</span><span class="p">,</span> <span class="no">nil</span>
    <span class="p">}</span>

    <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">database</span><span class="o">.</span><span class="n">ExecuteTransaction</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">MongoClient</span><span class="p">,</span> <span class="n">callback</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">shopID</span><span class="p">,</span> <span class="n">err</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Four operations, one transaction. If the wallet creation fails, the user update rolls back. If the notification settings fail, the shop insert rolls back. No half-created sellers.</p>

<p>Why a transaction instead of eventual consistency? A user with <code class="language-plaintext highlighter-rouge">is_seller: true</code> but no shop would break the dashboard. A shop without a wallet can’t receive payments. These invariants must hold at all times.</p>

<p>The trade-off? MongoDB transactions require replica sets. Single-node development setups need extra configuration. And transactions are slower than individual writes. For shop creation (once per seller, ever), the latency is acceptable.</p>

<p><img src="/assets/images/boromir.webp" alt="One does not simply create a shop without a transaction" /></p>

<hr />

<h2 id="shop-status-lifecycle">Shop Status Lifecycle</h2>

<p>Shops progress through statuses that control visibility and capabilities:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">const</span> <span class="p">(</span>
    <span class="n">ShopStatusInactive</span>      <span class="o">=</span> <span class="s">"inactive"</span>      <span class="c">// Just created, not visible</span>
    <span class="n">ShopStatusActive</span>        <span class="o">=</span> <span class="s">"active"</span>        <span class="c">// Fully operational</span>
    <span class="n">ShopStatusPendingReview</span> <span class="o">=</span> <span class="s">"pendingreview"</span> <span class="c">// Flagged for moderation</span>
    <span class="n">ShopStatusWarning</span>       <span class="o">=</span> <span class="s">"warning"</span>       <span class="c">// Minor violation confirmed</span>
    <span class="n">ShopStatusSuspended</span>     <span class="o">=</span> <span class="s">"suspended"</span>     <span class="c">// Temporarily disabled</span>
    <span class="n">ShopStatusBanned</span>        <span class="o">=</span> <span class="s">"banned"</span>        <span class="c">// Permanently removed</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The state machine:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INACTIVE → ACTIVE → PENDING_REVIEW → WARNING → BANNED
                 \→ SUSPENDED
</code></pre></div></div>

<p>Most shops stay <code class="language-plaintext highlighter-rouge">active</code> forever. <code class="language-plaintext highlighter-rouge">inactive</code> gives sellers time to configure branding before going live. <code class="language-plaintext highlighter-rouge">pendingreview</code> lets moderation investigate without immediately punishing. <code class="language-plaintext highlighter-rouge">suspended</code> is reversible; <code class="language-plaintext highlighter-rouge">banned</code> is terminal.</p>

<p>Why not just <code class="language-plaintext highlighter-rouge">active</code> and <code class="language-plaintext highlighter-rouge">banned</code>? Nuance. A shop selling knockoffs needs immediate suspension. A shop with unclear product descriptions needs a warning, not a ban. The intermediate states let us match response to severity.</p>

<hr />

<h2 id="embedded-followers-bounded">Embedded Followers (Bounded)</h2>

<p>The shop document embeds the 5 most recent followers:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Shop</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="c">// ...</span>
    <span class="n">Followers</span>     <span class="p">[]</span><span class="n">ShopFollower</span> <span class="s">`bson:"followers"`</span>     <span class="c">// Embedded (max 5)</span>
    <span class="n">FollowerCount</span> <span class="kt">int</span>            <span class="s">`bson:"follower_count"`</span>
<span class="p">}</span>
</code></pre></div></div>

<p>When someone follows a shop:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">ShopServiceImpl</span><span class="p">)</span> <span class="n">FollowShop</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">userID</span><span class="p">,</span> <span class="n">shopID</span> <span class="n">ObjectID</span><span class="p">)</span> <span class="p">(</span><span class="n">ObjectID</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">callback</span> <span class="o">:=</span> <span class="k">func</span><span class="p">(</span><span class="n">ctx</span> <span class="n">mongo</span><span class="o">.</span><span class="n">SessionContext</span><span class="p">)</span> <span class="p">(</span><span class="n">any</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
        <span class="c">// Insert into followers collection (full list)</span>
        <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">ShopFollowers</span><span class="o">.</span><span class="n">InsertOne</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">followerData</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
            <span class="k">if</span> <span class="n">mongo</span><span class="o">.</span><span class="n">IsDuplicateKeyError</span><span class="p">(</span><span class="n">err</span><span class="p">)</span> <span class="p">{</span>
                <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">errors</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"already following this shop"</span><span class="p">)</span>
            <span class="p">}</span>
            <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span>
        <span class="p">}</span>

        <span class="c">// Update shop with bounded embedded array</span>
        <span class="n">update</span> <span class="o">:=</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
            <span class="s">"$push"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
                <span class="s">"followers"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
                    <span class="s">"$each"</span><span class="o">:</span>     <span class="n">bson</span><span class="o">.</span><span class="n">A</span><span class="p">{</span><span class="n">followerExcerpt</span><span class="p">},</span>
                    <span class="s">"$sort"</span><span class="o">:</span>     <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"joined_at"</span><span class="o">:</span> <span class="o">-</span><span class="m">1</span><span class="p">},</span>
                    <span class="s">"$slice"</span><span class="o">:</span>    <span class="m">5</span><span class="p">,</span>        <span class="c">// Keep only 5</span>
                    <span class="s">"$position"</span><span class="o">:</span> <span class="m">0</span><span class="p">,</span>        <span class="c">// Prepend (newest first)</span>
                <span class="p">},</span>
            <span class="p">},</span>
            <span class="s">"$inc"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"follower_count"</span><span class="o">:</span> <span class="m">1</span><span class="p">},</span>
        <span class="p">}</span>
        <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Shops</span><span class="o">.</span><span class="n">UpdateOne</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"_id"</span><span class="o">:</span> <span class="n">shopID</span><span class="p">},</span> <span class="n">update</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">followerID</span><span class="p">,</span> <span class="n">err</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="n">database</span><span class="o">.</span><span class="n">ExecuteTransaction</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">MongoClient</span><span class="p">,</span> <span class="n">callback</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">$slice: 5</code> operator caps the embedded array. The full follower list lives in <code class="language-plaintext highlighter-rouge">shop_followers</code> collection for pagination.</p>

<p>Why embed at all? The shop profile page shows “recent followers” without a join. One document fetch, one render. For a shop with 10,000 followers, loading the full list would be wasteful when we only display 5.</p>

<p>The trade-off? Two writes per follow (collection + embedded). The embedded data can drift if updates fail partially—though the transaction prevents this. And we’re duplicating data. For 5 followers × ~100 bytes each, the duplication is negligible.</p>

<hr />

<h2 id="shipping-profiles">Shipping Profiles</h2>

<p>Each shop can have multiple shipping profiles:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">ShopShippingProfile</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">ID</span>              <span class="n">ObjectID</span> <span class="s">`bson:"_id"`</span>
    <span class="n">ShopID</span>          <span class="n">ObjectID</span> <span class="s">`bson:"shop_id"`</span>
    <span class="n">Title</span>           <span class="kt">string</span>   <span class="s">`bson:"title"`</span>           <span class="c">// "Standard Shipping"</span>
    <span class="n">OriginState</span>     <span class="kt">string</span>   <span class="s">`bson:"origin_state"`</span>    <span class="c">// "Lagos"</span>
    <span class="n">PrimaryPrice</span>    <span class="kt">int64</span>    <span class="s">`bson:"primary_price"`</span>   <span class="c">// Same zone (kobo)</span>
    <span class="n">SecondaryPrice</span>  <span class="kt">int64</span>    <span class="s">`bson:"secondary_price"`</span> <span class="c">// Different zone (kobo)</span>
    <span class="n">MinDeliveryDays</span> <span class="kt">int</span>      <span class="s">`bson:"min_delivery_days"`</span>
    <span class="n">MaxDeliveryDays</span> <span class="kt">int</span>      <span class="s">`bson:"max_delivery_days"`</span>
    <span class="n">IsDefault</span>       <span class="kt">bool</span>     <span class="s">`bson:"is_default"`</span>
    <span class="n">AcceptReturns</span>   <span class="kt">bool</span>     <span class="s">`bson:"accept_returns"`</span>
    <span class="n">ReturnPeriod</span>    <span class="kt">int</span>      <span class="s">`bson:"return_period"`</span>   <span class="c">// Days</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The dual pricing (<code class="language-plaintext highlighter-rouge">PrimaryPrice</code> vs <code class="language-plaintext highlighter-rouge">SecondaryPrice</code>) reflects Nigerian geography. Shipping within Lagos is cheaper than shipping from Lagos to Benue. Rather than model all 36 states individually, I use two tiers: same zone vs different zone.</p>

<p>Why not per-state pricing? Complexity. Most sellers ship from one location. Two tiers cover 90% of cases. A jewelry maker in Lagos charges ₦1,500 for Lagos delivery, ₦3,000 everywhere else. Good enough.</p>

<p>The trade-off? Sellers shipping to specific states (say, only Southwest Nigeria) need workarounds. The <code class="language-plaintext highlighter-rouge">Destinations</code> field exists for this, but most sellers just use “everywhere.”</p>

<hr />

<h2 id="dashboard-notification-counts">Dashboard Notification Counts</h2>

<p>The seller dashboard needs multiple counts: unread messages, new orders, pending refunds, low stock items. Six queries total. Running them sequentially would be slow.</p>

<p>Instead, parallel goroutines with channels:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">ShopServiceImpl</span><span class="p">)</span> <span class="n">GetShopNotificationCount</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">shopID</span> <span class="n">ObjectID</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="n">ShopNotificationCount</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">result</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="n">ShopNotificationCount</span><span class="p">{}</span>
    <span class="n">resultChan</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="k">chan</span> <span class="n">countResult</span><span class="p">,</span> <span class="m">6</span><span class="p">)</span>

    <span class="c">// 1. Unread messages</span>
    <span class="k">go</span> <span class="k">func</span><span class="p">()</span> <span class="p">{</span>
        <span class="n">count</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">countUnreadMessages</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">shopID</span><span class="p">)</span>
        <span class="n">resultChan</span> <span class="o">&lt;-</span> <span class="n">countResult</span><span class="p">{</span><span class="s">"unread_messages"</span><span class="p">,</span> <span class="n">count</span><span class="p">,</span> <span class="no">nil</span><span class="p">}</span>
    <span class="p">}()</span>

    <span class="c">// 2. New orders (pending or paid)</span>
    <span class="k">go</span> <span class="k">func</span><span class="p">()</span> <span class="p">{</span>
        <span class="n">count</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">countNewOrders</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">shopID</span><span class="p">)</span>
        <span class="n">resultChan</span> <span class="o">&lt;-</span> <span class="n">countResult</span><span class="p">{</span><span class="s">"new_orders"</span><span class="p">,</span> <span class="n">count</span><span class="p">,</span> <span class="no">nil</span><span class="p">}</span>
    <span class="p">}()</span>

    <span class="c">// 3. Pending refunds</span>
    <span class="k">go</span> <span class="k">func</span><span class="p">()</span> <span class="p">{</span>
        <span class="n">count</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">RefundRequests</span><span class="o">.</span><span class="n">CountDocuments</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span>
            <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"shop_id"</span><span class="o">:</span> <span class="n">shopID</span><span class="p">,</span> <span class="s">"status"</span><span class="o">:</span> <span class="s">"pending"</span><span class="p">})</span>
        <span class="n">resultChan</span> <span class="o">&lt;-</span> <span class="n">countResult</span><span class="p">{</span><span class="s">"pending_refunds"</span><span class="p">,</span> <span class="n">count</span><span class="p">,</span> <span class="no">nil</span><span class="p">}</span>
    <span class="p">}()</span>

    <span class="c">// 4. Low stock items (inventory &lt;= 5)</span>
    <span class="k">go</span> <span class="k">func</span><span class="p">()</span> <span class="p">{</span>
        <span class="n">count</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Listings</span><span class="o">.</span><span class="n">CountDocuments</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
            <span class="s">"shop_id"</span><span class="o">:</span>            <span class="n">shopID</span><span class="p">,</span>
            <span class="s">"state.state"</span><span class="o">:</span>        <span class="n">ListingStateActive</span><span class="p">,</span>
            <span class="s">"inventory.quantity"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"$lte"</span><span class="o">:</span> <span class="m">5</span><span class="p">,</span> <span class="s">"$gt"</span><span class="o">:</span> <span class="m">0</span><span class="p">},</span>
        <span class="p">})</span>
        <span class="n">resultChan</span> <span class="o">&lt;-</span> <span class="n">countResult</span><span class="p">{</span><span class="s">"low_stock_items"</span><span class="p">,</span> <span class="n">count</span><span class="p">,</span> <span class="no">nil</span><span class="p">}</span>
    <span class="p">}()</span>

    <span class="c">// 5. Expiring listings (within 7 days)</span>
    <span class="k">go</span> <span class="k">func</span><span class="p">()</span> <span class="p">{</span>
        <span class="n">sevenDaysFromNow</span> <span class="o">:=</span> <span class="n">time</span><span class="o">.</span><span class="n">Now</span><span class="p">()</span><span class="o">.</span><span class="n">AddDate</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">7</span><span class="p">)</span>
        <span class="n">count</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">Listings</span><span class="o">.</span><span class="n">CountDocuments</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
            <span class="s">"shop_id"</span><span class="o">:</span>     <span class="n">shopID</span><span class="p">,</span>
            <span class="s">"state.state"</span><span class="o">:</span> <span class="n">ListingStateActive</span><span class="p">,</span>
            <span class="s">"expires_at"</span><span class="o">:</span>  <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"$lte"</span><span class="o">:</span> <span class="n">sevenDaysFromNow</span><span class="p">},</span>
        <span class="p">})</span>
        <span class="n">resultChan</span> <span class="o">&lt;-</span> <span class="n">countResult</span><span class="p">{</span><span class="s">"expiring_listings"</span><span class="p">,</span> <span class="n">count</span><span class="p">,</span> <span class="no">nil</span><span class="p">}</span>
    <span class="p">}()</span>

    <span class="c">// 6. Unread notifications</span>
    <span class="k">go</span> <span class="k">func</span><span class="p">()</span> <span class="p">{</span>
        <span class="n">count</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">Coll</span><span class="o">.</span><span class="n">ShopNotifications</span><span class="o">.</span><span class="n">CountDocuments</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span>
            <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"recipient_id"</span><span class="o">:</span> <span class="n">shopID</span><span class="p">,</span> <span class="s">"is_read"</span><span class="o">:</span> <span class="no">false</span><span class="p">})</span>
        <span class="n">resultChan</span> <span class="o">&lt;-</span> <span class="n">countResult</span><span class="p">{</span><span class="s">"unread_notifications"</span><span class="p">,</span> <span class="n">count</span><span class="p">,</span> <span class="no">nil</span><span class="p">}</span>
    <span class="p">}()</span>

    <span class="c">// Collect results</span>
    <span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="m">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="m">6</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span> <span class="p">{</span>
        <span class="n">r</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="n">resultChan</span>
        <span class="k">switch</span> <span class="n">r</span><span class="o">.</span><span class="n">field</span> <span class="p">{</span>
        <span class="k">case</span> <span class="s">"unread_messages"</span><span class="o">:</span>
            <span class="n">result</span><span class="o">.</span><span class="n">UnreadMessages</span> <span class="o">=</span> <span class="n">r</span><span class="o">.</span><span class="n">count</span>
        <span class="k">case</span> <span class="s">"new_orders"</span><span class="o">:</span>
            <span class="n">result</span><span class="o">.</span><span class="n">NewOrders</span> <span class="o">=</span> <span class="n">r</span><span class="o">.</span><span class="n">count</span>
        <span class="c">// ... etc</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="n">result</span><span class="p">,</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Six queries run concurrently. Total latency is the slowest query, not the sum of all queries. On my test data, this drops dashboard load from ~600ms to ~150ms.</p>

<p><img src="/assets/images/i-am-speed.png" alt="I am speed" /></p>

<p>Why not cache these counts? They change constantly. An order comes in, the count increments. A message arrives, unread count changes. Caching would show stale data. For a seller checking their dashboard, accuracy matters more than the 150ms saved by caching.</p>

<hr />

<h2 id="vacation-mode">Vacation Mode</h2>

<p>Sellers can pause their shop without deleting anything:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Shop</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="c">// ...</span>
    <span class="n">IsVacation</span>      <span class="kt">bool</span>   <span class="s">`bson:"is_vacation"`</span>
    <span class="n">VacationMessage</span> <span class="kt">string</span> <span class="s">`bson:"vacation_message"`</span>
<span class="p">}</span>
</code></pre></div></div>

<p>When <code class="language-plaintext highlighter-rouge">IsVacation</code> is true:</p>
<ul>
  <li>Shop is hidden from search results</li>
  <li>Shop page shows the vacation message</li>
  <li>Existing orders continue processing</li>
  <li>Listings remain intact</li>
</ul>

<p>Why a flag instead of a status? Vacation is orthogonal to status. An <code class="language-plaintext highlighter-rouge">active</code> shop can go on vacation. A shop under <code class="language-plaintext highlighter-rouge">warning</code> can also go on vacation. Mixing these into one field would create a combinatorial explosion: <code class="language-plaintext highlighter-rouge">active_vacation</code>, <code class="language-plaintext highlighter-rouge">warning_vacation</code>, etc.</p>

<p>The trade-off? Two fields to check instead of one. Queries for “visible shops” need <code class="language-plaintext highlighter-rouge">status: active AND is_vacation: false</code>. Minor complexity.</p>

<hr />

<h2 id="shop-announcements">Shop Announcements</h2>

<p>Sellers can post announcements that appear at the top of their shop page:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Shop</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="c">// ...</span>
    <span class="n">Announcement</span>           <span class="kt">string</span>    <span class="s">`bson:"announcement"`</span>
    <span class="n">AnnouncementModifiedAt</span> <span class="n">time</span><span class="o">.</span><span class="n">Time</span> <span class="s">`bson:"announcement_modified_at"`</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Announcements are capped at 500 characters—enough for a sale notice or shipping delay warning, not enough for a newsletter. The <code class="language-plaintext highlighter-rouge">AnnouncementModifiedAt</code> timestamp lets the frontend show “Updated 2 days ago” without a separate query.</p>

<p>Why not a separate announcements collection with history? Overkill. Sellers update announcements infrequently. When they do, the old one doesn’t matter. A single field with a timestamp covers the use case.</p>

<hr />

<h2 id="what-id-do-differently">What I’d Do Differently</h2>

<p><strong>The 5-follower embedding.</strong> It works, but it’s a magic number. If the design team wants to show 10 followers on the profile page, I need a migration. A configurable limit or a separate “recent followers” query might be more flexible.</p>

<p><strong>The notification count queries.</strong> Six parallel queries is fast, but it’s still six round trips to MongoDB. A dedicated “shop stats” document updated via change streams would be faster. I’d pay for it with eventual consistency, but dashboard counts don’t need to be real-time accurate.</p>

<hr />

<h2 id="the-pattern">The Pattern</h2>

<p>Same philosophy as listings: optimize for the common case.</p>

<ul>
  <li>Most users own one shop → enforce 1:1, don’t build multi-shop support</li>
  <li>Most shop pages show few followers → embed 5, paginate the rest</li>
  <li>Most sellers ship two tiers → primary/secondary pricing, not per-state</li>
  <li>Most dashboard loads need all counts → parallelize, don’t waterfall</li>
</ul>

<p>The shop architecture is stable. New features—analytics dashboards, promotional tools, bulk listing management—attach cleanly to the existing model.</p>

<hr />

<p><em>Next: <a href="/building-khoomi-week-3.html">Week 3 - Multi-Vendor Order Architecture</a></em></p>

<p>—Samuel</p>]]></content><author><name>Samuel Onoja</name></author><category term="khoomi" /><summary type="html"><![CDATA[How shops work as separate entities from users, atomic creation transactions, embedded followers, and the decisions that make a marketplace seller experience.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sot.dev/assets/images/boromir.webp" /><media:content medium="image" url="https://sot.dev/assets/images/boromir.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Building Khoomi - Week 1: Listing Architecture Decisions</title><link href="https://sot.dev/building-khoomi-week-1.html" rel="alternate" type="text/html" title="Building Khoomi - Week 1: Listing Architecture Decisions" /><published>2026-01-15T00:00:00+00:00</published><updated>2026-01-15T00:00:00+00:00</updated><id>https://sot.dev/building-khoomi-week-1</id><content type="html" xml:base="https://sot.dev/building-khoomi-week-1.html"><![CDATA[<h2 id="introducing-building-khoomi">Introducing “Building Khoomi”</h2>

<p>I’ve been building <a href="/building-khoomi">Khoomi</a> for 3.5 years now, a marketplace for Nigerian artisans and creatives. Nigeria’s e-commerce landscape has unique constraints: local payment rails, unreliable logistics, and infrastructure gaps that global platforms ignore. Along the way, I’ve made thousands of technical decisions, some smart, some I’m still fixing.</p>

<p>This series is where I share what I’ve learned: the actual code, the trade-offs, and what I’d do differently with hindsight.</p>

<p>Each week, I’ll cover one piece of the system: listings, wallets, shipping, payments, all of it. If you’re building something similar, or just curious how a marketplace actually works under the hood, this is for you.</p>

<hr />

<p>Every marketplace lives or dies by how it handles listings. After 3.5 years developing Khoomi (MVP coming soon), I’ve made dozens of decisions about how listings work. Some were obvious. Most weren’t.</p>

<p>Here’s what I learned.</p>

<hr />

<h2 id="the-core-model">The Core Model</h2>

<p>A listing in Khoomi is a single MongoDB document. One document = one product. This sounds obvious until you consider the alternatives.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Listing</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">ID</span>          <span class="n">primitive</span><span class="o">.</span><span class="n">ObjectID</span>
    <span class="n">Code</span>        <span class="kt">string</span>              <span class="c">// "ABCD-1234"</span>
    <span class="n">Slug</span>        <span class="kt">string</span>              <span class="c">// URL-friendly</span>
    <span class="n">ShopID</span>      <span class="n">primitive</span><span class="o">.</span><span class="n">ObjectID</span>
    <span class="n">Title</span>       <span class="kt">string</span>
    <span class="n">Description</span> <span class="kt">string</span>
    <span class="n">Inventory</span>   <span class="n">Inventory</span>
    <span class="n">Variations</span>  <span class="p">[]</span><span class="n">Variation</span>
    <span class="n">Details</span>     <span class="n">ListingDetails</span>      <span class="c">// Category-specific data</span>
    <span class="n">State</span>       <span class="n">ListingState</span>
    <span class="n">Rating</span>      <span class="n">Rating</span>
    <span class="c">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>I chose embedded documents over references. A listing with 10 color variations stores all 10 in a single <code class="language-plaintext highlighter-rouge">variations</code> array, not in a separate <code class="language-plaintext highlighter-rouge">variations</code> collection.</p>

<p>Why? Atomic updates. When a customer buys “Large / Red”, I decrement that variation’s quantity in one database operation. No distributed transactions. No race conditions between collections.</p>

<p>The trade-off? MongoDB’s 16MB document limit caps me at roughly 5,000 variations per listing. For handmade goods, that’s never a problem.</p>

<hr />

<h2 id="two-prices-not-one">Two Prices, Not One</h2>

<p>Every variation can override the base price:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Variation</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">ID</span>       <span class="kt">string</span>
    <span class="n">Name</span>     <span class="kt">string</span>   <span class="c">// "Size"</span>
    <span class="n">Value</span>    <span class="kt">string</span>   <span class="c">// "Large"</span>
    <span class="n">Quantity</span> <span class="kt">int</span>
    <span class="n">Price</span>    <span class="o">*</span><span class="kt">int64</span>   <span class="c">// nil = use base price, 0 = free</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">Price</code> field is a pointer. If <code class="language-plaintext highlighter-rouge">nil</code>, inherit from <code class="language-plaintext highlighter-rouge">inventory.price</code>. If set, use the override.</p>

<p>This lets a seller price a leather bag at ₦15,000 for small and ₦18,000 for large without duplicating the entire listing. Simple idea, but the pointer-vs-value distinction matters. An explicit zero means free. A <code class="language-plaintext highlighter-rouge">nil</code> means “same as base.”</p>

<hr />

<h2 id="stock-reservation-later-not-sooner">Stock Reservation: Later, Not Sooner</h2>

<p>When should you reserve inventory? Two options:</p>

<ol>
  <li><strong>Reserve on cart add</strong> — Item is “held” while in cart</li>
  <li><strong>Reserve on checkout</strong> — Item remains available until payment</li>
</ol>

<p>I chose option 2.</p>

<p>Why? Carts are abandoned constantly. Reserving on cart add means expiry logic: “Release after 15 minutes of inactivity.” That creates edge cases. What if someone’s mid-checkout when the hold expires? What if they have unreliable internet?</p>

<p><img src="/assets/images/abandoned-cart.jpg" alt="Abandoned cart meme" /></p>

<p>Instead, multiple customers can cart the same item. At checkout, I verify and reserve atomically:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">CheckoutService</span><span class="p">)</span> <span class="n">ReserveStock</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">item</span> <span class="n">CartItem</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="n">result</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">listings</span><span class="o">.</span><span class="n">UpdateOne</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span>
        <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
            <span class="s">"_id"</span><span class="o">:</span> <span class="n">item</span><span class="o">.</span><span class="n">ListingID</span><span class="p">,</span>
            <span class="s">"variations"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
                <span class="s">"$elemMatch"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
                    <span class="s">"id"</span><span class="o">:</span>       <span class="n">item</span><span class="o">.</span><span class="n">VariationID</span><span class="p">,</span>
                    <span class="s">"quantity"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"$gte"</span><span class="o">:</span> <span class="n">item</span><span class="o">.</span><span class="n">Quantity</span><span class="p">},</span>
                <span class="p">},</span>
            <span class="p">},</span>
        <span class="p">},</span>
        <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
            <span class="s">"$inc"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
                <span class="s">"variations.$.quantity"</span><span class="o">:</span> <span class="o">-</span><span class="n">item</span><span class="o">.</span><span class="n">Quantity</span><span class="p">,</span>
            <span class="p">},</span>
        <span class="p">},</span>
    <span class="p">)</span>
    <span class="k">if</span> <span class="n">result</span><span class="o">.</span><span class="n">MatchedCount</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">ErrInsufficientStock</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">err</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The query filter ensures stock exists <em>before</em> decrementing. If someone else bought it first, the update matches nothing and checkout fails with a clear message.</p>

<p>The trade-off? Worse UX when items sell out while browsing. But that’s better than the alternative: customers whose “held” items vanish mid-checkout.</p>

<hr />

<h2 id="expiration-via-background-job">Expiration via Background Job</h2>

<p>Listings expire after 30 days. I could check this on every read:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// Option A: Check on read</span>
<span class="k">if</span> <span class="n">listing</span><span class="o">.</span><span class="n">ExpiresAt</span><span class="o">.</span><span class="n">Before</span><span class="p">(</span><span class="n">time</span><span class="o">.</span><span class="n">Now</span><span class="p">())</span> <span class="p">{</span>
    <span class="k">return</span> <span class="n">ErrListingExpired</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Instead, I run a daily job that bulk-updates expired listings:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// Option B: Background job</span>
<span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">ListingService</span><span class="p">)</span> <span class="n">MarkExpiredListings</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">collection</span><span class="o">.</span><span class="n">UpdateMany</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span>
        <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span>
            <span class="s">"state"</span><span class="o">:</span>      <span class="s">"active"</span><span class="p">,</span>
            <span class="s">"expires_at"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"$lt"</span><span class="o">:</span> <span class="n">time</span><span class="o">.</span><span class="n">Now</span><span class="p">()},</span>
        <span class="p">},</span>
        <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"$set"</span><span class="o">:</span> <span class="n">bson</span><span class="o">.</span><span class="n">M</span><span class="p">{</span><span class="s">"state"</span><span class="o">:</span> <span class="s">"expired"</span><span class="p">}},</span>
    <span class="p">)</span>
    <span class="k">return</span> <span class="n">err</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Why? Reads are frequent. Writes are rare. Pushing expiration logic into reads adds latency to every listing view. A background job handles it once, in bulk, off the critical path.</p>

<p>The trade-off? A listing might display as “active” for up to 24 hours past its expiration.</p>

<p><img src="/assets/images/this-is-fine.jpg" alt="This is fine meme" /></p>

<p>For a marketplace selling handmade goods, that’s acceptable. For concert tickets, it wouldn’t be.</p>

<hr />

<h2 id="polymorphic-category-data">Polymorphic Category Data</h2>

<p>Clothing needs size charts. Furniture needs dimensions. Jewelry needs materials. How do you model this?</p>

<p>I use a typed dynamic field:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">ListingDetails</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Category</span>      <span class="n">Category</span>
    <span class="n">DynamicType</span>   <span class="kt">string</span>                 <span class="c">// "clothing", "furniture"</span>
    <span class="n">Dynamic</span>       <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="k">interface</span><span class="p">{}</span> <span class="c">// Raw data</span>
    <span class="n">ClothingData</span>  <span class="o">*</span><span class="n">Clothing</span>              <span class="c">// Typed struct</span>
    <span class="n">FurnitureData</span> <span class="o">*</span><span class="n">Furniture</span>             <span class="c">// Typed struct</span>
    <span class="c">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">Dynamic</code> map stores whatever the frontend sends. The typed struct (<code class="language-plaintext highlighter-rouge">ClothingData</code>, <code class="language-plaintext highlighter-rouge">FurnitureData</code>) is populated by parsing that map at runtime.</p>

<p>Why not separate collections per category? Because listings change categories. A seller might realize their “home decor” item belongs under “art”. With embedded polymorphic data, that’s a field update. With separate collections, it’s a migration.</p>

<p>The trade-off? I can’t index inside the dynamic fields. Searching “all listings with cotton fabric” requires application-level filtering, not a database index. For Khoomi’s scale, that’s fine. For Jumia, it wouldn’t be.</p>

<hr />

<h2 id="what-id-do-differently">What I’d Do Differently</h2>

<p><strong>Slugs.</strong> I generate URL slugs from titles. When titles change, slugs don’t update automatically—to preserve existing links. This creates stale URLs over time. I should have made slugs immutable from day one, or built a redirect system earlier.</p>

<p><strong>Analytics denormalization.</strong> I store <code class="language-plaintext highlighter-rouge">views</code> and <code class="language-plaintext highlighter-rouge">favorers_count</code> directly on the listing document for fast reads. But updating these counters on every view creates write contention. I’m now migrating to a separate analytics collection with periodic sync back to listings.</p>

<hr />

<h2 id="the-pattern">The Pattern</h2>

<p>Most of these decisions follow a pattern: optimize for the common case, accept trade-offs for edge cases.</p>

<ul>
  <li>Most listings have &lt;20 variations → embed them</li>
  <li>Most carts are abandoned → don’t reserve stock early</li>
  <li>Most reads don’t need millisecond-accurate expiration → use background jobs</li>
  <li>Most category changes are rare → use polymorphic embedding</li>
</ul>

<p>The key is knowing your domain. Khoomi sells handmade goods. Low volume per listing. High variety across listings. Patient buyers. These constraints shaped every decision.</p>

<p>Your marketplace might be different. That’s the point.</p>

<hr />

<p><em>Next: <a href="/building-khoomi-week-2.html">Week 2 - Shop Architecture</a></em></p>

<p>—Samuel</p>]]></content><author><name>Samuel Onoja</name></author><category term="khoomi" /><summary type="html"><![CDATA[Deep dive into MongoDB document design, stock reservation strategies, and polymorphic category data for an African marketplace.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sot.dev/assets/images/this-is-fine.jpg" /><media:content medium="image" url="https://sot.dev/assets/images/this-is-fine.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>