<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Marc</title>
    <description>The latest articles on DEV Community by Marc (@marc_kumiko).</description>
    <link>https://dev.to/marc_kumiko</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4001120%2F74d319bd-196a-45cb-843d-d70b2e6a54c5.png</url>
      <title>DEV Community: Marc</title>
      <link>https://dev.to/marc_kumiko</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marc_kumiko"/>
    <language>en</language>
    <item>
      <title>Your event store is already your audit log</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Wed, 01 Jul 2026 07:51:25 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/your-event-store-is-already-your-audit-log-1keo</link>
      <guid>https://dev.to/marc_kumiko/your-event-store-is-already-your-audit-log-1keo</guid>
      <description>&lt;h1&gt;
  
  
  Your event store is already your audit log
&lt;/h1&gt;

&lt;p&gt;Almost every SaaS I've worked on ends up with an &lt;code&gt;audit_log&lt;/code&gt; table. Someone files a compliance ticket — "we need to know who changed what and when" — and a new table appears next to the domain tables. Then the real work starts: writing to it on every mutating endpoint, keeping it in sync, and quietly discovering six months later that three endpoints forgot to log.&lt;/p&gt;

&lt;p&gt;That table is a second source of truth. And second sources of truth drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an audit log actually needs
&lt;/h2&gt;

&lt;p&gt;Strip the compliance language away and an audit entry is five fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;who&lt;/strong&gt; did it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;when&lt;/strong&gt; they did it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;what&lt;/strong&gt; they touched (which entity)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;which action&lt;/strong&gt; it was&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the delta&lt;/strong&gt; — what actually changed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus, for a multi-tenant app: &lt;strong&gt;whose data&lt;/strong&gt; it was, so tenant A can never read tenant B's history.&lt;/p&gt;

&lt;p&gt;Now look at what an event in an event-sourced system carries. Every state change is an appended event with &lt;code&gt;createdBy&lt;/code&gt;, &lt;code&gt;createdAt&lt;/code&gt;, &lt;code&gt;tenantId&lt;/code&gt;, &lt;code&gt;aggregateType&lt;/code&gt; + &lt;code&gt;aggregateId&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, and a &lt;code&gt;payload&lt;/code&gt; holding the delta.&lt;/p&gt;

&lt;p&gt;That's the same five fields. The event log already &lt;em&gt;is&lt;/em&gt; the audit trail — append-only, ordered, and impossible to forget to write, because writing the event &lt;em&gt;is&lt;/em&gt; how state changes in the first place. There's no code path that mutates data without producing an event.&lt;/p&gt;

&lt;h2&gt;
  
  
  So don't build the table. Query the log.
&lt;/h2&gt;

&lt;p&gt;If the audit trail is already there, the whole "audit feature" collapses into one privileged read over the events table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;listQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineQueryHandler&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;list&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;aggregateType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;aggregateId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iso&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iso&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;before&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// cursor&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;access&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SystemAdmin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;where&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// tenant-isolated at the WHERE&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aggregateType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;where&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aggregateType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aggregateType&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aggregateId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="nx"&gt;where&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aggregateId&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aggregateId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="nx"&gt;where&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="nx"&gt;where&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createdBy&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ...time range + cursor omitted for brevity&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;selectMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;eventsTable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;where&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;col&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;desc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No table, no projection, no write path, no sync job. The filter surface an audit UI wants — by entity, by actor, by action, by time — is just &lt;code&gt;WHERE&lt;/code&gt; clauses over columns the events already have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two things you still owe
&lt;/h2&gt;

&lt;p&gt;Reusing the event log doesn't come completely free. Two concerns are real:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Access control.&lt;/strong&gt; The event log is the most sensitive read in the system — it's literally everything that ever happened. Gate it hard (&lt;code&gt;Admin&lt;/code&gt; / &lt;code&gt;SystemAdmin&lt;/code&gt; above) and pin tenant isolation into the &lt;code&gt;WHERE&lt;/code&gt; clause itself, not into application logic that a future refactor can bypass. Cross-tenant peeking should be structurally impossible, not politely discouraged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. PII.&lt;/strong&gt; If you dump raw event payloads into an audit view, you'll surface fields you didn't mean to. The clean fix is to strip sensitive values &lt;em&gt;at append time&lt;/em&gt; — mark them in the entity definition and never let them into the stored event. Then the audit read physically cannot leak them, because they were never written. Doing it at read time is a filter you'll eventually forget on some new field; doing it at write time is a guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this doesn't apply
&lt;/h2&gt;

&lt;p&gt;Honesty: this only works if you're actually event-sourced. If your system does in-place &lt;code&gt;UPDATE&lt;/code&gt;s, there's no historical record to query — you genuinely need to &lt;em&gt;start&lt;/em&gt; capturing one, and a dedicated table (or CDC/logical decoding off the WAL) is the pragmatic path. This isn't an argument to adopt event sourcing &lt;em&gt;for&lt;/em&gt; audit; it's an argument that if you already have it, the second table is redundant.&lt;/p&gt;

&lt;p&gt;One caveat even when it fits: event schemas evolve, so your audit reader sees heterogeneous historical payloads. For an audit log that's a feature — you want the exact shape as it was written — but don't mistake it for a clean queryable projection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point
&lt;/h2&gt;

&lt;p&gt;An audit log isn't a thing you build. It's a &lt;em&gt;view&lt;/em&gt; onto history you're already keeping. If you're appending events, you've been sitting on a complete, tamper-evident audit trail the whole time — the only missing piece was a gated query with the right &lt;code&gt;WHERE&lt;/code&gt; clauses.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is exactly how the &lt;code&gt;audit&lt;/code&gt; feature works in &lt;a href="https://kumiko.rocks" rel="noopener noreferrer"&gt;Kumiko&lt;/a&gt;, a Bun/TypeScript framework where multi-tenancy, GDPR, and audit are bundled features rather than boilerplate you rewrite per project — one ~40-line query handler, no separate table.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>eventsourcing</category>
      <category>postgres</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
    <item>
      <title>How do you prevent AI-generated code from drifting away from your conventions over time?</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Sun, 28 Jun 2026 10:52:37 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/how-do-you-prevent-ai-generated-code-from-drifting-away-from-your-conventions-over-time-4b3l</link>
      <guid>https://dev.to/marc_kumiko/how-do-you-prevent-ai-generated-code-from-drifting-away-from-your-conventions-over-time-4b3l</guid>
      <description>&lt;p&gt;We've been generating production features with AI for a while now — auth flows, billing hooks, notification handlers. And we've hit a pattern we don't have a good answer to yet.&lt;/p&gt;

&lt;p&gt;The first feature the AI generates looks great. It reads the codebase, picks up the patterns, and the output looks like something a senior dev wrote.&lt;/p&gt;

&lt;p&gt;The tenth feature? Less so. Small inconsistencies creep in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A handler that doesn't follow the error-handling convention&lt;/li&gt;
&lt;li&gt;A schema field with a different naming pattern&lt;/li&gt;
&lt;li&gt;A test that checks existence instead of behavior&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of it is wrong. All of it is subtly inconsistent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixes we've tried
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;AGENTS.md / CLAUDE.md&lt;/strong&gt; — helps, but gets stale and doesn't scale with the codebase&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code review&lt;/strong&gt; — catches it, but defeats some of the speed advantage&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linting + formatting&lt;/strong&gt; — catches easy stuff, misses semantic drift&lt;/p&gt;

&lt;p&gt;What we haven't solved: giving the AI a "living" representation of your conventions that stays current as the codebase evolves.&lt;/p&gt;

&lt;p&gt;We're building Kumiko — an opinionated SaaS framework — partly as an answer to this. If the framework constrains what's possible, drift has less surface area. But I'm not convinced that fully solves it either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Curious what's actually working for others
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Do you just review AI output carefully and accept some drift?&lt;/li&gt;
&lt;li&gt;Custom guards / linters that encode your conventions?&lt;/li&gt;
&lt;li&gt;Something that auto-generates AGENTS.md from the codebase?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's your approach?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Multi-Tenancy in Bun/Hono Without Boilerplate</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Wed, 24 Jun 2026 19:11:12 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/multi-tenancy-in-bunhono-without-boilerplate-2kgk</link>
      <guid>https://dev.to/marc_kumiko/multi-tenancy-in-bunhono-without-boilerplate-2kgk</guid>
      <description>&lt;p&gt;Every multi-tenant SaaS has the same problem: you need to make sure every query only returns data for the right tenant. Forget a &lt;code&gt;WHERE tenant_id = ?&lt;/code&gt; once, and you have a data leak.&lt;/p&gt;

&lt;p&gt;The obvious solution — a separate database per tenant — doesn't scale. Connections are expensive, migration overhead multiplies, and you lose cross-tenant reporting.&lt;/p&gt;

&lt;p&gt;For &lt;a href="https://kumiko.rocks" rel="noopener noreferrer"&gt;Kumiko&lt;/a&gt; we went a different route: &lt;strong&gt;a single DB pool, but every query automatically gets the tenantId injected&lt;/strong&gt; — without handler code ever having to do it manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Idea: TenantDb Instead of a Raw DbRunner
&lt;/h2&gt;

&lt;p&gt;Instead of passing a raw DB connection around, we create a &lt;code&gt;TenantDb&lt;/code&gt; wrapper per request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createTenantDb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawDb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From that point, &lt;code&gt;db&lt;/code&gt; behaves like a normal database — but with automatic isolation baked in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Handler code — no tenantId needed&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;selectMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;usersTable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// → SELECT * FROM users WHERE tenant_id IN ('tenant-123', 'system')&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;usersTable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;// → INSERT INTO users (name, tenant_id) VALUES ('Max', 'tenant-123')&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;usersTable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Moritz&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;// → UPDATE users SET name='Moritz' WHERE id=? AND tenant_id='tenant-123'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Handlers write plain CRUD code. Isolation happens underneath — invisible, but enforced.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Injection Works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Reads: own rows + reference data
&lt;/h3&gt;

&lt;p&gt;Read queries always see two tenants: the current one and &lt;code&gt;SYSTEM_TENANT_ID&lt;/code&gt;. This allows reference data (e.g. global config) to be visible to all tenants without duplicating it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tenantId filter === [currentTenantId, SYSTEM_TENANT_ID]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a handler passes its own &lt;code&gt;tenantId&lt;/code&gt; in the WHERE clause, it can only &lt;strong&gt;narrow&lt;/strong&gt; the scope, never widen it. A &lt;code&gt;where: { tenantId: 'other-tenant' }&lt;/code&gt; is silently dropped.&lt;/p&gt;

&lt;h3&gt;
  
  
  Writes: own rows only
&lt;/h3&gt;

&lt;p&gt;Inserts get &lt;code&gt;tenantId&lt;/code&gt; forced in — and the value cannot be overridden by the caller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// mode === "tenant": tenantId on INSERT is enforced last&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// overwrites whatever the handler passed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Updates and deletes without a WHERE clause throw an error instead of hitting all rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Prevents accidental mass-updates&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TenantDb.updateMany without where would mass-update all tenant rows.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  System mode for operators
&lt;/h3&gt;

&lt;p&gt;For admin screens there's &lt;code&gt;r.systemScope()&lt;/code&gt; — queries run unfiltered across all tenants. Explicit opt-in only, never the default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tenant Resolution in Hono
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;TenantDb&lt;/code&gt; needs a &lt;code&gt;tenantId&lt;/code&gt;. It comes from middleware that resolves it from the request — either from the hostname (for custom domains) or from the JWT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Middleware (simplified)&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;resolveTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;db&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;createTenantDb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawDb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every handler then gets proper isolation through &lt;code&gt;ctx.db&lt;/code&gt; — without a single line of tenant logic in actual feature code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Means in Practice
&lt;/h2&gt;

&lt;p&gt;Across three years of production use and several apps (CashColt, publicstatus, kumiko-studio) we've had &lt;strong&gt;zero data leak bugs&lt;/strong&gt; from forgotten tenant filters. Not because we were particularly careful, but because it's structurally impossible to forget.&lt;/p&gt;

&lt;p&gt;The overhead: nearly zero. A few extra conditions per query, no extra DB connection pool, no migration overhead multiplied per tenant.&lt;/p&gt;

&lt;p&gt;If you want to try the framework: &lt;a href="https://kumiko.rocks" rel="noopener noreferrer"&gt;kumiko.rocks&lt;/a&gt; — open source under BUSL-1.1.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>saas</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
