{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "sade.dev",
  "home_page_url": "https://sade.dev/en/",
  "feed_url": "https://sade.dev/en/feed.json",
  "description": "Notes and developer tools on backend, system design, PostgreSQL, Redis, Laravel and AI-assisted engineering.",
  "language": "en-US",
  "authors": [
    {
      "name": "Muhammet Şafak",
      "url": "https://www.muhammetsafak.com.tr"
    }
  ],
  "items": [
    {
      "id": "https://sade.dev/en/journal/orm-vs-native-sql/",
      "url": "https://sade.dev/en/journal/orm-vs-native-sql/",
      "title": "ORM or Native SQL?",
      "summary": "Is the ORM making us lazy, or actually protecting us? The balance of safety, readability, and performance between the two approaches",
      "content_html": "<p>A single page of an admin panel was firing 1,400 queries. The cause wasn’t visible on screen: inside a loop, the innocent-looking <code>$order-&gt;customer-&gt;name</code> call fired a separate query for every order. The ORM hadn’t actually hidden anything — it had only let the developer stop thinking about which query was running.</p>\n<p>Is the ORM making us lazy, or protecting us? The answer is both — and the team decides which one dominates.</p>\n<h2 id=\"what-an-orm-buys-you\">What an ORM buys you</h2>\n<p>The ORM has real upsides, and they shouldn’t be dismissed:</p>\n<ul>\n<li><strong>Safety.</strong> Queries are parameterized by default; under ordinary use, the SQL injection surface is closed.</li>\n<li><strong>Readability.</strong> For CRUD operations, <code>User::create($data)</code> is shorter and clearer than a hand-written <code>INSERT</code>.</li>\n<li><strong>Less repetition.</strong> Relationships, migrations, model events — all in one place.</li>\n</ul>\n<p>The vast majority of projects are CRUD at their core, and for that work the ORM is the right default. I have no objection to that.</p>\n<h2 id=\"where-the-orm-makes-you-lazy\">Where the ORM makes you lazy</h2>\n<p>The danger isn’t in what the ORM does, but in what it <strong>hides</strong>. The ORM keeps SQL out of sight; and when the developer can’t see the SQL, they stop thinking about which query is running.</p>\n<p>The result is familiar: the N+1 from the opening of this piece; pulling a whole row into memory when a single column would do; forgetting a <code>where</code> and unknowingly loading an entire table. The ORM doesn’t “do” any of these; they’re all done by the developer who never looks at what the ORM produces.</p>\n<p>In the <a href=\"/en/systems/data-intensive-systems-breaking-points/\">data-intensive systems</a> piece, the first row of the false-breaking-points table is exactly this: what looks like “the database is slow” is, most of the time, the application firing 200 queries.</p>\n<h2 id=\"where-the-orm-ends\">Where the ORM ends</h2>\n<p>The ORM shines at transactional CRUD: create a record, update it, read it with its relationships. Where it struggles is just as clear:</p>\n<ul>\n<li>Multi-table, multi-step reports.</li>\n<li>Aggregations, window functions, complex conditions on top of <code>GROUP BY</code>.</li>\n<li>Bulk operations — a single <code>UPDATE</code> instead of walking millions of rows one model at a time.</li>\n</ul>\n<p>For this kind of work, insisting on the ORM produces code that is both slower and less readable. Plain SQL is both faster and clearer here. Picking the right indexes is part of this work too — the <a href=\"/en/notes/index-management/\">index management</a> note covers that side.</p>\n<h2 id=\"the-call-orm-as-default-sql-as-a-tool\">The call: ORM as default, SQL as a tool</h2>\n<p>The two aren’t rivals. The right use is roughly this:</p>\n<ul>\n<li>90% of the work — CRUD, simple lists — with the ORM. Fast, safe, readable.</li>\n<li>The remaining 10% — heavy reporting, aggregation, bulk operations — with the query builder or plain SQL.</li>\n</ul>\n<p>The mark of seniority isn’t knowing the ORM; it’s knowing <strong>where it ends</strong>. A team that forces the ORM onto everything turns it into a performance trap; a team that never uses the ORM and writes everything by hand gives up safety and readability for nothing.</p>\n<h2 id=\"both-can-be-written-safely\">Both can be written safely</h2>\n<p>A common mistake: “plain SQL = unsafe.” What provides safety isn’t the ORM; it’s <strong>parameter binding</strong>. A plain query written with bound parameters is as closed to injection as the ORM:</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"php\"><code><span class=\"line\"><span style=\"color:#998418;--shiki-dark:#B8A965\">DB</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">::</span><span style=\"color:#59873A;--shiki-dark:#80A665\">select</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">SELECT</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\"> id, total </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">FROM</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\"> orders </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">WHERE</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> status</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> =</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\"> ?</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#999999;--shiki-dark:#666666\">,</span><span style=\"color:#999999;--shiki-dark:#666666\"> [</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">pending</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#999999;--shiki-dark:#666666\">]);</span></span></code></pre>\n<p>What’s dangerous isn’t plain SQL; it’s embedding user input into the query through string concatenation — and you can do that inside the ORM’s <code>whereRaw</code> too. The habit protects you, not the tool.</p>\n<hr/>\n<p>The ORM can be a laziness machine or a safety net — the difference is made by how the team looks at it. Use it, but never stop seeing what it produces.</p>\n<p>A good developer writes with the ORM; they also know when to put it down.</p>",
      "content_text": "A single page of an admin panel was firing 1,400 queries. The cause wasn't visible on screen: inside a loop, the innocent-looking `$order->customer->name` call fired a separate query for every order. The ORM hadn't actually hidden anything — it had only let the developer stop thinking about which query was running.\n\nIs the ORM making us lazy, or protecting us? The answer is both — and the team decides which one dominates.\n\n## What an ORM buys you\n\nThe ORM has real upsides, and they shouldn't be dismissed:\n\n- **Safety.** Queries are parameterized by default; under ordinary use, the SQL injection surface is closed.\n- **Readability.** For CRUD operations, `User::create($data)` is shorter and clearer than a hand-written `INSERT`.\n- **Less repetition.** Relationships, migrations, model events — all in one place.\n\nThe vast majority of projects are CRUD at their core, and for that work the ORM is the right default. I have no objection to that.\n\n## Where the ORM makes you lazy\n\nThe danger isn't in what the ORM does, but in what it **hides**. The ORM keeps SQL out of sight; and when the developer can't see the SQL, they stop thinking about which query is running.\n\nThe result is familiar: the N+1 from the opening of this piece; pulling a whole row into memory when a single column would do; forgetting a `where` and unknowingly loading an entire table. The ORM doesn't \"do\" any of these; they're all done by the developer who never looks at what the ORM produces.\n\nIn the [data-intensive systems](/en/systems/data-intensive-systems-breaking-points) piece, the first row of the false-breaking-points table is exactly this: what looks like \"the database is slow\" is, most of the time, the application firing 200 queries.\n\n## Where the ORM ends\n\nThe ORM shines at transactional CRUD: create a record, update it, read it with its relationships. Where it struggles is just as clear:\n\n- Multi-table, multi-step reports.\n- Aggregations, window functions, complex conditions on top of `GROUP BY`.\n- Bulk operations — a single `UPDATE` instead of walking millions of rows one model at a time.\n\nFor this kind of work, insisting on the ORM produces code that is both slower and less readable. Plain SQL is both faster and clearer here. Picking the right indexes is part of this work too — the [index management](/en/notes/index-management) note covers that side.\n\n## The call: ORM as default, SQL as a tool\n\nThe two aren't rivals. The right use is roughly this:\n\n- 90% of the work — CRUD, simple lists — with the ORM. Fast, safe, readable.\n- The remaining 10% — heavy reporting, aggregation, bulk operations — with the query builder or plain SQL.\n\nThe mark of seniority isn't knowing the ORM; it's knowing **where it ends**. A team that forces the ORM onto everything turns it into a performance trap; a team that never uses the ORM and writes everything by hand gives up safety and readability for nothing.\n\n## Both can be written safely\n\nA common mistake: \"plain SQL = unsafe.\" What provides safety isn't the ORM; it's **parameter binding**. A plain query written with bound parameters is as closed to injection as the ORM:\n\n```php\nDB::select('SELECT id, total FROM orders WHERE status = ?', ['pending']);\n```\n\nWhat's dangerous isn't plain SQL; it's embedding user input into the query through string concatenation — and you can do that inside the ORM's `whereRaw` too. The habit protects you, not the tool.\n\n---\n\nThe ORM can be a laziness machine or a safety net — the difference is made by how the team looks at it. Use it, but never stop seeing what it produces.\n\nA good developer writes with the ORM; they also know when to put it down.",
      "date_published": "2026-09-12T00:00:00.000Z",
      "tags": [
        "database",
        "orm",
        "sql",
        "opinion",
        "Journal"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/notes/index-management/",
      "url": "https://sade.dev/en/notes/index-management/",
      "title": "Indexes: Too Few Slow Reads, Too Many Kill Writes",
      "summary": "An index is not free read speed; it is read speed paid for with writes. Every INSERT writes an entry into every index on the table, and an UPDATE does the same the moment it touches an indexed column. A HOT update and a partial index are the exceptions, not the default. So add an index because a real query plan asked for it, and drop one because pg_stat_user_indexes shows idx_scan = 0.",
      "content_html": "<p>I’ve seen two opposite teams. One had put no indexes on the table at all — every query a sequential scan, every list page taking seconds. The other had done the exact opposite: “just in case,” it had created an index on every column, and now every <code>INSERT</code> crawled along.</p>\n<p>Both are two ends of the same fallacy: thinking an index is a free source of speed.</p>\n<h2 id=\"an-index-is-not-free-speed\">An index is not free speed</h2>\n<p>An index speeds up reads, because instead of scanning the whole table the database looks at an ordered structure. But that ordered structure doesn’t stay current on its own: an <code>INSERT</code> writes an entry into <strong>every index</strong> on the table, and an <code>UPDATE</code> does the same as soon as it touches an indexed column. There are exceptions — a HOT update that changes no indexed column leaves the indexes alone, a partial index is skipped for rows outside its <code>WHERE</code> clause, and a <code>DELETE</code> leaves its index entries behind for <code>VACUUM</code> to clean up later — but they are exceptions, not the default.</p>\n<p>So every index is a trade: you buy read speed with write cost. Inserting a single row into a table with five indexes means updating six structures at once. An index isn’t “free read speed,” it’s “read speed paid for with writes.”</p>\n<h2 id=\"a-missing-index-measure-dont-guess\">A missing index: measure, don’t guess</h2>\n<p>Find a missing index from the query plan, not from a hunch:</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"sql\"><code><span class=\"line\"><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">EXPLAIN ANALYZE</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">SELECT</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> *</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> FROM</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> orders </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">WHERE</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> status</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> =</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\"> &#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">pending</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">;</span></span></code></pre>\n<p>If you see a <code>Seq Scan</code> on a large table in the output and the query returns a small fraction of the rows, an index is probably missing. Scan the <code>pg_stat_user_tables</code> table for large tables with a high <code>seq_scan</code> count — those are your candidates.</p>\n<p>Add the index for a real, slow query. An index added “in case we need it later” is the database-layer version of <a href=\"/en/journal/the-cost-of-just-in-case-code/\">speculative generality</a>.</p>\n<h2 id=\"too-many-indexes-find-the-unused-ones\">Too many indexes: find the unused ones</h2>\n<p>In the other direction, indexes that are never used make every write more expensive for nothing. Find those by measurement too:</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"sql\"><code><span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">SELECT</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> indexrelname, idx_scan</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">FROM</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> pg_stat_user_indexes</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">WHERE</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> idx_scan </span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">=</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\"> 0</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">;</span></span></code></pre>\n<p>An index with <code>idx_scan = 0</code> means it’s speeding up no reads at all but adding cost to every write. (Keep the indexes for primary keys and unique constraints, of course.) This is the first cleanup listed at the write breaking point of <a href=\"/en/systems/data-intensive-systems-breaking-points/\">data-intensive systems</a>.</p>\n<h2 id=\"how-do-you-choose-the-right-index\">How do you choose the right index?</h2>\n<p>An index isn’t just “present or absent”; choosing the right type and shape matters:</p>\n<ul>\n<li><strong>Column order in a composite index.</strong> An <code>(a, b)</code> index serves equality on <code>a</code> + a range query on <code>b</code>; a lookup on <code>b</code> alone can still use the index, but it doesn’t narrow the portion that gets scanned — before PostgreSQL 18 the whole index is scanned, and from 18 on the skip scan optimization narrows it down. Put the column filtered by equality first, the one filtered by range last.</li>\n<li><strong>Partial index.</strong> If the query always looks at the same subset, limit the index to that subset too: <code>CREATE INDEX ... WHERE status = &#39;active&#39;</code>. A smaller index, cheaper maintenance.</li>\n<li><strong>Covering index.</strong> With <code>INCLUDE</code> you can add frequently read columns to the index and keep the database from going to the table at all.</li>\n<li><strong>Non-B-tree types.</strong> B-tree for equality/ordering; GIN for <code>jsonb</code> and full text; small, cheap BRIN for purely ordered, append-heavy data.</li>\n</ul>\n<h2 id=\"duplicate-and-overlapping-indexes\">Duplicate and overlapping indexes</h2>\n<p>An index on <code>(a)</code> is redundant if you already have an <code>(a, b)</code> index — the composite index serves queries starting with <code>a</code> too. Clean up overlaps like these periodically; each one is a silent write tax.</p>\n<h2 id=\"balance-is-set-with-the-query-plan\">Balance is set with the query plan</h2>\n<p>One rule: don’t add an index by guesswork, and don’t drop one by guesswork either. An index is added because a real query plan asks for it; an index is dropped because the statistics show no one is using it. The balance between too few and too many is struck with <code>EXPLAIN ANALYZE</code> and <code>pg_stat_user_indexes</code>, not with gut feeling.</p>\n<hr/>\n<p>Index management starts with shedding the belief that “more indexes is better.” Every index is a read gain and a write cost; good management is keeping the two in balance by measuring them.</p>\n<p>A missing index slows the query; too many slow the whole table. Measurement guards against both.</p>",
      "content_text": "I've seen two opposite teams. One had put no indexes on the table at all — every query a sequential scan, every list page taking seconds. The other had done the exact opposite: \"just in case,\" it had created an index on every column, and now every `INSERT` crawled along.\n\nBoth are two ends of the same fallacy: thinking an index is a free source of speed.\n\n## An index is not free speed\n\nAn index speeds up reads, because instead of scanning the whole table the database looks at an ordered structure. But that ordered structure doesn't stay current on its own: an `INSERT` writes an entry into **every index** on the table, and an `UPDATE` does the same as soon as it touches an indexed column. There are exceptions — a HOT update that changes no indexed column leaves the indexes alone, a partial index is skipped for rows outside its `WHERE` clause, and a `DELETE` leaves its index entries behind for `VACUUM` to clean up later — but they are exceptions, not the default.\n\nSo every index is a trade: you buy read speed with write cost. Inserting a single row into a table with five indexes means updating six structures at once. An index isn't \"free read speed,\" it's \"read speed paid for with writes.\"\n\n## A missing index: measure, don't guess\n\nFind a missing index from the query plan, not from a hunch:\n\n```sql\nEXPLAIN ANALYZE\nSELECT * FROM orders WHERE status = 'pending';\n```\n\nIf you see a `Seq Scan` on a large table in the output and the query returns a small fraction of the rows, an index is probably missing. Scan the `pg_stat_user_tables` table for large tables with a high `seq_scan` count — those are your candidates.\n\nAdd the index for a real, slow query. An index added \"in case we need it later\" is the database-layer version of [speculative generality](/en/journal/the-cost-of-just-in-case-code).\n\n## Too many indexes: find the unused ones\n\nIn the other direction, indexes that are never used make every write more expensive for nothing. Find those by measurement too:\n\n```sql\nSELECT indexrelname, idx_scan\nFROM pg_stat_user_indexes\nWHERE idx_scan = 0;\n```\n\nAn index with `idx_scan = 0` means it's speeding up no reads at all but adding cost to every write. (Keep the indexes for primary keys and unique constraints, of course.) This is the first cleanup listed at the write breaking point of [data-intensive systems](/en/systems/data-intensive-systems-breaking-points).\n\n## How do you choose the right index?\n\nAn index isn't just \"present or absent\"; choosing the right type and shape matters:\n\n- **Column order in a composite index.** An `(a, b)` index serves equality on `a` + a range query on `b`; a lookup on `b` alone can still use the index, but it doesn't narrow the portion that gets scanned — before PostgreSQL 18 the whole index is scanned, and from 18 on the skip scan optimization narrows it down. Put the column filtered by equality first, the one filtered by range last.\n- **Partial index.** If the query always looks at the same subset, limit the index to that subset too: `CREATE INDEX ... WHERE status = 'active'`. A smaller index, cheaper maintenance.\n- **Covering index.** With `INCLUDE` you can add frequently read columns to the index and keep the database from going to the table at all.\n- **Non-B-tree types.** B-tree for equality/ordering; GIN for `jsonb` and full text; small, cheap BRIN for purely ordered, append-heavy data.\n\n## Duplicate and overlapping indexes\n\nAn index on `(a)` is redundant if you already have an `(a, b)` index — the composite index serves queries starting with `a` too. Clean up overlaps like these periodically; each one is a silent write tax.\n\n## Balance is set with the query plan\n\nOne rule: don't add an index by guesswork, and don't drop one by guesswork either. An index is added because a real query plan asks for it; an index is dropped because the statistics show no one is using it. The balance between too few and too many is struck with `EXPLAIN ANALYZE` and `pg_stat_user_indexes`, not with gut feeling.\n\n---\n\nIndex management starts with shedding the belief that \"more indexes is better.\" Every index is a read gain and a write cost; good management is keeping the two in balance by measuring them.\n\nA missing index slows the query; too many slow the whole table. Measurement guards against both.",
      "date_published": "2026-09-05T00:00:00.000Z",
      "tags": [
        "postgresql",
        "database",
        "performance",
        "indexing",
        "Note"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/notes/zero-downtime-database-migrations/",
      "url": "https://sade.dev/en/notes/zero-downtime-database-migrations/",
      "title": "Changing Schema in Production With Zero Downtime",
      "summary": "A schema change is not what is dangerous; doing it in one step is. Only operations taking an ACCESS EXCLUSIVE lock queue up reads as well as writes, and on a fifty-million-row table that queue is the outage. Break the change into expand-contract steps: add the constraint NOT VALID and run VALIDATE separately, build indexes CONCURRENTLY, and give the migration session a short lock_timeout.",
      "content_html": "<p>A deploy ran a single <code>ALTER TABLE</code> on a table with 50 million rows and locked it for four minutes. For four minutes, every request that touched that table waited; the site was effectively down. The migration itself was correct — the problem was doing it in one step.</p>\n<p>The dangerous thing is not the schema change. The dangerous thing is making a schema change all at once, without thinking about backward compatibility.</p>\n<h2 id=\"who-takes-the-lock\">Who takes the lock?</h2>\n<p>Not every schema change costs the same. In modern PostgreSQL, adding a column with a constant <code>DEFAULT</code> is a metadata operation — it’s fast. The real danger is in operations that lock the table for a long time:</p>\n<ul>\n<li><code>CREATE INDEX</code> — without <code>CONCURRENTLY</code>, it closes the table to writes.</li>\n<li><code>ALTER COLUMN ... TYPE</code> changes that rewrite the table.</li>\n<li><code>NOT NULL</code>, <code>CHECK</code>, or foreign key additions that scan the whole table.</li>\n</ul>\n<p>These operations take a strong lock; the ones that take it in <code>ACCESS EXCLUSIVE</code> mode — a table rewrite, or adding <code>NOT NULL</code>/<code>CHECK</code> — queue up every query touching the table, reads included. The rest block writes only. If the table is large, the queue grows.</p>\n<h2 id=\"the-dangerous-part-is-the-single-step\">The dangerous part is the single step</h2>\n<p>The solution is not to avoid migrations; it’s to break every dangerous migration into small steps, each of which is safe and backward-compatible on its own. This is called the <strong>expand-contract</strong> pattern.</p>\n<p>Suppose you want to rename a column. A single-step <code>RENAME COLUMN</code> instantly breaks running code that reads the old column. Instead, three deploys:</p>\n<ol>\n<li><strong>Expand.</strong> Add the new column. Have the code write to both old and new, still reading from old.</li>\n<li><strong>Migrate.</strong> Move the old data into the new column in batches. Now have the code read from new.</li>\n<li><strong>Contract.</strong> Drop the old column.</li>\n</ol>\n<p>Each step works with both the code from the previous release and the new code. At no moment is the running code incompatible with the schema.</p>\n<h2 id=\"safe-recipes\">Safe recipes</h2>\n<p>The zero-downtime versions of common changes:</p>\n<p><strong>A new <code>NOT NULL</code> column.</strong> Adding <code>NOT NULL</code> in one step scans the table. Split it:</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"sql\"><code><span class=\"line\"><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">-- 1. Add it as nullable first</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">ALTER</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> TABLE</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> orders </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">ADD</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> COLUMN </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">status</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> text</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">-- 2. Backfill existing rows in batches (on the application side)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">-- 3. Add the constraint NOT VALID first, then validate in a separate step</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">ALTER</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> TABLE</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> orders </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">ADD</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> CONSTRAINT</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> orders_status_not_null</span></span>\n<span class=\"line\"><span style=\"color:#AB5959;--shiki-dark:#CB7676\">    CHECK</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> (</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">status</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> IS NOT NULL</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">) </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">NOT</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> VALID;</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">ALTER</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> TABLE</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> orders VALIDATE </span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">CONSTRAINT</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> orders_status_not_null;</span></span></code></pre>\n<p>A constraint added with <code>NOT VALID</code> applies immediately to new rows but does not scan existing ones; <code>VALIDATE</code> then scans the table with only a <code>SHARE UPDATE EXCLUSIVE</code> lock — it doesn’t block writes.</p>\n<p><strong>An index.</strong> Always <code>CONCURRENTLY</code>:</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"sql\"><code><span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">CREATE</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> INDEX</span><span style=\"color:#59873A;--shiki-dark:#80A665\"> CONCURRENTLY</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> idx_orders_status </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">ON</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> orders (</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">status</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">);</span></span></code></pre>\n<p><strong>A foreign key.</strong> The same two-step pattern: first <code>ADD CONSTRAINT ... NOT VALID</code>, then <code>VALIDATE CONSTRAINT</code>.</p>\n<p><strong><code>lock_timeout</code>.</strong> While a migration waits for a lock behind a long-running query, it blocks everything behind it too. To prevent this, give the migration session a short <code>lock_timeout</code> — if the lock can’t be taken immediately, let the migration fail instead of waiting, and retry it yourself.</p>\n<h2 id=\"code-and-schema-must-be-compatible-together\">Code and schema must be compatible together</h2>\n<p>The essence of expand-contract is one rule: at every intermediate step, both the <strong>old code still running</strong> and the <strong>new code</strong> must be able to work with the current schema.</p>\n<p>The database schema is what I called a “one-way door” in <a href=\"/en/journal/the-cost-of-just-in-case-code/\">the cost of just-in-case code</a> — rolling it back is expensive. So design the change not as one big, irreversible step, but as small steps that can each be rolled back individually.</p>\n<hr/>\n<p>Zero-downtime migration is not a tool but a discipline: breaking every schema change into steps small enough that the running code never notices.</p>\n<p>The dangerous thing is not the change itself, but doing it in one breath.</p>",
      "content_text": "A deploy ran a single `ALTER TABLE` on a table with 50 million rows and locked it for four minutes. For four minutes, every request that touched that table waited; the site was effectively down. The migration itself was correct — the problem was doing it in one step.\n\nThe dangerous thing is not the schema change. The dangerous thing is making a schema change all at once, without thinking about backward compatibility.\n\n## Who takes the lock?\n\nNot every schema change costs the same. In modern PostgreSQL, adding a column with a constant `DEFAULT` is a metadata operation — it's fast. The real danger is in operations that lock the table for a long time:\n\n- `CREATE INDEX` — without `CONCURRENTLY`, it closes the table to writes.\n- `ALTER COLUMN ... TYPE` changes that rewrite the table.\n- `NOT NULL`, `CHECK`, or foreign key additions that scan the whole table.\n\nThese operations take a strong lock; the ones that take it in `ACCESS EXCLUSIVE` mode — a table rewrite, or adding `NOT NULL`/`CHECK` — queue up every query touching the table, reads included. The rest block writes only. If the table is large, the queue grows.\n\n## The dangerous part is the single step\n\nThe solution is not to avoid migrations; it's to break every dangerous migration into small steps, each of which is safe and backward-compatible on its own. This is called the **expand-contract** pattern.\n\nSuppose you want to rename a column. A single-step `RENAME COLUMN` instantly breaks running code that reads the old column. Instead, three deploys:\n\n1. **Expand.** Add the new column. Have the code write to both old and new, still reading from old.\n2. **Migrate.** Move the old data into the new column in batches. Now have the code read from new.\n3. **Contract.** Drop the old column.\n\nEach step works with both the code from the previous release and the new code. At no moment is the running code incompatible with the schema.\n\n## Safe recipes\n\nThe zero-downtime versions of common changes:\n\n**A new `NOT NULL` column.** Adding `NOT NULL` in one step scans the table. Split it:\n\n```sql\n-- 1. Add it as nullable first\nALTER TABLE orders ADD COLUMN status text;\n\n-- 2. Backfill existing rows in batches (on the application side)\n\n-- 3. Add the constraint NOT VALID first, then validate in a separate step\nALTER TABLE orders ADD CONSTRAINT orders_status_not_null\n    CHECK (status IS NOT NULL) NOT VALID;\nALTER TABLE orders VALIDATE CONSTRAINT orders_status_not_null;\n```\n\nA constraint added with `NOT VALID` applies immediately to new rows but does not scan existing ones; `VALIDATE` then scans the table with only a `SHARE UPDATE EXCLUSIVE` lock — it doesn't block writes.\n\n**An index.** Always `CONCURRENTLY`:\n\n```sql\nCREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);\n```\n\n**A foreign key.** The same two-step pattern: first `ADD CONSTRAINT ... NOT VALID`, then `VALIDATE CONSTRAINT`.\n\n**`lock_timeout`.** While a migration waits for a lock behind a long-running query, it blocks everything behind it too. To prevent this, give the migration session a short `lock_timeout` — if the lock can't be taken immediately, let the migration fail instead of waiting, and retry it yourself.\n\n## Code and schema must be compatible together\n\nThe essence of expand-contract is one rule: at every intermediate step, both the **old code still running** and the **new code** must be able to work with the current schema.\n\nThe database schema is what I called a \"one-way door\" in [the cost of just-in-case code](/en/journal/the-cost-of-just-in-case-code) — rolling it back is expensive. So design the change not as one big, irreversible step, but as small steps that can each be rolled back individually.\n\n---\n\nZero-downtime migration is not a tool but a discipline: breaking every schema change into steps small enough that the running code never notices.\n\nThe dangerous thing is not the change itself, but doing it in one breath.",
      "date_published": "2026-08-29T00:00:00.000Z",
      "tags": [
        "postgresql",
        "database",
        "migrations",
        "production",
        "Note"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/notes/read-write-splitting/",
      "url": "https://sade.dev/en/notes/read-write-splitting/",
      "title": "Read/Write Splitting: Separating Read and Write Load",
      "summary": "A read replica scales reads and adds nothing to write capacity, so the trade is not free: the price is replication lag. Laravel's sticky option repairs read-after-write only inside a single request; across two requests the user still sees the profile they just updated as stale. Do index discipline first, because a replica is often an expensive way to buy what one CREATE INDEX would have solved.",
      "content_html": "<p>The primary database’s CPU was constantly maxed out. The interesting part: the write rate was low. Almost all of the load was reads — report pages, listing endpoints, search. A single primary was trying to carry a pile of reads that never needed it in the first place.</p>\n<p>Separating read and write load — read/write splitting — is the known fix for this picture. But applied at the wrong time, or without awareness of the right traps, it brings more problems than it solves.</p>\n<h2 id=\"most-load-is-read-heavy\">Most load is read-heavy</h2>\n<p>How read-heavy the traffic is depends on the workload: in measurements of the standard OLTP benchmarks, TPC-E runs 90.69% reads while TPC-C stays at 65.71% — so measure your own ratio instead of assuming it. Every order is written once but read dozens of times: in the list, in the detail view, in a report, on a dashboard. This asymmetry is what makes read/write splitting appealing — because the side you need to scale is obvious.</p>\n<h2 id=\"first-is-this-really-a-capacity-problem\">First: is this really a capacity problem?</h2>\n<p>Stop before adding a replica. A full primary doesn’t always mean “out of capacity.” Often a single missing index makes the primary look many times busier than it is.</p>\n<p>Adding a replica — a new server, replication setup, lag monitoring — can amount to expensively buying your way out of a problem a single <code>CREATE INDEX</code> would have solved. The order in <a href=\"/en/systems/data-intensive-systems-breaking-points/\">the breaking points of data-intensive systems</a> is clear: index discipline first, then replicas. Don’t skip that order.</p>\n<p>Measure your queries with <code>EXPLAIN ANALYZE</code>. If the primary is genuinely saturating under correctly indexed queries — that’s when you reach for a replica.</p>\n<h2 id=\"a-replica-scales-reads-not-writes\">A replica scales reads, not writes</h2>\n<p>Let’s be clear: a read replica adds <strong>nothing</strong> to your write capacity. The same writes are replayed on every replica. A replica solves a read-load problem; if you have a write-load problem, a replica is the wrong tool.</p>\n<h2 id=\"setting-it-up-in-laravel\">Setting it up in Laravel</h2>\n<p>Laravel supports read/write connection splitting natively:</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"php\"><code><span class=\"line\"><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">// config/database.php</span></span>\n<span class=\"line\"><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">pgsql</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> =&gt;</span><span style=\"color:#999999;--shiki-dark:#666666\"> [</span></span>\n<span class=\"line\"><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">    &#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">driver</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> =&gt;</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\"> &#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">pgsql</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#999999;--shiki-dark:#666666\">,</span></span>\n<span class=\"line\"><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">    &#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">read</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">   =&gt;</span><span style=\"color:#999999;--shiki-dark:#666666\"> [</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">host</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> =&gt;</span><span style=\"color:#999999;--shiki-dark:#666666\"> [</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">10.0.0.2</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#999999;--shiki-dark:#666666\">]],</span><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">   // replica</span></span>\n<span class=\"line\"><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">    &#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">write</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">  =&gt;</span><span style=\"color:#999999;--shiki-dark:#666666\"> [</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">host</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> =&gt;</span><span style=\"color:#999999;--shiki-dark:#666666\"> [</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">10.0.0.1</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#999999;--shiki-dark:#666666\">]],</span><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">   // primary</span></span>\n<span class=\"line\"><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">    &#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">sticky</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> =&gt;</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> true</span><span style=\"color:#999999;--shiki-dark:#666666\">,</span></span>\n<span class=\"line\"><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">    // ...shared settings</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">],</span></span></code></pre>\n<p><code>SELECT</code>s go to the replica, <code>INSERT/UPDATE/DELETE</code>s go to the primary. The replica gets its own connection pool — separate from the primary’s; if you use <a href=\"/en/notes/pgbouncer-auth-query/\">pgBouncer</a>, they are two distinct pools.</p>\n<h2 id=\"replication-lag-the-real-bill\">Replication lag: the real bill</h2>\n<p>The replica trails the primary by a few milliseconds — a few seconds under load. This delay is the real cost of read/write splitting, and its name is the <strong>read-after-write</strong> problem.</p>\n<p><code>sticky =&gt; true</code> partly addresses it: if you wrote within a request, the subsequent reads in that same request go to the primary. But <code>sticky</code> only works within the boundary of <strong>a single request</strong>.</p>\n<p>Outside that boundary it’s still open: a user updates their profile (request 1, written to the primary), moves to the next page (request 2, read from the replica), and sees their old profile, not yet replayed on the replica. The user sees their own data as stale. This looks like a bug, but it’s actually a tradeoff the architecture accepts — one that has to be accepted deliberately.</p>\n<h2 id=\"the-query-classification-discipline\">The query classification discipline</h2>\n<p>Setting up read/write splitting requires every read to answer one question: <strong>can this query read stale data?</strong></p>\n<ul>\n<li><strong>Can read from a replica:</strong> lists, reports, search results, dashboards. A few seconds of delay is irrelevant.</li>\n<li><strong>Must read from the primary:</strong> account balance, stock count, authorization checks, any read a write decision depends on.</li>\n</ul>\n<p>This classification is now part of the architecture, and it needs to be documented. A new developer must have somewhere to look for “where should this query read from” — otherwise the classification quietly rots.</p>\n<h2 id=\"when-do-you-actually-need-it\">When do you actually need it?</h2>\n<p>Read/write splitting is the right move when these three conditions hold together:</p>\n<ol>\n<li>Index discipline is complete; queries are correctly indexed and the primary is still saturating.</li>\n<li>The load is measurably read-heavy.</li>\n<li>There’s the discipline to do and document the stale-read classification.</li>\n</ol>\n<p>If any one of these is missing, deferring the replica is cheaper.</p>\n<hr/>\n<p>Read/write splitting is a cheaper scaling move than growing vertically — but it isn’t free. The price is replication lag, and the cost of ignoring it is paid by showing users their own data as stale.</p>\n<p>Before splitting reads, know which reads can tolerate staleness.</p>",
      "content_text": "The primary database's CPU was constantly maxed out. The interesting part: the write rate was low. Almost all of the load was reads — report pages, listing endpoints, search. A single primary was trying to carry a pile of reads that never needed it in the first place.\n\nSeparating read and write load — read/write splitting — is the known fix for this picture. But applied at the wrong time, or without awareness of the right traps, it brings more problems than it solves.\n\n## Most load is read-heavy\n\nHow read-heavy the traffic is depends on the workload: in measurements of the standard OLTP benchmarks, TPC-E runs 90.69% reads while TPC-C stays at 65.71% — so measure your own ratio instead of assuming it. Every order is written once but read dozens of times: in the list, in the detail view, in a report, on a dashboard. This asymmetry is what makes read/write splitting appealing — because the side you need to scale is obvious.\n\n## First: is this really a capacity problem?\n\nStop before adding a replica. A full primary doesn't always mean \"out of capacity.\" Often a single missing index makes the primary look many times busier than it is.\n\nAdding a replica — a new server, replication setup, lag monitoring — can amount to expensively buying your way out of a problem a single `CREATE INDEX` would have solved. The order in [the breaking points of data-intensive systems](/en/systems/data-intensive-systems-breaking-points) is clear: index discipline first, then replicas. Don't skip that order.\n\nMeasure your queries with `EXPLAIN ANALYZE`. If the primary is genuinely saturating under correctly indexed queries — that's when you reach for a replica.\n\n## A replica scales reads, not writes\n\nLet's be clear: a read replica adds **nothing** to your write capacity. The same writes are replayed on every replica. A replica solves a read-load problem; if you have a write-load problem, a replica is the wrong tool.\n\n## Setting it up in Laravel\n\nLaravel supports read/write connection splitting natively:\n\n```php\n// config/database.php\n'pgsql' => [\n    'driver' => 'pgsql',\n    'read'   => ['host' => ['10.0.0.2']],   // replica\n    'write'  => ['host' => ['10.0.0.1']],   // primary\n    'sticky' => true,\n    // ...shared settings\n],\n```\n\n`SELECT`s go to the replica, `INSERT/UPDATE/DELETE`s go to the primary. The replica gets its own connection pool — separate from the primary's; if you use [pgBouncer](/en/notes/pgbouncer-auth-query), they are two distinct pools.\n\n## Replication lag: the real bill\n\nThe replica trails the primary by a few milliseconds — a few seconds under load. This delay is the real cost of read/write splitting, and its name is the **read-after-write** problem.\n\n`sticky => true` partly addresses it: if you wrote within a request, the subsequent reads in that same request go to the primary. But `sticky` only works within the boundary of **a single request**.\n\nOutside that boundary it's still open: a user updates their profile (request 1, written to the primary), moves to the next page (request 2, read from the replica), and sees their old profile, not yet replayed on the replica. The user sees their own data as stale. This looks like a bug, but it's actually a tradeoff the architecture accepts — one that has to be accepted deliberately.\n\n## The query classification discipline\n\nSetting up read/write splitting requires every read to answer one question: **can this query read stale data?**\n\n- **Can read from a replica:** lists, reports, search results, dashboards. A few seconds of delay is irrelevant.\n- **Must read from the primary:** account balance, stock count, authorization checks, any read a write decision depends on.\n\nThis classification is now part of the architecture, and it needs to be documented. A new developer must have somewhere to look for \"where should this query read from\" — otherwise the classification quietly rots.\n\n## When do you actually need it?\n\nRead/write splitting is the right move when these three conditions hold together:\n\n1. Index discipline is complete; queries are correctly indexed and the primary is still saturating.\n2. The load is measurably read-heavy.\n3. There's the discipline to do and document the stale-read classification.\n\nIf any one of these is missing, deferring the replica is cheaper.\n\n---\n\nRead/write splitting is a cheaper scaling move than growing vertically — but it isn't free. The price is replication lag, and the cost of ignoring it is paid by showing users their own data as stale.\n\nBefore splitting reads, know which reads can tolerate staleness.",
      "date_published": "2026-08-22T00:00:00.000Z",
      "tags": [
        "postgresql",
        "database",
        "scaling",
        "performance",
        "Note"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/notes/the-right-cache-strategy/",
      "url": "https://sade.dev/en/notes/the-right-cache-strategy/",
      "title": "Before You Reach for Redis: The Right Cache Strategy",
      "summary": "A cache is never a faster database; it is speed bought with accepted staleness, so the first question is not technical: how stale may this data be? TTL is the boring default, because it does not require knowing every write path and it does not break silently when one is forgotten. Explicit invalidation is exact only if you catch every path. And a cache reduces read load, never write load.",
      "content_html": "<p>A team added a Redis cache because “the site is slow.” A week later the support queue filled up: “I updated the price but the old one still shows.” The cache had fixed the slowness — and quietly created a new problem.</p>\n<p>Adding a cache is not a free performance win. It trades freshness for speed; and if you didn’t make that trade deliberately, the bill comes back as a bug.</p>\n<h2 id=\"a-cache-is-a-consistency-concession\">A cache is a consistency concession</h2>\n<p>The moment you cache a value, you’ve accepted this: someone, for a while, may see stale data. A cache is never a “faster database”; it’s “speed in exchange for accepted staleness.”</p>\n<p>So the first question isn’t technical: <strong>how stale can this data be?</strong> Data whose answer is “not at all” — an account balance, a stock count — should be thought through twice before it’s cached.</p>\n<h2 id=\"questions-to-ask-first\">Questions to ask first</h2>\n<p>Before you turn on Redis:</p>\n<ul>\n<li><strong>Is this really a read bottleneck?</strong> “The site is slow” is a hypothesis, not a measurement. Adding a cache without seeing the source of the slowness is covering up something you don’t understand.</li>\n<li><strong>Is the slowness actually a missing index?</strong> A cache papers over a bad query but doesn’t fix it. Hiding behind a cache what a <code>CREATE INDEX</code> would solve is moving the problem, not solving it — this is exactly the wrong breaking point in <a href=\"/en/systems/data-intensive-systems-breaking-points/\">data-intensive systems</a>.</li>\n<li><strong>How stale can this data stay?</strong> The answer determines which strategy you pick.</li>\n</ul>\n<h2 id=\"why-is-invalidation-hard\">Why is invalidation hard?</h2>\n<p>Cache invalidation gets cited as one of the two hard problems in software — and that’s no exaggeration. Putting the cache in is easy; <strong>clearing</strong> it at the right moment is hard.</p>\n<p>Because a cached value must be cleared on every write path that affects it. A product price changes not only from the “edit product” screen; it also changes from a bulk price update, a discount job, an admin script. Finding <strong>all</strong> of those paths and clearing the cache — that’s the hard part. Miss one, and you get the support ticket from the top of this post.</p>\n<h2 id=\"two-strategies\">Two strategies</h2>\n<p>In practice there are two roads:</p>\n<ul>\n<li><strong>TTL-based.</strong> You assign the value a lifetime — 60 seconds, 5 minutes. When it expires, the cache refreshes itself. Simple, sturdy, and it doesn’t require knowing every write path. In return: you accept staleness up to the TTL.</li>\n<li><strong>Explicit invalidation.</strong> You delete the cache by hand when the data changes. Exact and fresh — but only if you catch every write path completely.</li>\n</ul>\n<p>My default is TTL. It’s boring, predictable, and it doesn’t break silently because of a forgotten write path. I move to explicit invalidation only when staleness is genuinely unacceptable and the number of write paths is limited and known. Most of the time the two are used together: a short TTL as a safety net, explicit invalidation for speed.</p>\n<h2 id=\"a-cache-doesnt-reduce-writes\">A cache doesn’t reduce writes</h2>\n<p>A common mistake: trying to rescue a system under write load by adding a cache. A cache reduces <strong>read</strong> load; it has no effect on write load — if anything, invalidation itself is extra write work. If your problem is on the write side, a cache is the wrong tool.</p>\n<h2 id=\"practical-patterns\">Practical patterns</h2>\n<ul>\n<li><strong>Cache-aside.</strong> Read: check the cache first, and on a miss fetch the value from the database and write it to the cache. The most common and most understandable pattern.</li>\n<li><strong>Stampede protection.</strong> The instant a popular key’s TTL expires, hundreds of requests hit the database at once. On a cache miss, use a short lock so only one request goes to the database.</li>\n<li><strong>Key discipline.</strong> Namespace your cache keys — the pattern in the <a href=\"/en/notes/shared-redis-namespace-isolation/\">shared Redis namespace isolation</a> note applies to cache keys too.</li>\n</ul>\n<hr/>\n<p>A cache is not the cure for a slow system; it’s the relief of a measured read bottleneck, bought with a deliberate consistency concession. A cache added without an invalidation plan takes back more as debt than it speeds up.</p>\n<p>Before you add a cache, ask: how old can this data be — and who’s going to clear it?</p>",
      "content_text": "A team added a Redis cache because \"the site is slow.\" A week later the support queue filled up: \"I updated the price but the old one still shows.\" The cache had fixed the slowness — and quietly created a new problem.\n\nAdding a cache is not a free performance win. It trades freshness for speed; and if you didn't make that trade deliberately, the bill comes back as a bug.\n\n## A cache is a consistency concession\n\nThe moment you cache a value, you've accepted this: someone, for a while, may see stale data. A cache is never a \"faster database\"; it's \"speed in exchange for accepted staleness.\"\n\nSo the first question isn't technical: **how stale can this data be?** Data whose answer is \"not at all\" — an account balance, a stock count — should be thought through twice before it's cached.\n\n## Questions to ask first\n\nBefore you turn on Redis:\n\n- **Is this really a read bottleneck?** \"The site is slow\" is a hypothesis, not a measurement. Adding a cache without seeing the source of the slowness is covering up something you don't understand.\n- **Is the slowness actually a missing index?** A cache papers over a bad query but doesn't fix it. Hiding behind a cache what a `CREATE INDEX` would solve is moving the problem, not solving it — this is exactly the wrong breaking point in [data-intensive systems](/en/systems/data-intensive-systems-breaking-points).\n- **How stale can this data stay?** The answer determines which strategy you pick.\n\n## Why is invalidation hard?\n\nCache invalidation gets cited as one of the two hard problems in software — and that's no exaggeration. Putting the cache in is easy; **clearing** it at the right moment is hard.\n\nBecause a cached value must be cleared on every write path that affects it. A product price changes not only from the \"edit product\" screen; it also changes from a bulk price update, a discount job, an admin script. Finding **all** of those paths and clearing the cache — that's the hard part. Miss one, and you get the support ticket from the top of this post.\n\n## Two strategies\n\nIn practice there are two roads:\n\n- **TTL-based.** You assign the value a lifetime — 60 seconds, 5 minutes. When it expires, the cache refreshes itself. Simple, sturdy, and it doesn't require knowing every write path. In return: you accept staleness up to the TTL.\n- **Explicit invalidation.** You delete the cache by hand when the data changes. Exact and fresh — but only if you catch every write path completely.\n\nMy default is TTL. It's boring, predictable, and it doesn't break silently because of a forgotten write path. I move to explicit invalidation only when staleness is genuinely unacceptable and the number of write paths is limited and known. Most of the time the two are used together: a short TTL as a safety net, explicit invalidation for speed.\n\n## A cache doesn't reduce writes\n\nA common mistake: trying to rescue a system under write load by adding a cache. A cache reduces **read** load; it has no effect on write load — if anything, invalidation itself is extra write work. If your problem is on the write side, a cache is the wrong tool.\n\n## Practical patterns\n\n- **Cache-aside.** Read: check the cache first, and on a miss fetch the value from the database and write it to the cache. The most common and most understandable pattern.\n- **Stampede protection.** The instant a popular key's TTL expires, hundreds of requests hit the database at once. On a cache miss, use a short lock so only one request goes to the database.\n- **Key discipline.** Namespace your cache keys — the pattern in the [shared Redis namespace isolation](/en/notes/shared-redis-namespace-isolation) note applies to cache keys too.\n\n---\n\nA cache is not the cure for a slow system; it's the relief of a measured read bottleneck, bought with a deliberate consistency concession. A cache added without an invalidation plan takes back more as debt than it speeds up.\n\nBefore you add a cache, ask: how old can this data be — and who's going to clear it?",
      "date_published": "2026-08-15T00:00:00.000Z",
      "tags": [
        "caching",
        "redis",
        "performance",
        "production",
        "Note"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/journal/the-nosql-trap/",
      "url": "https://sade.dev/en/journal/the-nosql-trap/",
      "title": "The NoSQL Trap: Starting Because \"It Has No Schema\"",
      "summary": "There is no such thing as schemaless data, only the question of where the shape is defined and who enforces it. Choosing a document database for the comfort of not wanting a schema does not delete the schema; it moves it into every read, every write and the team's heads, where nothing enforces it. NoSQL earns its place on horizontal write scale or genuinely heterogeneous documents, not on schema avoidance.",
      "content_html": "<p>I once saw a project that had picked MongoDB on the logic of “no schema, we’ll move fast” — two years later. Every read was defensive: <code>data?.user?.address?.city ?? null</code>. The reason was simple — no two documents had exactly the same shape. The schema hadn’t disappeared; it had just left the database and scattered across every bit of code that reads a document.</p>\n<h2 id=\"schemaless-is-a-misnomer\">”Schemaless” is a misnomer</h2>\n<p>There is no such thing as schemaless data. Data always has a shape: a user’s email, the line items of an order, the date on an invoice. The question is not whether there is a shape; it is <strong>where that shape is defined</strong>.</p>\n<p>There are two options. Either the schema lives in the database and is enforced by the database, or it lives in the application code and everyone is assumed to comply with it. Saying “schemaless” means choosing the second — not deleting the schema, but moving it somewhere it isn’t enforced.</p>\n<h2 id=\"where-does-the-schema-move\">Where does the schema move?</h2>\n<p>When the database stops enforcing the schema, that work doesn’t vanish; it scatters to these places:</p>\n<ul>\n<li><strong>To every read.</strong> The code can no longer assume the incoming document has the expected fields; it checks every field defensively.</li>\n<li><strong>To every write.</strong> There is no guarantee you wrote the right shape; there is only hope.</li>\n<li><strong>To scattered validation code.</strong> The <code>NOT NULL</code>, type check, and foreign key that the database does in a single line all turn into hand-written checks.</li>\n<li><strong>To the team’s heads.</strong> “That field is sometimes a string, sometimes an array” becomes undocumented tribal knowledge.</li>\n</ul>\n<p>A schema is not bureaucracy; it is validation the database does for free on your behalf. Refusing it doesn’t erase the bill; it just charges it to someone else.</p>\n<h2 id=\"the-crises-you-hit\">The crises you hit</h2>\n<p>Projects that start with the comfort of “schemaless” sooner or later hit these:</p>\n<ul>\n<li><strong>Data drift.</strong> Over time, ten different versions of a document live in the same collection. A record written in 2023 doesn’t have the same shape as one written in 2026, and no migration ever enforced it.</li>\n<li><strong>No referential integrity.</strong> An order pointing to a user that doesn’t exist isn’t blocked by the database. Orphan records pile up silently.</li>\n<li><strong>Migration becomes an application job.</strong> What a single <code>ALTER TABLE</code> does in the relational world becomes, here, a script that walks millions of documents one by one and has to be batched.</li>\n</ul>\n<p>These are invisible in a small project; they blow up at exactly the moment the project grows, the team changes, and no one can answer “why is that field sometimes missing?”</p>\n<h2 id=\"where-nosql-really-is-the-right-answer\">Where NoSQL really is the right answer</h2>\n<p>This is not an anti-NoSQL piece. NoSQL has real and justified uses:</p>\n<ul>\n<li>Workloads that exceed the write capacity of a single primary and genuinely need horizontal scale — the hardest breaking point in <a href=\"/en/systems/data-intensive-systems-breaking-points/\">data-intensive systems</a>.</li>\n<li>Documents that are genuinely heterogeneous by nature.</li>\n<li>Specific access patterns — pure key-value, wide-column, graph.</li>\n</ul>\n<p>The trap isn’t NoSQL; it’s choosing NoSQL not for one of these real strengths but just because “I don’t want a schema.” Right tool, wrong reason.</p>\n<h2 id=\"if-you-want-flexibility-postgresql-already-gives-it\">If you want flexibility, PostgreSQL already gives it</h2>\n<p>If the need for “let some fields be flexible” is real, you don’t have to sacrifice all your integrity for it. PostgreSQL’s <code>jsonb</code> column gives you a flexible document field within the integrity of a relational table — as I touched on in <a href=\"/en/journal/is-postgresql-enough-for-everything/\">is PostgreSQL enough for everything</a>. The fields that are fixed stay as schema, and the part that is genuinely variable lives in <code>jsonb</code>. The best of both, and in a single system.</p>\n<hr/>\n<p>A schema is not a burden; it is a shield. When you remove it from the database it doesn’t disappear — it just leaves the place where it protected you and moves to a place where it can’t.</p>\n<p>There is no such thing as “schemaless”; there is only the question of “who enforces the schema.”</p>",
      "content_text": "I once saw a project that had picked MongoDB on the logic of \"no schema, we'll move fast\" — two years later. Every read was defensive: `data?.user?.address?.city ?? null`. The reason was simple — no two documents had exactly the same shape. The schema hadn't disappeared; it had just left the database and scattered across every bit of code that reads a document.\n\n## \"Schemaless\" is a misnomer\n\nThere is no such thing as schemaless data. Data always has a shape: a user's email, the line items of an order, the date on an invoice. The question is not whether there is a shape; it is **where that shape is defined**.\n\nThere are two options. Either the schema lives in the database and is enforced by the database, or it lives in the application code and everyone is assumed to comply with it. Saying \"schemaless\" means choosing the second — not deleting the schema, but moving it somewhere it isn't enforced.\n\n## Where does the schema move?\n\nWhen the database stops enforcing the schema, that work doesn't vanish; it scatters to these places:\n\n- **To every read.** The code can no longer assume the incoming document has the expected fields; it checks every field defensively.\n- **To every write.** There is no guarantee you wrote the right shape; there is only hope.\n- **To scattered validation code.** The `NOT NULL`, type check, and foreign key that the database does in a single line all turn into hand-written checks.\n- **To the team's heads.** \"That field is sometimes a string, sometimes an array\" becomes undocumented tribal knowledge.\n\nA schema is not bureaucracy; it is validation the database does for free on your behalf. Refusing it doesn't erase the bill; it just charges it to someone else.\n\n## The crises you hit\n\nProjects that start with the comfort of \"schemaless\" sooner or later hit these:\n\n- **Data drift.** Over time, ten different versions of a document live in the same collection. A record written in 2023 doesn't have the same shape as one written in 2026, and no migration ever enforced it.\n- **No referential integrity.** An order pointing to a user that doesn't exist isn't blocked by the database. Orphan records pile up silently.\n- **Migration becomes an application job.** What a single `ALTER TABLE` does in the relational world becomes, here, a script that walks millions of documents one by one and has to be batched.\n\nThese are invisible in a small project; they blow up at exactly the moment the project grows, the team changes, and no one can answer \"why is that field sometimes missing?\"\n\n## Where NoSQL really is the right answer\n\nThis is not an anti-NoSQL piece. NoSQL has real and justified uses:\n\n- Workloads that exceed the write capacity of a single primary and genuinely need horizontal scale — the hardest breaking point in [data-intensive systems](/en/systems/data-intensive-systems-breaking-points).\n- Documents that are genuinely heterogeneous by nature.\n- Specific access patterns — pure key-value, wide-column, graph.\n\nThe trap isn't NoSQL; it's choosing NoSQL not for one of these real strengths but just because \"I don't want a schema.\" Right tool, wrong reason.\n\n## If you want flexibility, PostgreSQL already gives it\n\nIf the need for \"let some fields be flexible\" is real, you don't have to sacrifice all your integrity for it. PostgreSQL's `jsonb` column gives you a flexible document field within the integrity of a relational table — as I touched on in [is PostgreSQL enough for everything](/en/journal/is-postgresql-enough-for-everything). The fields that are fixed stay as schema, and the part that is genuinely variable lives in `jsonb`. The best of both, and in a single system.\n\n---\n\nA schema is not a burden; it is a shield. When you remove it from the database it doesn't disappear — it just leaves the place where it protected you and moves to a place where it can't.\n\nThere is no such thing as \"schemaless\"; there is only the question of \"who enforces the schema.\"",
      "date_published": "2026-08-08T00:00:00.000Z",
      "tags": [
        "nosql",
        "database",
        "architecture",
        "opinion",
        "Journal"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/journal/is-postgresql-enough-for-everything/",
      "url": "https://sade.dev/en/journal/is-postgresql-enough-for-everything/",
      "title": "Is PostgreSQL Enough for Everything?",
      "summary": "Most of the list that puts Elasticsearch, MongoDB and RabbitMQ next to PostgreSQL is carried by a single PostgreSQL for a long time: tsvector with a GIN index for search, jsonb for documents, SELECT FOR UPDATE SKIP LOCKED for a moderate-volume queue. The limits are honest and real, but a second data system should arrive on a measured limit, not on a hunch or a blog post.",
      "content_html": "<p>An architecture plan, drawn up before there was a single user, carried this list: PostgreSQL for durable data, Elasticsearch for search, Redis for cache, MongoDB for flexible documents, RabbitMQ for queueing. Five separate data systems — each with its own backups, monitoring, version upgrades, and distinct failure mode.</p>\n<p>Most of that list, for a long time, a single PostgreSQL carries on its own. The question isn’t “can PostgreSQL do this”; it’s “did you really buy the operational burden of these five systems”.</p>\n<h2 id=\"postgresqls-little-known-breadth\">PostgreSQL’s little-known breadth</h2>\n<p>Treating PostgreSQL as just a table-row store uses a small fraction of what it can do. Most of the needs that prompt a separate system are already inside it:</p>\n<ul>\n<li><strong>Full-text search.</strong> A real search built on <code>tsvector</code>, <code>tsquery</code>, and a GIN index. It covers most apps’ “search products” and “search posts” needs, including stemming, weighting, and ranking.</li>\n<li><strong>Document storage.</strong> A <code>jsonb</code> column, paired with a GIN index, is a queryable document store. You can keep the fields that want schema flexibility right inside the relational table.</li>\n<li><strong>Queues.</strong> <code>SELECT ... FOR UPDATE SKIP LOCKED</code> and <code>LISTEN/NOTIFY</code> run a moderate-volume job queue without a separate broker. Laravel’s <code>database</code> queue driver uses the <code>SKIP LOCKED</code> half of it — its workers poll the jobs table at intervals rather than waiting on a <code>LISTEN/NOTIFY</code> signal.</li>\n<li><strong>Analytical queries.</strong> Window functions, CTEs, materialized views — serious reporting without reaching for a separate analytical database.</li>\n<li><strong>Geospatial data.</strong> Location queries via the PostGIS extension.</li>\n</ul>\n<p>These features aren’t “present but unused”; they’re mature capabilities that run reliably in production.</p>\n<h2 id=\"the-quiet-payoff-of-a-single-system\">The quiet payoff of a single system</h2>\n<p>Every new data system isn’t just a box; it’s a maintenance commitment. What you gain by staying on one system:</p>\n<ul>\n<li><strong>One backup line.</strong> One backup/restore procedure, one recovery drill.</li>\n<li><strong>One monitoring target.</strong> One set of metrics to learn, one alerting setup to build.</li>\n<li><strong>Cross-consistency.</strong> This is the most important one. If your search index lives in the same database as your data, there <strong>can be no drift</strong> between them — both update in the same transaction. A separate Elasticsearch will eventually fall out of sync with the data, and fixing that becomes its own line item.</li>\n<li><strong>One mental model.</strong> The team learns the quirks of one system, not five.</li>\n</ul>\n<p>This is the data-layer version of the innovation-token logic from the <a href=\"/en/journal/why-boring-architecture/\">boring architecture</a> piece: every new system is a token, and tokens are limited.</p>\n<h2 id=\"where-does-the-limit-begin\">Where does the limit begin?</h2>\n<p>PostgreSQL isn’t enough for everything — it has honest limits, and ignoring them is also a mistake:</p>\n<ul>\n<li><strong>Search.</strong> When typo tolerance, advanced relevance tuning, faceted search, and multilingual analysis are needed at serious scale, a dedicated search engine genuinely pays off.</li>\n<li><strong>Queues.</strong> When you need very high throughput, complex routing, or multi-consumer fan-out, a real message broker is the right tool. A PostgreSQL queue is comfortable at moderate volume, not at the extremes.</li>\n<li><strong>Write throughput.</strong> Sustained writes beyond a single primary’s fsync capacity — this is the hardest breaking point in <a href=\"/en/systems/data-intensive-systems-breaking-points/\">data-intensive systems</a>, and a real limit.</li>\n</ul>\n<p>When you hit one of these limits, bringing in an extra system is the right call. Bringing it in before you hit it is just a guess.</p>\n<h2 id=\"the-decision-postgresql-first-then-measure\">The decision: PostgreSQL first, then measure</h2>\n<p>The rule is plain: in a new project, make PostgreSQL do everything PostgreSQL can do. Let it be the first stop for search, queue, and cache-like needs. A separate system should arrive only when a <strong>measured</strong> limit is crossed — not from a hunch, a blog post, or résumé anxiety.</p>\n<p>Most teams use maybe 20% of PostgreSQL, then say “it’s not enough”. What’s not enough is usually not PostgreSQL, but the effort to get to know it.</p>\n<hr/>\n<p>PostgreSQL isn’t enough for everything — but it’s enough for far more than you think. Before you bring in a second data system, ask whether you’ve truly reached the end of the first.</p>\n<p>PostgreSQL first; let the rest follow from measurement.</p>",
      "content_text": "An architecture plan, drawn up before there was a single user, carried this list: PostgreSQL for durable data, Elasticsearch for search, Redis for cache, MongoDB for flexible documents, RabbitMQ for queueing. Five separate data systems — each with its own backups, monitoring, version upgrades, and distinct failure mode.\n\nMost of that list, for a long time, a single PostgreSQL carries on its own. The question isn't \"can PostgreSQL do this\"; it's \"did you really buy the operational burden of these five systems\".\n\n## PostgreSQL's little-known breadth\n\nTreating PostgreSQL as just a table-row store uses a small fraction of what it can do. Most of the needs that prompt a separate system are already inside it:\n\n- **Full-text search.** A real search built on `tsvector`, `tsquery`, and a GIN index. It covers most apps' \"search products\" and \"search posts\" needs, including stemming, weighting, and ranking.\n- **Document storage.** A `jsonb` column, paired with a GIN index, is a queryable document store. You can keep the fields that want schema flexibility right inside the relational table.\n- **Queues.** `SELECT ... FOR UPDATE SKIP LOCKED` and `LISTEN/NOTIFY` run a moderate-volume job queue without a separate broker. Laravel's `database` queue driver uses the `SKIP LOCKED` half of it — its workers poll the jobs table at intervals rather than waiting on a `LISTEN/NOTIFY` signal.\n- **Analytical queries.** Window functions, CTEs, materialized views — serious reporting without reaching for a separate analytical database.\n- **Geospatial data.** Location queries via the PostGIS extension.\n\nThese features aren't \"present but unused\"; they're mature capabilities that run reliably in production.\n\n## The quiet payoff of a single system\n\nEvery new data system isn't just a box; it's a maintenance commitment. What you gain by staying on one system:\n\n- **One backup line.** One backup/restore procedure, one recovery drill.\n- **One monitoring target.** One set of metrics to learn, one alerting setup to build.\n- **Cross-consistency.** This is the most important one. If your search index lives in the same database as your data, there **can be no drift** between them — both update in the same transaction. A separate Elasticsearch will eventually fall out of sync with the data, and fixing that becomes its own line item.\n- **One mental model.** The team learns the quirks of one system, not five.\n\nThis is the data-layer version of the innovation-token logic from the [boring architecture](/en/journal/why-boring-architecture) piece: every new system is a token, and tokens are limited.\n\n## Where does the limit begin?\n\nPostgreSQL isn't enough for everything — it has honest limits, and ignoring them is also a mistake:\n\n- **Search.** When typo tolerance, advanced relevance tuning, faceted search, and multilingual analysis are needed at serious scale, a dedicated search engine genuinely pays off.\n- **Queues.** When you need very high throughput, complex routing, or multi-consumer fan-out, a real message broker is the right tool. A PostgreSQL queue is comfortable at moderate volume, not at the extremes.\n- **Write throughput.** Sustained writes beyond a single primary's fsync capacity — this is the hardest breaking point in [data-intensive systems](/en/systems/data-intensive-systems-breaking-points), and a real limit.\n\nWhen you hit one of these limits, bringing in an extra system is the right call. Bringing it in before you hit it is just a guess.\n\n## The decision: PostgreSQL first, then measure\n\nThe rule is plain: in a new project, make PostgreSQL do everything PostgreSQL can do. Let it be the first stop for search, queue, and cache-like needs. A separate system should arrive only when a **measured** limit is crossed — not from a hunch, a blog post, or résumé anxiety.\n\nMost teams use maybe 20% of PostgreSQL, then say \"it's not enough\". What's not enough is usually not PostgreSQL, but the effort to get to know it.\n\n---\n\nPostgreSQL isn't enough for everything — but it's enough for far more than you think. Before you bring in a second data system, ask whether you've truly reached the end of the first.\n\nPostgreSQL first; let the rest follow from measurement.",
      "date_published": "2026-08-01T00:00:00.000Z",
      "tags": [
        "postgresql",
        "database",
        "architecture",
        "opinion",
        "Journal"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/journal/signals-of-an-over-complex-system/",
      "url": "https://sade.dev/en/journal/signals-of-an-over-complex-system/",
      "title": "Signals That a System Has Grown Too Complex",
      "summary": "Complexity does not arrive in one decision; it accumulates in defensible steps, which is why simplification starts from named signals rather than a feeling. Eight concrete signals, then a four-step subtraction: dead code, single-implementation interfaces, premature indirection, then clear boundaries. The target is not the least code but the least unnecessary code — essential complexity cannot be deleted.",
      "content_html": "<p>At the end of her first week, an engineer who had just joined the team asked: “Why does adding a single field take two days?” The question was innocent; the diagnosis was clear. Nobody had sat down one day and decided to make that system complex. The complexity had simply accumulated.</p>\n<p>That is the heart of it: complexity doesn’t arrive in a single decision, it builds up unnoticed. To undo it, you first have to be able to <strong>see</strong> it.</p>\n<h2 id=\"how-does-complexity-accumulate\">How does complexity accumulate?</h2>\n<p>No complex system decides to be complex. Each step looks reasonable on its own: “let’s open an interface for now”, “let’s put a flag on this case”, “one more layer in between”. Each is small, each is defensible.</p>\n<p>Their sum is not defensible. Complexity is not an event but an accumulation — and an accumulation stays invisible until it is named. That is why simplification cannot start from a feeling; it starts from concrete signals.</p>\n<h2 id=\"the-concrete-signals\">The concrete signals</h2>\n<p>The red flags, not open to debate, that tell you a system is more complex than it needs to be:</p>\n<ul>\n<li><strong>You open a lot of files to understand a single feature.</strong> If seeing where a request goes takes six files and four levels of indirection, the problem isn’t you, it’s the structure.</li>\n<li><strong>A one-line change touches a dozen files.</strong> To add one field: migration, model, DTO, mapper, interface, factory, test fixture… The cost of a change is not proportional to the size of the change.</li>\n<li><strong>Interfaces with a single implementation.</strong> Abstractions opened “just in case” whose second implementation never arrived. This is the residue <a href=\"/en/journal/the-cost-of-just-in-case-code/\">speculative generality</a> leaves in a codebase.</li>\n<li><strong>Onboarding takes weeks.</strong> In a mature, lean system a new engineer is productive in the first days. If it takes weeks, the code isn’t explaining itself.</li>\n<li><strong>Nobody can draw the system on a whiteboard.</strong> An architecture that can’t be described as “this box connects to that one” is an architecture that isn’t understood.</li>\n<li><strong>The test setup is longer than the test itself.</strong> The ten mocks you need to stand up to test one behavior are a confession that the behavior depends on far too much.</li>\n<li><strong>“Don’t touch that, it’ll break.”</strong> If a region of code has been declared untouchable, that region is already broken — it just hasn’t blown up yet.</li>\n<li><strong>Config, flags, and options nobody uses.</strong> Every option that was opened and forgotten is a branch that everyone reading the code has to account for.</li>\n</ul>\n<p>If two or three of these signals are present in a system, the debate isn’t “is it complex” but “where do we start”.</p>\n<h2 id=\"where-do-you-start-simplifying\">Where do you start simplifying?</h2>\n<p>Simplification is not a rewrite; most of the time it’s a <strong>subtraction</strong>. In order:</p>\n<ol>\n<li><strong>Delete dead code.</strong> Unused flags, methods never called, branches never reached. The lowest-risk, highest-return step — deleted code has no bugs.</li>\n<li><strong>Collapse single-implementation interfaces.</strong> Use the concrete class directly until the abstraction earns a real second use. When a seam is genuinely needed — <a href=\"/en/journal/the-cost-of-just-in-case-code/\">once the need is proven</a> — it comes back in a few hours of refactoring.</li>\n<li><strong>Inline premature indirection.</strong> Remove the layers that are called from a single place and only pass an argument on to another function.</li>\n<li><strong>Clarify boundaries.</strong> Gather the remaining complexity behind clear boundaries, like in a <a href=\"/en/journal/why-i-start-with-a-modular-monolith/\">modular monolith</a> — so the next accumulation shows up early.</li>\n</ol>\n<p>After each step, stop and measure. Simplification can also go too far; the goal is not “the least code” but “the least unnecessary code”.</p>\n<h2 id=\"complexity-isnt-always-bad\">Complexity isn’t always bad</h2>\n<p>An important distinction: there are two kinds of complexity. In Fred Brooks’s terms — <strong>essential</strong> and <strong>accidental</strong>.</p>\n<p>Essential complexity comes from the domain itself. A tax calculation is complex because tax law is complex; you can’t delete that, you can only model it honestly. Accidental complexity is what you add: the unnecessary layer, the premature abstraction, the forgotten flag.</p>\n<p>The aim of simplification is to clear away the accidental — not the essential. Making a system “too simple” and ignoring the domain’s real complexity is also a mistake; that complexity doesn’t get deleted, it just moves to the wrong place — usually into the calling code.</p>\n<p>Good architecture is exactly as complex as the domain is; no more.</p>\n<hr/>\n<p>Complexity accumulates unnoticed; that is why knowing by heart the signals that make it noticeable is an engineering skill. If you can’t name the signal, you can’t start simplifying either.</p>\n<p>Keeping a system simple takes more effort than building it simple — and that effort is worth it.</p>",
      "content_text": "At the end of her first week, an engineer who had just joined the team asked: \"Why does adding a single field take two days?\" The question was innocent; the diagnosis was clear. Nobody had sat down one day and decided to make that system complex. The complexity had simply accumulated.\n\nThat is the heart of it: complexity doesn't arrive in a single decision, it builds up unnoticed. To undo it, you first have to be able to **see** it.\n\n## How does complexity accumulate?\n\nNo complex system decides to be complex. Each step looks reasonable on its own: \"let's open an interface for now\", \"let's put a flag on this case\", \"one more layer in between\". Each is small, each is defensible.\n\nTheir sum is not defensible. Complexity is not an event but an accumulation — and an accumulation stays invisible until it is named. That is why simplification cannot start from a feeling; it starts from concrete signals.\n\n## The concrete signals\n\nThe red flags, not open to debate, that tell you a system is more complex than it needs to be:\n\n- **You open a lot of files to understand a single feature.** If seeing where a request goes takes six files and four levels of indirection, the problem isn't you, it's the structure.\n- **A one-line change touches a dozen files.** To add one field: migration, model, DTO, mapper, interface, factory, test fixture... The cost of a change is not proportional to the size of the change.\n- **Interfaces with a single implementation.** Abstractions opened \"just in case\" whose second implementation never arrived. This is the residue [speculative generality](/en/journal/the-cost-of-just-in-case-code) leaves in a codebase.\n- **Onboarding takes weeks.** In a mature, lean system a new engineer is productive in the first days. If it takes weeks, the code isn't explaining itself.\n- **Nobody can draw the system on a whiteboard.** An architecture that can't be described as \"this box connects to that one\" is an architecture that isn't understood.\n- **The test setup is longer than the test itself.** The ten mocks you need to stand up to test one behavior are a confession that the behavior depends on far too much.\n- **\"Don't touch that, it'll break.\"** If a region of code has been declared untouchable, that region is already broken — it just hasn't blown up yet.\n- **Config, flags, and options nobody uses.** Every option that was opened and forgotten is a branch that everyone reading the code has to account for.\n\nIf two or three of these signals are present in a system, the debate isn't \"is it complex\" but \"where do we start\".\n\n## Where do you start simplifying?\n\nSimplification is not a rewrite; most of the time it's a **subtraction**. In order:\n\n1. **Delete dead code.** Unused flags, methods never called, branches never reached. The lowest-risk, highest-return step — deleted code has no bugs.\n2. **Collapse single-implementation interfaces.** Use the concrete class directly until the abstraction earns a real second use. When a seam is genuinely needed — [once the need is proven](/en/journal/the-cost-of-just-in-case-code) — it comes back in a few hours of refactoring.\n3. **Inline premature indirection.** Remove the layers that are called from a single place and only pass an argument on to another function.\n4. **Clarify boundaries.** Gather the remaining complexity behind clear boundaries, like in a [modular monolith](/en/journal/why-i-start-with-a-modular-monolith) — so the next accumulation shows up early.\n\nAfter each step, stop and measure. Simplification can also go too far; the goal is not \"the least code\" but \"the least unnecessary code\".\n\n## Complexity isn't always bad\n\nAn important distinction: there are two kinds of complexity. In Fred Brooks's terms — **essential** and **accidental**.\n\nEssential complexity comes from the domain itself. A tax calculation is complex because tax law is complex; you can't delete that, you can only model it honestly. Accidental complexity is what you add: the unnecessary layer, the premature abstraction, the forgotten flag.\n\nThe aim of simplification is to clear away the accidental — not the essential. Making a system \"too simple\" and ignoring the domain's real complexity is also a mistake; that complexity doesn't get deleted, it just moves to the wrong place — usually into the calling code.\n\nGood architecture is exactly as complex as the domain is; no more.\n\n---\n\nComplexity accumulates unnoticed; that is why knowing by heart the signals that make it noticeable is an engineering skill. If you can't name the signal, you can't start simplifying either.\n\nKeeping a system simple takes more effort than building it simple — and that effort is worth it.",
      "date_published": "2026-07-25T00:00:00.000Z",
      "tags": [
        "architecture",
        "simplicity",
        "decisions",
        "opinion",
        "Journal"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/notes/circuit-breaker-external-service-integration/",
      "url": "https://sade.dev/en/notes/circuit-breaker-external-service-integration/",
      "title": "Circuit Breakers for External Service Integrations",
      "summary": "A slow external service takes an application down faster than a crashed one: the call holds the process, the pool fills, and unrelated endpoints start timing out. Three defenses, in order — a short explicit timeout on every call, retry only with exponential backoff and jitter, and a circuit breaker that stops calling at all. Its value is decided by the fallback you serve while the circuit is open.",
      "content_html": "<p>An SMS provider slowed down — it didn’t even crash, its response time just climbed to 30 seconds. Within half an hour the entire application became unresponsive; even endpoints with nothing to do with SMS were timing out. One slow external service had dragged the whole system down with it.</p>\n<p>An external service will fail sooner or later. That’s not the question. The question is: will your system go down with it?</p>\n<h2 id=\"how-does-cascading-failure-happen\">How does cascading failure happen?</h2>\n<p>The logic is simple and merciless. A slow external call locks up the worker that makes it — or the PHP-FPM process. If the call takes 30 seconds, that process can’t look at any other request for 30 seconds.</p>\n<p>Requests pile up, the process pool fills. Once the pool is full, new requests — including ones with nothing to do with SMS — wait too. Your healthy endpoints die because of one sick dependency. This is called <strong>cascading failure</strong>, and it almost always starts with a slowdown.</p>\n<h2 id=\"first-defense-timeout\">First defense: timeout</h2>\n<p>The first and cheapest defense is to give every external call an <strong>explicit and short</strong> timeout. Default timeouts — 30 seconds or more in most HTTP clients — are unacceptable for production.</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"php\"><code><span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">$</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">response</span><span style=\"color:#999999;--shiki-dark:#666666\"> =</span><span style=\"color:#998418;--shiki-dark:#B8A965\"> Http</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">::</span><span style=\"color:#59873A;--shiki-dark:#80A665\">timeout</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\">5</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">        // total time</span></span>\n<span class=\"line\"><span style=\"color:#AB5959;--shiki-dark:#CB7676\">    -&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">connectTimeout</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\">2</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">             // connection establishment time</span></span>\n<span class=\"line\"><span style=\"color:#AB5959;--shiki-dark:#CB7676\">    -&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">get</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">https://sms-provider.example/send</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#999999;--shiki-dark:#666666\">);</span></span></code></pre>\n<p>The rule from the <a href=\"/en/notes/laravel-queue-production-slowdown/\">Laravel queue post</a> applies here too: not the default 30 seconds, but 5. Don’t leave to chance how long a process waits on a dependency — that number should be a decision.</p>\n<h2 id=\"second-defense-retry--but-carefully\">Second defense: retry — but carefully</h2>\n<p>Retry makes sense for transient errors, but blind retry does harm. Immediately retrying a failing service piles more load onto it — this is called a <strong>retry storm</strong>; it knocks the service back down before it can fully recover.</p>\n<p>If you retry, two things are mandatory: exponential backoff (increasing wait on each attempt) and jitter (random offset — so all clients don’t retry at the same moment).</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"php\"><code><span class=\"line\"><span style=\"color:#998418;--shiki-dark:#B8A965\">Http</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">::</span><span style=\"color:#59873A;--shiki-dark:#80A665\">retry</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\">3</span><span style=\"color:#999999;--shiki-dark:#666666\">,</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> function</span><span style=\"color:#999999;--shiki-dark:#666666\"> (</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">int</span><span style=\"color:#999999;--shiki-dark:#666666\"> $</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">attempt</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#999999;--shiki-dark:#666666\"> {</span></span>\n<span class=\"line\"><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">    // exponential backoff (200, 400, 800 ms) plus jitter</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">    return</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\"> 200</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> *</span><span style=\"color:#999999;--shiki-dark:#666666\"> (</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\">2</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> **</span><span style=\"color:#999999;--shiki-dark:#666666\"> ($</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">attempt</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> -</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\"> 1</span><span style=\"color:#999999;--shiki-dark:#666666\">))</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> +</span><span style=\"color:#59873A;--shiki-dark:#80A665\"> random_int</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\">0</span><span style=\"color:#999999;--shiki-dark:#666666\">,</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\"> 100</span><span style=\"color:#999999;--shiki-dark:#666666\">);</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">},</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> function</span><span style=\"color:#999999;--shiki-dark:#666666\"> ($</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">exception</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#999999;--shiki-dark:#666666\"> {</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">    return</span><span style=\"color:#999999;--shiki-dark:#666666\"> $</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">exception</span><span style=\"color:#999999;--shiki-dark:#666666\"> instanceof</span><span style=\"color:#998418;--shiki-dark:#B8A965\"> ConnectionException</span><span style=\"color:#999999;--shiki-dark:#666666\">;</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">})</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">timeout</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\">5</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">get</span><span style=\"color:#999999;--shiki-dark:#666666\">($</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">url</span><span style=\"color:#999999;--shiki-dark:#666666\">);</span></span></code></pre>\n<p>Keep the retry count low. If there’s still an error after three attempts, the problem isn’t transient; continuing to retry only ties up your own process for longer.</p>\n<h2 id=\"third-defense-circuit-breaker\">Third defense: circuit breaker</h2>\n<p>A timeout caps a single call; a circuit breaker decides to <strong>stop making calls</strong> at all. Its logic comes from an electrical circuit breaker, and it has three states:</p>\n<ul>\n<li><strong>Closed</strong> — everything’s normal, calls pass through. Failures are counted.</li>\n<li><strong>Open</strong> — most of the last N calls failed; the circuit opens. The external service is no longer hit at all, and the call returns an error <strong>instantly</strong>.</li>\n<li><strong>Half-open</strong> — after a while the circuit allows a single trial call. If it succeeds, it returns to <code>closed</code>; if not, back to <code>open</code>.</li>\n</ul>\n<p>The gain: when the external service is sick, you don’t wait on it. Even a 5-second timeout is 5 seconds per process; in the <code>open</code> state that drops to 0. Fail-fast beats slow-fail.</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"php\"><code><span class=\"line\"><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">// Conceptual flow — leave the threshold, counter, and timer to a mature package.</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">if</span><span style=\"color:#999999;--shiki-dark:#666666\"> ($</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">breaker</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">isOpen</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">sms</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#999999;--shiki-dark:#666666\">))</span><span style=\"color:#999999;--shiki-dark:#666666\"> {</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">    return</span><span style=\"color:#999999;--shiki-dark:#666666\"> $</span><span style=\"color:#A65E2B;--shiki-dark:#C99076\">this</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">queueForLater</span><span style=\"color:#999999;--shiki-dark:#666666\">($</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">message</span><span style=\"color:#999999;--shiki-dark:#666666\">);</span><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">   // circuit open: no attempt at all</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">try</span><span style=\"color:#999999;--shiki-dark:#666666\"> {</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">    $</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">response</span><span style=\"color:#999999;--shiki-dark:#666666\"> =</span><span style=\"color:#998418;--shiki-dark:#B8A965\"> Http</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">::</span><span style=\"color:#59873A;--shiki-dark:#80A665\">timeout</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\">5</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">post</span><span style=\"color:#999999;--shiki-dark:#666666\">($</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">smsUrl</span><span style=\"color:#999999;--shiki-dark:#666666\">,</span><span style=\"color:#999999;--shiki-dark:#666666\"> $</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">payload</span><span style=\"color:#999999;--shiki-dark:#666666\">);</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">    $</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">breaker</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">recordSuccess</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">sms</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#999999;--shiki-dark:#666666\">);</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">}</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> catch</span><span style=\"color:#999999;--shiki-dark:#666666\"> (\\</span><span style=\"color:#998418;--shiki-dark:#B8A965\">Throwable</span><span style=\"color:#999999;--shiki-dark:#666666\"> $</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">e</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#999999;--shiki-dark:#666666\"> {</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">    $</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">breaker</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">recordFailure</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">sms</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#999999;--shiki-dark:#666666\">);</span><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">          // circuit opens if the threshold is exceeded</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">    return</span><span style=\"color:#999999;--shiki-dark:#666666\"> $</span><span style=\"color:#A65E2B;--shiki-dark:#C99076\">this</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">queueForLater</span><span style=\"color:#999999;--shiki-dark:#666666\">($</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">message</span><span style=\"color:#999999;--shiki-dark:#666666\">);</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">}</span></span></code></pre>\n<p>Instead of writing this logic from scratch, use a mature package — getting the state counting, the threshold, and the timer right is subtler than it looks.</p>\n<h2 id=\"fallback-what-do-you-do-when-the-circuit-is-open\">Fallback: what do you do when the circuit is open?</h2>\n<p>A circuit breaker’s value shows up in what you do in the <code>open</code> state. “Return an error” is the weakest option. Better ones:</p>\n<ul>\n<li><strong>Queue it, send it later.</strong> If the SMS doesn’t have to go out immediately, drop it on a <a href=\"/en/notes/sync-vs-async/\">queue</a> to be processed once the circuit closes.</li>\n<li><strong>Serve stale but valid data.</strong> If an exchange-rate service is down, showing the last known rate beats showing none.</li>\n<li><strong>Degrade the feature gracefully.</strong> A “Recommendations can’t load right now” message beats crashing the whole page.</li>\n</ul>\n<p>Which fallback is right depends on the business — but “no fallback” is not an answer.</p>\n<h2 id=\"when-dont-you-need-all-this\">When don’t you need all this?</h2>\n<p>Not every external call wants a circuit breaker. A timeout is <strong>always</strong> mandatory. Retry, only if the operation is idempotent. A circuit breaker mainly adds value when the call is frequent and the service is on the user’s path — for an integration called a few times a day, a timeout and a reasonable retry are usually enough.</p>\n<hr/>\n<p>An external service failing isn’t a question of “if,” it’s a question of “when.” Going down with it, though, is a design choice — and a choice you can change.</p>\n<p>Don’t leave your system as fragile as your weakest dependency.</p>",
      "content_text": "An SMS provider slowed down — it didn't even crash, its response time just climbed to 30 seconds. Within half an hour the entire application became unresponsive; even endpoints with nothing to do with SMS were timing out. One slow external service had dragged the whole system down with it.\n\nAn external service will fail sooner or later. That's not the question. The question is: will your system go down with it?\n\n## How does cascading failure happen?\n\nThe logic is simple and merciless. A slow external call locks up the worker that makes it — or the PHP-FPM process. If the call takes 30 seconds, that process can't look at any other request for 30 seconds.\n\nRequests pile up, the process pool fills. Once the pool is full, new requests — including ones with nothing to do with SMS — wait too. Your healthy endpoints die because of one sick dependency. This is called **cascading failure**, and it almost always starts with a slowdown.\n\n## First defense: timeout\n\nThe first and cheapest defense is to give every external call an **explicit and short** timeout. Default timeouts — 30 seconds or more in most HTTP clients — are unacceptable for production.\n\n```php\n$response = Http::timeout(5)        // total time\n    ->connectTimeout(2)             // connection establishment time\n    ->get('https://sms-provider.example/send');\n```\n\nThe rule from the [Laravel queue post](/en/notes/laravel-queue-production-slowdown) applies here too: not the default 30 seconds, but 5. Don't leave to chance how long a process waits on a dependency — that number should be a decision.\n\n## Second defense: retry — but carefully\n\nRetry makes sense for transient errors, but blind retry does harm. Immediately retrying a failing service piles more load onto it — this is called a **retry storm**; it knocks the service back down before it can fully recover.\n\nIf you retry, two things are mandatory: exponential backoff (increasing wait on each attempt) and jitter (random offset — so all clients don't retry at the same moment).\n\n```php\nHttp::retry(3, function (int $attempt) {\n    // exponential backoff (200, 400, 800 ms) plus jitter\n    return 200 * (2 ** ($attempt - 1)) + random_int(0, 100);\n}, function ($exception) {\n    return $exception instanceof ConnectionException;\n})->timeout(5)->get($url);\n```\n\nKeep the retry count low. If there's still an error after three attempts, the problem isn't transient; continuing to retry only ties up your own process for longer.\n\n## Third defense: circuit breaker\n\nA timeout caps a single call; a circuit breaker decides to **stop making calls** at all. Its logic comes from an electrical circuit breaker, and it has three states:\n\n- **Closed** — everything's normal, calls pass through. Failures are counted.\n- **Open** — most of the last N calls failed; the circuit opens. The external service is no longer hit at all, and the call returns an error **instantly**.\n- **Half-open** — after a while the circuit allows a single trial call. If it succeeds, it returns to `closed`; if not, back to `open`.\n\nThe gain: when the external service is sick, you don't wait on it. Even a 5-second timeout is 5 seconds per process; in the `open` state that drops to 0. Fail-fast beats slow-fail.\n\n```php\n// Conceptual flow — leave the threshold, counter, and timer to a mature package.\nif ($breaker->isOpen('sms')) {\n    return $this->queueForLater($message);   // circuit open: no attempt at all\n}\n\ntry {\n    $response = Http::timeout(5)->post($smsUrl, $payload);\n    $breaker->recordSuccess('sms');\n} catch (\\Throwable $e) {\n    $breaker->recordFailure('sms');          // circuit opens if the threshold is exceeded\n    return $this->queueForLater($message);\n}\n```\n\nInstead of writing this logic from scratch, use a mature package — getting the state counting, the threshold, and the timer right is subtler than it looks.\n\n## Fallback: what do you do when the circuit is open?\n\nA circuit breaker's value shows up in what you do in the `open` state. \"Return an error\" is the weakest option. Better ones:\n\n- **Queue it, send it later.** If the SMS doesn't have to go out immediately, drop it on a [queue](/en/notes/sync-vs-async) to be processed once the circuit closes.\n- **Serve stale but valid data.** If an exchange-rate service is down, showing the last known rate beats showing none.\n- **Degrade the feature gracefully.** A \"Recommendations can't load right now\" message beats crashing the whole page.\n\nWhich fallback is right depends on the business — but \"no fallback\" is not an answer.\n\n## When don't you need all this?\n\nNot every external call wants a circuit breaker. A timeout is **always** mandatory. Retry, only if the operation is idempotent. A circuit breaker mainly adds value when the call is frequent and the service is on the user's path — for an integration called a few times a day, a timeout and a reasonable retry are usually enough.\n\n---\n\nAn external service failing isn't a question of \"if,\" it's a question of \"when.\" Going down with it, though, is a design choice — and a choice you can change.\n\nDon't leave your system as fragile as your weakest dependency.",
      "date_published": "2026-07-18T00:00:00.000Z",
      "tags": [
        "resilience",
        "architecture",
        "api",
        "production",
        "Note"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/notes/serverless-migration-decision/",
      "url": "https://sade.dev/en/notes/serverless-migration-decision/",
      "title": "The Serverless Decision: Cold-Start and Vendor Lock-in",
      "summary": "Serverless is not an architectural fashion, it is a tool with a workload profile: variable, sparse, bursty. Steady traffic turns pay-per-use into pay-per-second, and on a low-traffic path a cold start is the rule rather than the exception, so the user pays it on nearly every request. The bond is not the runtime but the glue around it — event sources, IAM, gateway — which makes leaving a rewrite, not a migration.",
      "content_html": "<p>A team built its new service on AWS Lambda with a “serverless first” decision. Yet the traffic was steady and predictable — roughly the same all day. At the end of the month the bill came in at several times what a single VPS doing the same work would cost; on top of that, p95 latency was worse.</p>\n<p>Serverless isn’t the wrong technology. It was the right technology applied to the wrong workload.</p>\n<h2 id=\"what-serverless-buys-you\">What serverless buys you</h2>\n<p>FaaS (Function as a Service) has real upsides:</p>\n<ul>\n<li><strong>No server management.</strong> Patching, scaling, capacity planning all sit with the provider.</li>\n<li><strong>Scale to zero.</strong> No requests means nothing running and nothing paid for.</li>\n<li><strong>Pay per use.</strong> You don’t pay for idle capacity.</li>\n</ul>\n<p>These three genuinely pay off on the right workload. The trick is diagnosing that workload correctly.</p>\n<h2 id=\"the-real-cost-of-cold-start\">The real cost of cold-start</h2>\n<p>If a function hasn’t been called for a while, the provider brings it up from scratch: the runtime loads, dependencies initialize, the code runs. This <strong>cold-start</strong> adds anywhere from under 100 ms to seconds of latency compared to an already-running process.</p>\n<p>The key distinction: for a low-traffic endpoint, cold-start is <strong>the rule, not the exception</strong>. On a function that gets a few calls an hour, nearly every request is cold. “Pay by scale” sounds appealing, but at near-zero traffic what you pay is the latency the user sees every single time. On a latency-sensitive user path, that’s a direct UX cost.</p>\n<h2 id=\"vendor-lock-in\">Vendor lock-in</h2>\n<p>Lock-in isn’t just the runtime; the real bond is in the glue around it. Over time a serverless application also couples to the provider’s event sources, identity/authorization model (IAM), managed queue, API gateway, and deployment tooling.</p>\n<p>Moving a single function is easy. But in a real system, “getting off serverless” is usually not a migration, it’s a rewrite. This ties directly into the innovation-token logic from the <a href=\"/en/journal/why-boring-architecture/\">boring architecture</a> piece: what looks like a single decision is actually a bond that’s expensive to undo.</p>\n<h2 id=\"which-workloads-win\">Which workloads win?</h2>\n<p>Serverless shines where the load is <strong>variable and sparse</strong>:</p>\n<ul>\n<li>Scheduled jobs — nightly reports, cron-like tasks.</li>\n<li>Webhook receivers — uncertain when and how often the calls will arrive.</li>\n<li>Low-volume internal tools — panels used a few times a day.</li>\n<li>Bursty batch jobs — once an hour, briefly high parallelism.</li>\n</ul>\n<p>On these workloads, scale-to-zero is real money saved, and cold-start is usually irrelevant — nobody cares that a nightly report starts 800 ms late.</p>\n<h2 id=\"which-workloads-lose\">Which workloads lose?</h2>\n<p>The reverse is just as clear:</p>\n<ul>\n<li><strong>Steady, continuous traffic.</strong> On an always-running API, “pay per use” turns into “pay every second” — and that’s usually more expensive than a continuously running server.</li>\n<li><strong>Latency-sensitive paths.</strong> Cold-start is unacceptable on a request the user is waiting on.</li>\n<li><strong>Long-running jobs.</strong> FaaS has execution-time limits; long jobs either get split up or don’t fit.</li>\n</ul>\n<p>For a workload with this profile, a boring VPS — like the setup in the <a href=\"/en/systems/single-vps-multi-project-architecture/\">multiple projects on a single VPS</a> piece — is both cheaper and more predictable. The cost of a steady load matches a steady server.</p>\n<h2 id=\"laravel-and-serverless\">Laravel and serverless</h2>\n<p>On the Laravel side, Laravel Vapor makes it possible to run the application on Lambda, and it works well. But that it works doesn’t mean it’s the right decision: with Vapor you also inherit cold-start and the AWS bond.</p>\n<p>The decision is, again, the workload. For a Laravel app with bursty, sparse, or unpredictable traffic, Vapor makes sense. For a continuous, latency-sensitive app, a classic server is still the plainer, cheaper answer.</p>\n<h2 id=\"mix-them\">Mix them</h2>\n<p>This isn’t a binary choice. A boring server carrying the continuous load + handing only the sparse/bursty work (webhooks, the nightly report) to serverless is often the most honest split. Put every workload where its own cost profile belongs.</p>\n<hr/>\n<p>Serverless isn’t an architectural fashion, it’s a tool for a specific workload profile. If you have that profile, it pays off; if you don’t, it’s paid back as cold-start and lock-in.</p>\n<p>Measure your load first; the tool comes after.</p>",
      "content_text": "A team built its new service on AWS Lambda with a \"serverless first\" decision. Yet the traffic was steady and predictable — roughly the same all day. At the end of the month the bill came in at several times what a single VPS doing the same work would cost; on top of that, p95 latency was worse.\n\nServerless isn't the wrong technology. It was the right technology applied to the wrong workload.\n\n## What serverless buys you\n\nFaaS (Function as a Service) has real upsides:\n\n- **No server management.** Patching, scaling, capacity planning all sit with the provider.\n- **Scale to zero.** No requests means nothing running and nothing paid for.\n- **Pay per use.** You don't pay for idle capacity.\n\nThese three genuinely pay off on the right workload. The trick is diagnosing that workload correctly.\n\n## The real cost of cold-start\n\nIf a function hasn't been called for a while, the provider brings it up from scratch: the runtime loads, dependencies initialize, the code runs. This **cold-start** adds anywhere from under 100 ms to seconds of latency compared to an already-running process.\n\nThe key distinction: for a low-traffic endpoint, cold-start is **the rule, not the exception**. On a function that gets a few calls an hour, nearly every request is cold. \"Pay by scale\" sounds appealing, but at near-zero traffic what you pay is the latency the user sees every single time. On a latency-sensitive user path, that's a direct UX cost.\n\n## Vendor lock-in\n\nLock-in isn't just the runtime; the real bond is in the glue around it. Over time a serverless application also couples to the provider's event sources, identity/authorization model (IAM), managed queue, API gateway, and deployment tooling.\n\nMoving a single function is easy. But in a real system, \"getting off serverless\" is usually not a migration, it's a rewrite. This ties directly into the innovation-token logic from the [boring architecture](/en/journal/why-boring-architecture) piece: what looks like a single decision is actually a bond that's expensive to undo.\n\n## Which workloads win?\n\nServerless shines where the load is **variable and sparse**:\n\n- Scheduled jobs — nightly reports, cron-like tasks.\n- Webhook receivers — uncertain when and how often the calls will arrive.\n- Low-volume internal tools — panels used a few times a day.\n- Bursty batch jobs — once an hour, briefly high parallelism.\n\nOn these workloads, scale-to-zero is real money saved, and cold-start is usually irrelevant — nobody cares that a nightly report starts 800 ms late.\n\n## Which workloads lose?\n\nThe reverse is just as clear:\n\n- **Steady, continuous traffic.** On an always-running API, \"pay per use\" turns into \"pay every second\" — and that's usually more expensive than a continuously running server.\n- **Latency-sensitive paths.** Cold-start is unacceptable on a request the user is waiting on.\n- **Long-running jobs.** FaaS has execution-time limits; long jobs either get split up or don't fit.\n\nFor a workload with this profile, a boring VPS — like the setup in the [multiple projects on a single VPS](/en/systems/single-vps-multi-project-architecture) piece — is both cheaper and more predictable. The cost of a steady load matches a steady server.\n\n## Laravel and serverless\n\nOn the Laravel side, Laravel Vapor makes it possible to run the application on Lambda, and it works well. But that it works doesn't mean it's the right decision: with Vapor you also inherit cold-start and the AWS bond.\n\nThe decision is, again, the workload. For a Laravel app with bursty, sparse, or unpredictable traffic, Vapor makes sense. For a continuous, latency-sensitive app, a classic server is still the plainer, cheaper answer.\n\n## Mix them\n\nThis isn't a binary choice. A boring server carrying the continuous load + handing only the sparse/bursty work (webhooks, the nightly report) to serverless is often the most honest split. Put every workload where its own cost profile belongs.\n\n---\n\nServerless isn't an architectural fashion, it's a tool for a specific workload profile. If you have that profile, it pays off; if you don't, it's paid back as cold-start and lock-in.\n\nMeasure your load first; the tool comes after.",
      "date_published": "2026-07-11T00:00:00.000Z",
      "tags": [
        "serverless",
        "architecture",
        "cloud",
        "Note"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/notes/bff-backend-for-frontend/",
      "url": "https://sade.dev/en/notes/bff-backend-for-frontend/",
      "title": "BFF Pattern: A Separate API Layer for Mobile and Web",
      "summary": "Bending one general-purpose API to fit every client is paid for in over-fetching, chatty flows, conditional fields and a versioning knot that locks web and mobile into one contract. A BFF cancels that bill with a thin adaptation layer per client — aggregation and response shaping, never business rules. What justifies the layer is the distance between clients, not their number.",
      "content_html": "<p>I once watched a mobile app fire nine separate requests just to render a single home screen. The API had actually been designed for web; mobile was collecting the data the way web sliced it, piece by piece, and stitching it back together on screen. Nine round-trips per launch — under mobile network conditions, no less.</p>\n<p>That was the bill for the “one API fits everyone” assumption. The BFF — Backend for Frontend — exists precisely to cancel that bill.</p>\n<h2 id=\"what-a-bff-is\">What a BFF is</h2>\n<p>A BFF is a thin API layer tailored to one specific client type. Web gets its own BFF, mobile gets its own. Each one calls the same underlying services and domain, but shapes the response to fit its own client’s screen.</p>\n<p>The real business logic doesn’t live in the BFF. The BFF is only an <strong>adaptation layer</strong>: it aggregates, trims unneeded fields, and gathers everything a client needs for one screen into a single response.</p>\n<h2 id=\"the-hidden-cost-of-bending-one-api-to-fit-everyone\">The hidden cost of bending one API to fit everyone</h2>\n<p>A single general-purpose API can’t fully satisfy two clients over time:</p>\n<ul>\n<li><strong>Over-fetching.</strong> Mobile downloads a 40-field user object and uses 4 of them. The rest is wasted bandwidth and battery.</li>\n<li><strong>Chatty flow.</strong> The “fetch the list first, then fetch details for each row” flow that web finds perfectly normal turns into nine round-trips on a mobile network.</li>\n<li><strong>Conditional fields.</strong> The response starts branching with <code>if mobile then ... else ...</code>. A single endpoint tries to carry the requirements of two clients at once and does a mediocre job for both.</li>\n<li><strong>Versioning knot.</strong> A change needed for web risks breaking mobile’s published version. Two clients get locked into one contract.</li>\n</ul>\n<p>Each of these costs looks small on its own; their sum is an architectural problem.</p>\n<h2 id=\"when-is-a-bff-warranted\">When is a BFF warranted?</h2>\n<p>A BFF is justified when client needs <strong>genuinely</strong> diverge:</p>\n<ul>\n<li>Mobile and web need distinctly different screens and different data shapes.</li>\n<li>Mobile network and battery constraints make round-trip count and payload size a first-class concern.</li>\n<li>The clients ship at different cadences — the mobile release is stuck in store review while web deploys every day; a single contract locks the two together.</li>\n</ul>\n<p>When these differences exist, giving each client its own BFF is cheaper than constantly bending one API to fit both.</p>\n<h2 id=\"when-is-a-bff-unnecessary\">When is a BFF unnecessary?</h2>\n<p>If you have a single client, a BFF is more layer than solution — just an extra hop.</p>\n<p>If you have two clients but both want almost the same data in almost the same shape, it’s still unnecessary. What justifies a BFF isn’t the number of clients but the <strong>difference between</strong> them. No difference, no separate layer — otherwise it’s <a href=\"/en/journal/the-cost-of-just-in-case-code/\">speculative generality</a> in the shape of an API layer.</p>\n<h2 id=\"a-bff-is-not-a-microservice\">A BFF is not a microservice</h2>\n<p>A common confusion: adding a BFF doesn’t drop you into distributed architecture. A BFF is a presentation/aggregation layer, not a domain service. Putting business rules inside it means copying the rule once per client.</p>\n<p>The BFF’s only kinship with microservices is this: both ask “is this worth a separate deployable?” The answer is usually “no” — the measured-signal threshold from <a href=\"/en/journal/when-i-move-to-microservices/\">the decision to move to microservices</a> applies here too.</p>\n<h2 id=\"start-light\">Start light</h2>\n<p>“BFF” doesn’t have to mean standing up a separate server. Within the same monolith:</p>\n<ul>\n<li>One route namespace per client — <code>routes/web-api.php</code>, <code>routes/mobile-api.php</code> — each with its own controllers and its own response shape.</li>\n<li>Or a single GraphQL layer: each client queries exactly what it needs, and over-fetching resolves itself.</li>\n</ul>\n<p>The real idea isn’t a box, it’s a boundary: collect the client-specific adaptation in one place and keep the domain clean of it. Move to a separate deployable only when <a href=\"/en/journal/when-i-move-to-microservices/\">a measured signal</a> forces it.</p>\n<hr/>\n<p>The BFF is the antidote to the “every client makes do with the same API” stubbornness — but only when clients genuinely diverge. No difference, no layer.</p>\n<p>What adds the layer isn’t the number of clients, it’s the distance between them.</p>",
      "content_text": "I once watched a mobile app fire nine separate requests just to render a single home screen. The API had actually been designed for web; mobile was collecting the data the way web sliced it, piece by piece, and stitching it back together on screen. Nine round-trips per launch — under mobile network conditions, no less.\n\nThat was the bill for the \"one API fits everyone\" assumption. The BFF — Backend for Frontend — exists precisely to cancel that bill.\n\n## What a BFF is\n\nA BFF is a thin API layer tailored to one specific client type. Web gets its own BFF, mobile gets its own. Each one calls the same underlying services and domain, but shapes the response to fit its own client's screen.\n\nThe real business logic doesn't live in the BFF. The BFF is only an **adaptation layer**: it aggregates, trims unneeded fields, and gathers everything a client needs for one screen into a single response.\n\n## The hidden cost of bending one API to fit everyone\n\nA single general-purpose API can't fully satisfy two clients over time:\n\n- **Over-fetching.** Mobile downloads a 40-field user object and uses 4 of them. The rest is wasted bandwidth and battery.\n- **Chatty flow.** The \"fetch the list first, then fetch details for each row\" flow that web finds perfectly normal turns into nine round-trips on a mobile network.\n- **Conditional fields.** The response starts branching with `if mobile then ... else ...`. A single endpoint tries to carry the requirements of two clients at once and does a mediocre job for both.\n- **Versioning knot.** A change needed for web risks breaking mobile's published version. Two clients get locked into one contract.\n\nEach of these costs looks small on its own; their sum is an architectural problem.\n\n## When is a BFF warranted?\n\nA BFF is justified when client needs **genuinely** diverge:\n\n- Mobile and web need distinctly different screens and different data shapes.\n- Mobile network and battery constraints make round-trip count and payload size a first-class concern.\n- The clients ship at different cadences — the mobile release is stuck in store review while web deploys every day; a single contract locks the two together.\n\nWhen these differences exist, giving each client its own BFF is cheaper than constantly bending one API to fit both.\n\n## When is a BFF unnecessary?\n\nIf you have a single client, a BFF is more layer than solution — just an extra hop.\n\nIf you have two clients but both want almost the same data in almost the same shape, it's still unnecessary. What justifies a BFF isn't the number of clients but the **difference between** them. No difference, no separate layer — otherwise it's [speculative generality](/en/journal/the-cost-of-just-in-case-code) in the shape of an API layer.\n\n## A BFF is not a microservice\n\nA common confusion: adding a BFF doesn't drop you into distributed architecture. A BFF is a presentation/aggregation layer, not a domain service. Putting business rules inside it means copying the rule once per client.\n\nThe BFF's only kinship with microservices is this: both ask \"is this worth a separate deployable?\" The answer is usually \"no\" — the measured-signal threshold from [the decision to move to microservices](/en/journal/when-i-move-to-microservices) applies here too.\n\n## Start light\n\n\"BFF\" doesn't have to mean standing up a separate server. Within the same monolith:\n\n- One route namespace per client — `routes/web-api.php`, `routes/mobile-api.php` — each with its own controllers and its own response shape.\n- Or a single GraphQL layer: each client queries exactly what it needs, and over-fetching resolves itself.\n\nThe real idea isn't a box, it's a boundary: collect the client-specific adaptation in one place and keep the domain clean of it. Move to a separate deployable only when [a measured signal](/en/journal/when-i-move-to-microservices) forces it.\n\n---\n\nThe BFF is the antidote to the \"every client makes do with the same API\" stubbornness — but only when clients genuinely diverge. No difference, no layer.\n\nWhat adds the layer isn't the number of clients, it's the distance between them.",
      "date_published": "2026-07-04T00:00:00.000Z",
      "tags": [
        "architecture",
        "api",
        "bff",
        "Note"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/notes/sync-vs-async/",
      "url": "https://sade.dev/en/notes/sync-vs-async/",
      "title": "Synchronous or Asynchronous? HTTP or the Queue",
      "summary": "Synchronous or asynchronous is not a performance question, it is a question of who is waiting. If the user has to see the result, the job stays inside the request; if not, it goes to a queue and the request answers 202 Accepted. Failure sharpens the same line: a synchronous failure is an error screen, an asynchronous one is a retry — which is why an unstable third party never belongs on the synchronous path.",
      "content_html": "<p>A signup form took eight seconds. Inside the request, in order: the user got saved, a welcome email went out, a PDF was generated, a record was pushed to a CRM. For all eight seconds the user stared at a spinner — when the only thing they cared about was whether their account had been created.</p>\n<p>Should a job run inside the HTTP request, or go to a queue? The line is clearer than it looks.</p>\n<h2 id=\"the-decision-line-is-the-user-waiting-on-the-result\">The decision line: is the user waiting on the result?</h2>\n<p>One question decides most of it: <strong>does the user have to see the result of this job?</strong></p>\n<ul>\n<li>If the answer is “yes,” the job stays synchronous — it finishes inside the request, before the response returns.</li>\n<li>If the answer is “no,” the job goes to a queue — the request says “accepted” and returns.</li>\n</ul>\n<p>In the form above, the user is only waiting for the answer to “was my account created?” The email, the PDF, the CRM — none of their results need to show up on that screen. All three were on the wrong side.</p>\n<h2 id=\"which-jobs-have-to-stay-synchronous\">Which jobs have to stay synchronous?</h2>\n<p>Jobs whose result the user sees directly stay inside the request:</p>\n<ul>\n<li>Input validation — the error must come back immediately.</li>\n<li>Creating the actual resource — user creation, order creation.</li>\n<li>The <strong>result of a payment authorization</strong> — did the card go through or not? The user has to know right then.</li>\n<li>Data the user will see in the next step.</li>\n</ul>\n<p>Putting these on a queue means telling the user “check back later” — and that is usually a bad experience.</p>\n<h2 id=\"which-jobs-should-go-to-a-queue\">Which jobs should go to a queue?</h2>\n<p>Every job whose result the user doesn’t need right then is asynchronous:</p>\n<ul>\n<li>Email and notifications.</li>\n<li>PDF and report generation.</li>\n<li>Webhook delivery.</li>\n<li>Third-party synchronization — CRM, analytics, search index.</li>\n<li>Image processing, exports.</li>\n</ul>\n<p>For these, the right response is <code>202 Accepted</code>: “I got your request, I’m working on it.” The user doesn’t wait, and the system does the work at its own pace.</p>\n<h2 id=\"response-time-sharpens-the-line\">Response time sharpens the line</h2>\n<p>Every endpoint should have a p95 budget — say 300 ms. If a single step eats that budget on its own and the user doesn’t have to see that step’s result, the step goes to a queue.</p>\n<p>A synchronous chain is only as fast as its slowest link. If PDF generation takes 4 seconds, user creation takes 4 seconds too — even though the PDF has nothing to do with the response.</p>\n<h2 id=\"fault-tolerance-the-real-distinction\">Fault tolerance: the real distinction</h2>\n<p>The real issue isn’t speed, it’s failure. Synchronous and asynchronous handle failure in completely different ways:</p>\n<ul>\n<li><strong>Synchronous failure</strong> = the user sees an error screen. The work is lost.</li>\n<li><strong>Asynchronous failure</strong> = the job is retried, drops to a DLQ if it has to, and the <a href=\"/en/notes/laravel-queue-production-slowdown/\">queue</a> processes it later.</li>\n</ul>\n<p>From this comes a single rule: <strong>an unstable third party should never sit on the synchronous path.</strong> If that CRM call is inside the request, your signup form goes down every time the CRM does. If the call is queued, the retry completes on its own once the CRM comes back. The third party’s uptime stops being your uptime.</p>\n<h2 id=\"the-202-and-status-poll-pattern\">The 202-and-status-poll pattern</h2>\n<p>Once a job goes to a queue, leave the user a way to follow up:</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"plaintext\"><code><span class=\"line\"><span>POST /exports          → 202 Accepted  { &quot;id&quot;: &quot;exp_1a2b&quot;, &quot;status&quot;: &quot;processing&quot; }</span></span>\n<span class=\"line\"><span>GET  /exports/exp_1a2b → 200 OK        { &quot;status&quot;: &quot;done&quot;, &quot;url&quot;: &quot;...&quot; }</span></span></code></pre>\n<p>The user isn’t waiting on the result, but sometimes they get curious. A status endpoint, or a notification sent once it’s ready, closes that gap.</p>\n<h2 id=\"where-does-the-line-dissolve\">Where does the line dissolve?</h2>\n<p>Some jobs sit between the two: the user wants to see the result, but the job is also slow. There’s a third path here — start the job synchronously, return the first meaningful result immediately, hand the rest off to a queue. Create the order record instantly (synchronous), generate the invoice in the background (asynchronous).</p>\n<p>Draw the line per job; “everything to a queue” is as wrong as “everything synchronous.”</p>\n<hr/>\n<p>Synchronous or asynchronous isn’t a performance question, it’s a “who’s waiting” question. No job the user isn’t waiting on should make their request wait.</p>\n<p>Tie up the request only with the work whose answer they actually want.</p>",
      "content_text": "A signup form took eight seconds. Inside the request, in order: the user got saved, a welcome email went out, a PDF was generated, a record was pushed to a CRM. For all eight seconds the user stared at a spinner — when the only thing they cared about was whether their account had been created.\n\nShould a job run inside the HTTP request, or go to a queue? The line is clearer than it looks.\n\n## The decision line: is the user waiting on the result?\n\nOne question decides most of it: **does the user have to see the result of this job?**\n\n- If the answer is \"yes,\" the job stays synchronous — it finishes inside the request, before the response returns.\n- If the answer is \"no,\" the job goes to a queue — the request says \"accepted\" and returns.\n\nIn the form above, the user is only waiting for the answer to \"was my account created?\" The email, the PDF, the CRM — none of their results need to show up on that screen. All three were on the wrong side.\n\n## Which jobs have to stay synchronous?\n\nJobs whose result the user sees directly stay inside the request:\n\n- Input validation — the error must come back immediately.\n- Creating the actual resource — user creation, order creation.\n- The **result of a payment authorization** — did the card go through or not? The user has to know right then.\n- Data the user will see in the next step.\n\nPutting these on a queue means telling the user \"check back later\" — and that is usually a bad experience.\n\n## Which jobs should go to a queue?\n\nEvery job whose result the user doesn't need right then is asynchronous:\n\n- Email and notifications.\n- PDF and report generation.\n- Webhook delivery.\n- Third-party synchronization — CRM, analytics, search index.\n- Image processing, exports.\n\nFor these, the right response is `202 Accepted`: \"I got your request, I'm working on it.\" The user doesn't wait, and the system does the work at its own pace.\n\n## Response time sharpens the line\n\nEvery endpoint should have a p95 budget — say 300 ms. If a single step eats that budget on its own and the user doesn't have to see that step's result, the step goes to a queue.\n\nA synchronous chain is only as fast as its slowest link. If PDF generation takes 4 seconds, user creation takes 4 seconds too — even though the PDF has nothing to do with the response.\n\n## Fault tolerance: the real distinction\n\nThe real issue isn't speed, it's failure. Synchronous and asynchronous handle failure in completely different ways:\n\n- **Synchronous failure** = the user sees an error screen. The work is lost.\n- **Asynchronous failure** = the job is retried, drops to a DLQ if it has to, and the [queue](/en/notes/laravel-queue-production-slowdown) processes it later.\n\nFrom this comes a single rule: **an unstable third party should never sit on the synchronous path.** If that CRM call is inside the request, your signup form goes down every time the CRM does. If the call is queued, the retry completes on its own once the CRM comes back. The third party's uptime stops being your uptime.\n\n## The 202-and-status-poll pattern\n\nOnce a job goes to a queue, leave the user a way to follow up:\n\n```\nPOST /exports          → 202 Accepted  { \"id\": \"exp_1a2b\", \"status\": \"processing\" }\nGET  /exports/exp_1a2b → 200 OK        { \"status\": \"done\", \"url\": \"...\" }\n```\n\nThe user isn't waiting on the result, but sometimes they get curious. A status endpoint, or a notification sent once it's ready, closes that gap.\n\n## Where does the line dissolve?\n\nSome jobs sit between the two: the user wants to see the result, but the job is also slow. There's a third path here — start the job synchronously, return the first meaningful result immediately, hand the rest off to a queue. Create the order record instantly (synchronous), generate the invoice in the background (asynchronous).\n\nDraw the line per job; \"everything to a queue\" is as wrong as \"everything synchronous.\"\n\n---\n\nSynchronous or asynchronous isn't a performance question, it's a \"who's waiting\" question. No job the user isn't waiting on should make their request wait.\n\nTie up the request only with the work whose answer they actually want.",
      "date_published": "2026-06-27T00:00:00.000Z",
      "tags": [
        "architecture",
        "queue",
        "async",
        "api",
        "Note"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/systems/transactional-outbox-dual-write-and-idempotent-consumption/",
      "url": "https://sade.dev/en/systems/transactional-outbox-dual-write-and-idempotent-consumption/",
      "title": "Transactional Outbox: Dual-Write, Idempotent Consumers",
      "summary": "An outbox collapses dual-write into a single write but gives no free guarantee — it gives at-least-once. Polling vs CDC, multi-relay with SKIP LOCKED, ordering, pruning processed_messages, the exactly-once illusion, and field-level encryption.",
      "content_html": "<p>The symptom was small: one or two invoices a day sat in the database but the consumer never saw them. The user said “I issued it,” and there was no trace on the other side. Always at night, always during a deploy or a network blip. Once I laid the picture out, the cause was clear — the <code>INSERT</code> into the database and the <code>publish</code> to the queue were two separate systems, with no guarantee between them.</p>\n<p>This post is the systems side of the <strong>transactional outbox</strong> pattern that closed that gap, the <strong>at-least-once</strong> repeats it opened in return, and finally encrypting the personal data that flows through the event at the <strong>field level</strong>. I wrote the decision-chain side as <a href=\"https://muhammetsafak.com.tr/en/blog/from-dual-write-to-outbox-idempotent-consumption-and-field-encryption\">a log on muhammetsafak.com.tr</a>; here I collect the decisions you have to make for the pattern to be durable in production.</p>\n<h2 id=\"dual-write-you-cant-stretch-a-transaction-across-two-systems\">Dual-write: you can’t stretch a transaction across two systems</h2>\n<p>The crux in one sentence: your local database transaction doesn’t cover RabbitMQ. The classic code is dangerous <em>because it works most of the time</em>:</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"php\"><code><span class=\"line\"><span style=\"color:#998418;--shiki-dark:#B8A965\">DB</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">::</span><span style=\"color:#59873A;--shiki-dark:#80A665\">transaction</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">function</span><span style=\"color:#999999;--shiki-dark:#666666\"> ()</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> use</span><span style=\"color:#999999;--shiki-dark:#666666\"> ($</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">invoice</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#999999;--shiki-dark:#666666\"> {</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">    $</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">invoice</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">save</span><span style=\"color:#999999;--shiki-dark:#666666\">();</span><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">                 // 1) write to MySQL</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">    $</span><span style=\"color:#A65E2B;--shiki-dark:#C99076\">this</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">publishToRabbit</span><span style=\"color:#999999;--shiki-dark:#666666\">($</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">invoice</span><span style=\"color:#999999;--shiki-dark:#666666\">);</span><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\"> // 2) publish to the queue</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">});</span></span></code></pre>\n<p>There are two distinct failure modes:</p>\n<ul>\n<li><code>save()</code> succeeds, <code>publish</code> drops on a network error → the invoice is in the DB, the event isn’t. <strong>Lost event.</strong></li>\n<li><code>publish</code> succeeds, then the transaction <code>rollback</code>s for another reason → the event went out, no counterpart in the DB. <strong>Phantom event.</strong></li>\n</ul>\n<p><code>DB::transaction</code> doesn’t save you, because commit/rollback only wraps the MySQL side; the broker isn’t part of that transaction. The practical way to hold two systems in one atomic step is to reduce the write to <strong>a single system</strong>.</p>\n<h2 id=\"outbox-write-to-one-system-first-let-a-separate-process-publish\">Outbox: write to one system first, let a separate process publish</h2>\n<p>The idea is plain: instead of pushing the message straight to the queue, write it as a row into an <code>outbox</code> table <strong>inside the same transaction</strong>. The invoice and the event either both exist or both don’t, in the same commit — dual-write collapses into a single write.</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"sql\"><code><span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">CREATE</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> TABLE</span><span style=\"color:#59873A;--shiki-dark:#80A665\"> outbox</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> (</span></span>\n<span class=\"line\"><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">    id            </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">BINARY</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">(</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\">16</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">)   </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">NOT NULL</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">,        </span><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">-- event id = idempotency key</span></span>\n<span class=\"line\"><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">    aggregate_id  </span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">BIGINT</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">       NOT NULL</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">,        </span><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">-- ordering and partition key</span></span>\n<span class=\"line\"><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">    topic         </span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">VARCHAR</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">(</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\">120</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">) </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">NOT NULL</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">,</span></span>\n<span class=\"line\"><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">    payload       </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">JSON</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">         NOT NULL</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">,</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">    status</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">        ENUM(</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">pending</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">,</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">published</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">) </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">NOT NULL</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> DEFAULT</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\"> &#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">pending</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">,</span></span>\n<span class=\"line\"><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">    created_at    </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">DATETIME</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">(</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\">6</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">)  </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">NOT NULL</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">,</span></span>\n<span class=\"line\"><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">    published_at  </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">DATETIME</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">(</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\">6</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">)  </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">NULL</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">,</span></span>\n<span class=\"line\"><span style=\"color:#AB5959;--shiki-dark:#CB7676\">    PRIMARY KEY</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> (id),</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">    KEY</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> idx_dispatch (</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">status</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">, created_at)        </span><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">-- the relay scan goes through this index</span></span>\n<span class=\"line\"><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">);</span></span></code></pre>\n<p>The write is no longer the business code’s concern:</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"php\"><code><span class=\"line\"><span style=\"color:#998418;--shiki-dark:#B8A965\">DB</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">::</span><span style=\"color:#59873A;--shiki-dark:#80A665\">transaction</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">function</span><span style=\"color:#999999;--shiki-dark:#666666\"> ()</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> use</span><span style=\"color:#999999;--shiki-dark:#666666\"> ($</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">invoice</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#999999;--shiki-dark:#666666\"> {</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">    $</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">invoice</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">save</span><span style=\"color:#999999;--shiki-dark:#666666\">();</span></span>\n<span class=\"line\"><span style=\"color:#998418;--shiki-dark:#B8A965\">    Outbox</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">::</span><span style=\"color:#59873A;--shiki-dark:#80A665\">write</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">invoice.issued</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#999999;--shiki-dark:#666666\">,</span><span style=\"color:#999999;--shiki-dark:#666666\"> $</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">invoice</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">id</span><span style=\"color:#999999;--shiki-dark:#666666\">,</span><span style=\"color:#999999;--shiki-dark:#666666\"> $</span><span style=\"color:#A65E2B;--shiki-dark:#C99076\">this</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">payload</span><span style=\"color:#999999;--shiki-dark:#666666\">($</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">invoice</span><span style=\"color:#999999;--shiki-dark:#666666\">));</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">});</span></span></code></pre>\n<p>A separate <strong>relay</strong> does the queue push: it reads the <code>pending</code> rows, <code>publish</code>es them to RabbitMQ, and stamps them <code>published</code> on success. The critical fact here: the relay can fall into the “I published but crashed before stamping it <code>published</code>” state. So while the outbox solves dual-write, it gives you <strong>no free guarantee</strong> — it gives you <strong>at-least-once</strong>. The message isn’t lost, but it can repeat. Everything else arranges itself around this fact.</p>\n<h2 id=\"how-do-you-feed-the-relay-polling-or-cdc\">How do you feed the relay: polling or CDC?</h2>\n<p>The relay can see <code>pending</code> rows two ways.</p>\n<p><strong>Polling</strong> — the relay scans the table periodically:</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"sql\"><code><span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">SELECT</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> id, topic, payload</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">FROM</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> outbox</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">WHERE</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> status</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> =</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\"> &#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">pending</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">ORDER BY</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> created_at</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">LIMIT</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\"> 100</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">;</span></span></code></pre>\n<p>Simple, works on every database, low operational overhead. The cost is two things: <strong>latency</strong> up to the scan interval (a 1s poll = ~1s of queue latency) and an idling query. Without the <code>idx_dispatch</code> index this scan gets expensive as the table grows; even with it, you trade poll frequency against DB load. At small-to-medium volume, polling is the <strong>right</strong> answer — tune it frequent enough to keep latency acceptable, sparse enough not to hammer the DB (in practice 200ms–1s).</p>\n<p><strong>CDC</strong> (change data capture) — the relay listens to the database’s <strong>WAL/binlog</strong> rather than the table (Debezium is the typical tool). Every <code>INSERT</code> into <code>outbox</code> turns into an event almost instantly; polling latency and idle scanning disappear. The cost is operational: binlog access, a connector process, one more moving part. I turn CDC on when latency genuinely matters (sub-second) or when volume strains polling; otherwise I count polling’s simplicity as an advantage.</p>\n<blockquote>\n<p>Rule: your latency budget and your operational budget conflict. CDC buys you latency and charges you an infrastructure piece in return. Start with polling; switch to CDC when there’s a measured reason.</p>\n</blockquote>\n<h2 id=\"multiple-relays-collision-free-dispatch-with-skip-locked\">Multiple relays: collision-free dispatch with <code>SKIP LOCKED</code></h2>\n<p>A single relay is a bottleneck and a single point of failure. Running multiple relays over the same table creates a new risk: two of them grab the same row and publish the same message twice. The naive fix is to lock the table — which kills the parallelism.</p>\n<p>The right tool is <code>SELECT ... FOR UPDATE SKIP LOCKED</code>. Each relay claims a batch of rows <strong>by locking them</strong>; instead of queuing behind rows another relay has locked, it <strong>skips</strong> them:</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"sql\"><code><span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">BEGIN</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">;</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">  SELECT</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> id, topic, payload</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">  FROM</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> outbox</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">  WHERE</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> status</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> =</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\"> &#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">pending</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">  ORDER BY</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> created_at</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">  LIMIT</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\"> 100</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">  FOR</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> UPDATE</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> SKIP</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> LOCKED;     </span><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">-- skip rows another relay holds, don&#39;t wait</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">  -- publish this batch, then:</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">  UPDATE</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> outbox </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">SET</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> status</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> =</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\"> &#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">published</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">, published_at </span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">=</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> NOW</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">(</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\">6</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">)</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">  WHERE</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> id </span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">IN</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> (...);</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">COMMIT</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">;</span></span></code></pre>\n<p>Without <code>SKIP LOCKED</code>, the relays queue for the same rows and the parallelism effectively runs serially. With it, each relay pulls a disjoint set and all of them move at once. PostgreSQL and MySQL 8+ support it.</p>\n<p>A subtlety: if the relay crashes between <code>publish</code> and <code>UPDATE ... published</code>, the row stays <code>pending</code> and gets <strong>republished</strong>. That’s acceptable — we’re already at at-least-once; the fix is on the consumer side, below. The dangerous order is the reverse: stamping <code>published</code> first and then <code>publish</code>ing. A crash there produces a <strong>lost event</strong> — exactly what we ran from. So the order is <strong>fixed</strong>: <code>publish</code> first, stamp second.</p>\n<h2 id=\"ordering-what-you-can-guarantee-what-you-cant\">Ordering: what you can guarantee, what you can’t</h2>\n<p>“Let events arrive in the order they were written” is an intuitive expectation, but global ordering is expensive and usually unnecessary. What you actually need is <strong>per-aggregate</strong> order: the same invoice’s <code>created</code> event should arrive before its <code>updated</code>; the order of two different invoices relative to each other is nobody’s concern.</p>\n<p>You get this by partitioning on <code>aggregate_id</code>: events with the same <code>aggregate_id</code> go to the same queue partition (or, in RabbitMQ, the same queue via a consistent-hash exchange), and a <strong>single consumer</strong> processes that partition in order. Different aggregates flow in parallel.</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"plaintext\"><code><span class=\"line\"><span>outbox (in created_at order)</span></span>\n<span class=\"line\"><span>   │  hash(aggregate_id) % N</span></span>\n<span class=\"line\"><span>   ├── partition 0 ──► consumer-0   (events for aggregates A,D — ordered)</span></span>\n<span class=\"line\"><span>   ├── partition 1 ──► consumer-1   (events for aggregate B    — ordered)</span></span>\n<span class=\"line\"><span>   └── partition 2 ──► consumer-2   (events for aggregate C    — ordered)</span></span></code></pre>\n<p>Two things still break ordering, and the design has to be ready for them: multiple relays under <code>SKIP LOCKED</code> can publish rows in a different order, and at-least-once repeats can cut in. So rather than leaning on a global order, you write the consumer to be resilient to <strong>out-of-order and duplicated</strong> events. The practical shield: put a monotonic <code>version</code>/<code>sequence</code> on the event and drop the stale one (lower version) at the consumer. If you genuinely need strict global ordering you drop to a single partition + single consumer — which then ties throughput to that one consumer; most systems don’t want to pay that.</p>\n<h2 id=\"once-you-have-at-least-once-the-consumer-must-be-idempotent\">Once you have at-least-once: the consumer must be idempotent</h2>\n<p>Because the relay can republish, you have to assume every event arrives at least once, sometimes more. The fix isn’t to make the event unique — it’s to set up the <strong>consumer</strong> so that processing the same event twice has the same effect as once. Write the <code>id</code> of every processed event into a dedupe table, and do the work <strong>inside the same transaction</strong>:</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"php\"><code><span class=\"line\"><span style=\"color:#998418;--shiki-dark:#B8A965\">DB</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">::</span><span style=\"color:#59873A;--shiki-dark:#80A665\">transaction</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">function</span><span style=\"color:#999999;--shiki-dark:#666666\"> ()</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> use</span><span style=\"color:#999999;--shiki-dark:#666666\"> ($</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">event</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#999999;--shiki-dark:#666666\"> {</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">    $</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">inserted</span><span style=\"color:#999999;--shiki-dark:#666666\"> =</span><span style=\"color:#998418;--shiki-dark:#B8A965\"> DB</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">::</span><span style=\"color:#59873A;--shiki-dark:#80A665\">table</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">processed_messages</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">insertOrIgnore</span><span style=\"color:#999999;--shiki-dark:#666666\">([</span></span>\n<span class=\"line\"><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">        &#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">message_id</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">   =&gt;</span><span style=\"color:#999999;--shiki-dark:#666666\"> $</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">event</span><span style=\"color:#999999;--shiki-dark:#666666\">[</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">id</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#999999;--shiki-dark:#666666\">],</span></span>\n<span class=\"line\"><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">        &#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">processed_at</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> =&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\"> now</span><span style=\"color:#999999;--shiki-dark:#666666\">(),</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">    ]);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">    if</span><span style=\"color:#999999;--shiki-dark:#666666\"> ($</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">inserted</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> ===</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\"> 0</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#999999;--shiki-dark:#666666\"> {</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">        return</span><span style=\"color:#999999;--shiki-dark:#666666\">;</span><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\"> // it&#39;s a repeat; produce no side effects</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">    }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">    $</span><span style=\"color:#A65E2B;--shiki-dark:#C99076\">this</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">applyBusinessEffect</span><span style=\"color:#999999;--shiki-dark:#666666\">($</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">event</span><span style=\"color:#999999;--shiki-dark:#666666\">);</span><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\"> // the real work — exactly once</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">});</span></span></code></pre>\n<p>The <strong>unique</strong> constraint on <code>processed_messages.message_id</code> is the heart of it: a second arrival of the same <code>id</code> is silently dropped by <code>insertOrIgnore</code>. Putting the business effect in the same transaction is mandatory — otherwise the “I processed it but crashed before stamping” gap opens. Where the dedupe key comes from, and designing a dedupe that survives a crash, is a topic of its own; I covered it in <a href=\"/en/notes/idempotency-duplicate-delivery/\">a separate note</a>.</p>\n<p>If the side effect is an external system (an e-invoice integrator, a payment API), dedupe alone isn’t enough — because the side effect is outside the transaction. There you have to <strong>carry the key to the external service</strong>: pass the same <code>id</code> as an idempotency key to the integrator, or ask “does this already exist?” before sending. I worked through that boundary in detail in <a href=\"/en/systems/race-conditions-and-gaps-in-sequential-numbering/\">the JIT reservation post</a>, where sealing the number before the external call turns recovery into a lookup.</p>\n<h2 id=\"processed_messages-cant-grow-forever\"><code>processed_messages</code> can’t grow forever</h2>\n<p>The dedupe table accumulates a row per event; if you don’t prune it, it becomes a performance problem of its own. The key observation: you don’t need to keep an event <code>id</code> forever — only as long as the <strong>window in which a repeat could arrive</strong>. Relay retries and broker redeliveries are on the order of hours, not days.</p>\n<p>In practice I pick a fixed <strong>retention window</strong> (say 7 days — comfortably above the longest possible redelivery) and prune older rows:</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"sql\"><code><span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">DELETE</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> FROM</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> processed_messages</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">WHERE</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> processed_at </span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">&lt;</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> NOW</span><span style=\"color:#999999;--shiki-dark:#666666\">()</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> -</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\"> INTERVAL </span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\">7</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> DAY</span></span>\n<span class=\"line\"><span style=\"color:#1E754F;--shiki-dark:#4D9375\">LIMIT</span><span style=\"color:#2F798A;--shiki-dark:#4C9A91\"> 10000</span><span style=\"color:#393A34;--shiki-dark:#DBD7CAEE\">;   </span><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">-- small batches at a time, keeping replica lag and locks in check</span></span></code></pre>\n<p>If <code>message_id</code> is a UUID the table and its index grow; pruning keeps that bounded. When choosing the window, frame the rule backwards: <em>how late could the latest redelivery arrive?</em> Make a safe upper bound of that your retention. Set the window too short and a late repeat misses the dedupe, so the side effect runs a second time.</p>\n<h2 id=\"why-exactly-once-is-usually-an-illusion\">Why exactly-once is usually an illusion</h2>\n<p>In a distributed system “exactly-once delivery” is tempting but can’t be guaranteed end to end. The reason is the same dual-write, at the broker boundary: if the consumer did the work but crashed before getting its <code>ack</code> to the broker, the broker redelivers the message — because it never saw the <code>ack</code>. “I processed it” and “I reported that I processed it” are two separate steps, and a crash can fall between them. This is the practical face of the Two Generals problem.</p>\n<p>So the goal isn’t exactly-once <strong>delivery</strong>, it’s exactly-once <strong>effect</strong>. And you get that by combining two cheap guarantees:</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"plaintext\"><code><span class=\"line\"><span>at-least-once delivery  +  idempotent consumer  =  effectively-once</span></span></code></pre>\n<p>That is, you stop trying to deliver exactly once and instead make the <strong>repeat harmless</strong>. Under systems that market “exactly-once,” this is usually exactly what’s there: at-least-once plus a dedupe layer. Look for the guarantee in the effect, not in the delivery.</p>\n<h2 id=\"personal-data-flowing-through-the-event-field-level-encryption\">Personal data flowing through the event: field-level encryption</h2>\n<p>Once the outbox settles in, a new surface appears: the events’ payload carries personal data — customer name, tax ID, address — and that payload now sits in a <strong>durable</strong> table (<code>outbox</code>) and passes through the broker. If I leave the data as-is, I’m copying GDPR/KVKK-scoped fields, unencrypted, into more than one place.</p>\n<p>I didn’t want to encrypt the whole payload — I needed fields like <code>topic</code>, <code>aggregate_id</code>, and <code>invoice_id</code> in the clear for relay routing and observability. The need is <strong>field-level</strong> encryption: only the sensitive fields encrypted, the rest in the clear. I marked the sensitive fields in the schema with <code>x-gdpr-sensitive</code> and encrypted only those at serialization time:</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"php\"><code><span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">$</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">schema</span><span style=\"color:#999999;--shiki-dark:#666666\"> =</span><span style=\"color:#999999;--shiki-dark:#666666\"> [</span></span>\n<span class=\"line\"><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">    &#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">invoice_id</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">    =&gt;</span><span style=\"color:#999999;--shiki-dark:#666666\"> [</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">x-gdpr-sensitive</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> =&gt;</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> false</span><span style=\"color:#999999;--shiki-dark:#666666\">],</span></span>\n<span class=\"line\"><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">    &#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">customer_name</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> =&gt;</span><span style=\"color:#999999;--shiki-dark:#666666\"> [</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">x-gdpr-sensitive</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> =&gt;</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> true</span><span style=\"color:#999999;--shiki-dark:#666666\">],</span></span>\n<span class=\"line\"><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">    &#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">tax_id</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">        =&gt;</span><span style=\"color:#999999;--shiki-dark:#666666\"> [</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">x-gdpr-sensitive</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> =&gt;</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> true</span><span style=\"color:#999999;--shiki-dark:#666666\">],</span></span>\n<span class=\"line\"><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">    &#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">total</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">         =&gt;</span><span style=\"color:#999999;--shiki-dark:#666666\"> [</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">x-gdpr-sensitive</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> =&gt;</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> false</span><span style=\"color:#999999;--shiki-dark:#666666\">],</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">];</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">$</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">payload</span><span style=\"color:#999999;--shiki-dark:#666666\"> =</span><span style=\"color:#59873A;--shiki-dark:#80A665\"> collect</span><span style=\"color:#999999;--shiki-dark:#666666\">($</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">raw</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">map</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">fn</span><span style=\"color:#999999;--shiki-dark:#666666\"> ($</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">value</span><span style=\"color:#999999;--shiki-dark:#666666\">,</span><span style=\"color:#999999;--shiki-dark:#666666\"> $</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">field</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#999999;--shiki-dark:#666666\"> =&gt;</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">    ($</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">schema</span><span style=\"color:#999999;--shiki-dark:#666666\">[$</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">field</span><span style=\"color:#999999;--shiki-dark:#666666\">][</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">x-gdpr-sensitive</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#999999;--shiki-dark:#666666\">]</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> ??</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\"> false</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span></span>\n<span class=\"line\"><span style=\"color:#AB5959;--shiki-dark:#CB7676\">        ?</span><span style=\"color:#998418;--shiki-dark:#B8A965\"> Crypt</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">::</span><span style=\"color:#59873A;--shiki-dark:#80A665\">encryptString</span><span style=\"color:#999999;--shiki-dark:#666666\">((</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">string</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#999999;--shiki-dark:#666666\"> $</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">value</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#5F6E5F;--shiki-dark:#8A9A8A\">  // only the sensitive field is encrypted</span></span>\n<span class=\"line\"><span style=\"color:#AB5959;--shiki-dark:#CB7676\">        :</span><span style=\"color:#999999;--shiki-dark:#666666\"> $</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">value</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">all</span><span style=\"color:#999999;--shiki-dark:#666666\">();</span></span></code></pre>\n<p>That the schema declares the sensitive field is no accident — combine it with schema validation at the edge of the queue and both questions (“which field is personal data, and is it the right type?”) get answered <a href=\"/en/notes/validate-schema-at-the-edge/\">at the edge</a>. The real engineering decision isn’t the encryption itself, it’s the three questions around it:</p>\n<p><strong>Key management and rotation.</strong> Laravel’s <code>Crypt</code> uses AES-256 keyed by <code>APP_KEY</code>. Bind yourself to a single key and rotation becomes a nightmare: the new key can’t open outbox rows written with the old one. The fix is to store a <strong>key version</strong> (key id) alongside the encrypted value and keep a keyring that recognizes several keys at once — new writes use the current key, old values decrypt with their own version. In Laravel, <code>key</code> + <code>previous_keys</code> in <code>config/app.php</code> is exactly this; decryption tries the older keys in turn. That’s why you can rotate without migrating data.</p>\n<p><strong>Searching an encrypted field.</strong> <code>Crypt::encryptString</code> produces different ciphertext on every call (a random IV), which is correct for security but makes <code>WHERE tax_id = ?</code> impossible. If you need equality search you add a <strong>blind index</strong>: keep a deterministic HMAC of the searchable field in a separate column and query that. The encrypted column carries confidentiality, the blind index carries searchability; they’re separate columns because they do two separate jobs.</p>\n<p><strong>Losing the key is losing the data.</strong> Field encryption turns <code>APP_KEY</code> into an <strong>availability</strong> dependency: lose the key and the encrypted fields are permanent garbage. Key backup and access control are as much a part of the design as the encryption itself.</p>\n<h2 id=\"when-do-you-not-build-this-pattern\">When do you not build this pattern?</h2>\n<p>Outbox + idempotent consumption + field encryption isn’t cheap: it brings a table, a relay process (or a CDC connector), a dedupe table and its pruning, plus key management. If you don’t know what you’re buying in return, don’t pay for it. I don’t build it when:</p>\n<ul>\n<li><strong>Event loss is tolerable.</strong> If what you publish is a best-effort notification (cache invalidation, a “new content available” signal) and losing one or two doesn’t matter, a direct <code>publish</code> is enough. You build the outbox because loss matters.</li>\n<li><strong>The write and the publish are in the same system.</strong> If your target is the same database (there’s no separate broker), there’s no dual-write; you don’t need an outbox.</li>\n<li><strong>Volume fits a single consumer and ordering is critical.</strong> At low volume a single-consumer queue gives you both order and uniqueness simply; building the multi-relay-and-partition machinery is solving a problem you don’t have.</li>\n</ul>\n<p>The need for the pattern starts when three conditions arise <strong>together</strong>: event loss is unacceptable, production/consumption is parallel, and the data goes to a separate system (a broker). If all three aren’t present, there’s a plainer solution.</p>\n<hr/>\n<p>The three pieces come down to one discipline: <strong>write the data to a single system, suppress repeats at the consumer, seal the sensitive field before it leaves.</strong> The outbox makes trust in the queue unnecessary, idempotent consumption makes trust in delivery unnecessary, and field encryption makes trust in the payload unnecessary — each moves a guarantee out of the code and into the design.</p>",
      "content_text": "The symptom was small: one or two invoices a day sat in the database but the consumer never saw them. The user said \"I issued it,\" and there was no trace on the other side. Always at night, always during a deploy or a network blip. Once I laid the picture out, the cause was clear — the `INSERT` into the database and the `publish` to the queue were two separate systems, with no guarantee between them.\n\nThis post is the systems side of the **transactional outbox** pattern that closed that gap, the **at-least-once** repeats it opened in return, and finally encrypting the personal data that flows through the event at the **field level**. I wrote the decision-chain side as [a log on muhammetsafak.com.tr](https://muhammetsafak.com.tr/en/blog/from-dual-write-to-outbox-idempotent-consumption-and-field-encryption); here I collect the decisions you have to make for the pattern to be durable in production.\n\n## Dual-write: you can't stretch a transaction across two systems\n\nThe crux in one sentence: your local database transaction doesn't cover RabbitMQ. The classic code is dangerous *because it works most of the time*:\n\n```php\nDB::transaction(function () use ($invoice) {\n    $invoice->save();                 // 1) write to MySQL\n    $this->publishToRabbit($invoice); // 2) publish to the queue\n});\n```\n\nThere are two distinct failure modes:\n\n- `save()` succeeds, `publish` drops on a network error → the invoice is in the DB, the event isn't. **Lost event.**\n- `publish` succeeds, then the transaction `rollback`s for another reason → the event went out, no counterpart in the DB. **Phantom event.**\n\n`DB::transaction` doesn't save you, because commit/rollback only wraps the MySQL side; the broker isn't part of that transaction. The practical way to hold two systems in one atomic step is to reduce the write to **a single system**.\n\n## Outbox: write to one system first, let a separate process publish\n\nThe idea is plain: instead of pushing the message straight to the queue, write it as a row into an `outbox` table **inside the same transaction**. The invoice and the event either both exist or both don't, in the same commit — dual-write collapses into a single write.\n\n```sql\nCREATE TABLE outbox (\n    id            BINARY(16)   NOT NULL,        -- event id = idempotency key\n    aggregate_id  BIGINT       NOT NULL,        -- ordering and partition key\n    topic         VARCHAR(120) NOT NULL,\n    payload       JSON         NOT NULL,\n    status        ENUM('pending','published') NOT NULL DEFAULT 'pending',\n    created_at    DATETIME(6)  NOT NULL,\n    published_at  DATETIME(6)  NULL,\n    PRIMARY KEY (id),\n    KEY idx_dispatch (status, created_at)        -- the relay scan goes through this index\n);\n```\n\nThe write is no longer the business code's concern:\n\n```php\nDB::transaction(function () use ($invoice) {\n    $invoice->save();\n    Outbox::write('invoice.issued', $invoice->id, $this->payload($invoice));\n});\n```\n\nA separate **relay** does the queue push: it reads the `pending` rows, `publish`es them to RabbitMQ, and stamps them `published` on success. The critical fact here: the relay can fall into the \"I published but crashed before stamping it `published`\" state. So while the outbox solves dual-write, it gives you **no free guarantee** — it gives you **at-least-once**. The message isn't lost, but it can repeat. Everything else arranges itself around this fact.\n\n## How do you feed the relay: polling or CDC?\n\nThe relay can see `pending` rows two ways.\n\n**Polling** — the relay scans the table periodically:\n\n```sql\nSELECT id, topic, payload\nFROM outbox\nWHERE status = 'pending'\nORDER BY created_at\nLIMIT 100;\n```\n\nSimple, works on every database, low operational overhead. The cost is two things: **latency** up to the scan interval (a 1s poll = ~1s of queue latency) and an idling query. Without the `idx_dispatch` index this scan gets expensive as the table grows; even with it, you trade poll frequency against DB load. At small-to-medium volume, polling is the **right** answer — tune it frequent enough to keep latency acceptable, sparse enough not to hammer the DB (in practice 200ms–1s).\n\n**CDC** (change data capture) — the relay listens to the database's **WAL/binlog** rather than the table (Debezium is the typical tool). Every `INSERT` into `outbox` turns into an event almost instantly; polling latency and idle scanning disappear. The cost is operational: binlog access, a connector process, one more moving part. I turn CDC on when latency genuinely matters (sub-second) or when volume strains polling; otherwise I count polling's simplicity as an advantage.\n\n> Rule: your latency budget and your operational budget conflict. CDC buys you latency and charges you an infrastructure piece in return. Start with polling; switch to CDC when there's a measured reason.\n\n## Multiple relays: collision-free dispatch with `SKIP LOCKED`\n\nA single relay is a bottleneck and a single point of failure. Running multiple relays over the same table creates a new risk: two of them grab the same row and publish the same message twice. The naive fix is to lock the table — which kills the parallelism.\n\nThe right tool is `SELECT ... FOR UPDATE SKIP LOCKED`. Each relay claims a batch of rows **by locking them**; instead of queuing behind rows another relay has locked, it **skips** them:\n\n```sql\nBEGIN;\n  SELECT id, topic, payload\n  FROM outbox\n  WHERE status = 'pending'\n  ORDER BY created_at\n  LIMIT 100\n  FOR UPDATE SKIP LOCKED;     -- skip rows another relay holds, don't wait\n\n  -- publish this batch, then:\n  UPDATE outbox SET status = 'published', published_at = NOW(6)\n  WHERE id IN (...);\nCOMMIT;\n```\n\nWithout `SKIP LOCKED`, the relays queue for the same rows and the parallelism effectively runs serially. With it, each relay pulls a disjoint set and all of them move at once. PostgreSQL and MySQL 8+ support it.\n\nA subtlety: if the relay crashes between `publish` and `UPDATE ... published`, the row stays `pending` and gets **republished**. That's acceptable — we're already at at-least-once; the fix is on the consumer side, below. The dangerous order is the reverse: stamping `published` first and then `publish`ing. A crash there produces a **lost event** — exactly what we ran from. So the order is **fixed**: `publish` first, stamp second.\n\n## Ordering: what you can guarantee, what you can't\n\n\"Let events arrive in the order they were written\" is an intuitive expectation, but global ordering is expensive and usually unnecessary. What you actually need is **per-aggregate** order: the same invoice's `created` event should arrive before its `updated`; the order of two different invoices relative to each other is nobody's concern.\n\nYou get this by partitioning on `aggregate_id`: events with the same `aggregate_id` go to the same queue partition (or, in RabbitMQ, the same queue via a consistent-hash exchange), and a **single consumer** processes that partition in order. Different aggregates flow in parallel.\n\n```\noutbox (in created_at order)\n   │  hash(aggregate_id) % N\n   ├── partition 0 ──► consumer-0   (events for aggregates A,D — ordered)\n   ├── partition 1 ──► consumer-1   (events for aggregate B    — ordered)\n   └── partition 2 ──► consumer-2   (events for aggregate C    — ordered)\n```\n\nTwo things still break ordering, and the design has to be ready for them: multiple relays under `SKIP LOCKED` can publish rows in a different order, and at-least-once repeats can cut in. So rather than leaning on a global order, you write the consumer to be resilient to **out-of-order and duplicated** events. The practical shield: put a monotonic `version`/`sequence` on the event and drop the stale one (lower version) at the consumer. If you genuinely need strict global ordering you drop to a single partition + single consumer — which then ties throughput to that one consumer; most systems don't want to pay that.\n\n## Once you have at-least-once: the consumer must be idempotent\n\nBecause the relay can republish, you have to assume every event arrives at least once, sometimes more. The fix isn't to make the event unique — it's to set up the **consumer** so that processing the same event twice has the same effect as once. Write the `id` of every processed event into a dedupe table, and do the work **inside the same transaction**:\n\n```php\nDB::transaction(function () use ($event) {\n    $inserted = DB::table('processed_messages')->insertOrIgnore([\n        'message_id'   => $event['id'],\n        'processed_at' => now(),\n    ]);\n\n    if ($inserted === 0) {\n        return; // it's a repeat; produce no side effects\n    }\n\n    $this->applyBusinessEffect($event); // the real work — exactly once\n});\n```\n\nThe **unique** constraint on `processed_messages.message_id` is the heart of it: a second arrival of the same `id` is silently dropped by `insertOrIgnore`. Putting the business effect in the same transaction is mandatory — otherwise the \"I processed it but crashed before stamping\" gap opens. Where the dedupe key comes from, and designing a dedupe that survives a crash, is a topic of its own; I covered it in [a separate note](/en/notes/idempotency-duplicate-delivery).\n\nIf the side effect is an external system (an e-invoice integrator, a payment API), dedupe alone isn't enough — because the side effect is outside the transaction. There you have to **carry the key to the external service**: pass the same `id` as an idempotency key to the integrator, or ask \"does this already exist?\" before sending. I worked through that boundary in detail in [the JIT reservation post](/en/systems/race-conditions-and-gaps-in-sequential-numbering), where sealing the number before the external call turns recovery into a lookup.\n\n## `processed_messages` can't grow forever\n\nThe dedupe table accumulates a row per event; if you don't prune it, it becomes a performance problem of its own. The key observation: you don't need to keep an event `id` forever — only as long as the **window in which a repeat could arrive**. Relay retries and broker redeliveries are on the order of hours, not days.\n\nIn practice I pick a fixed **retention window** (say 7 days — comfortably above the longest possible redelivery) and prune older rows:\n\n```sql\nDELETE FROM processed_messages\nWHERE processed_at < NOW() - INTERVAL 7 DAY\nLIMIT 10000;   -- small batches at a time, keeping replica lag and locks in check\n```\n\nIf `message_id` is a UUID the table and its index grow; pruning keeps that bounded. When choosing the window, frame the rule backwards: *how late could the latest redelivery arrive?* Make a safe upper bound of that your retention. Set the window too short and a late repeat misses the dedupe, so the side effect runs a second time.\n\n## Why exactly-once is usually an illusion\n\nIn a distributed system \"exactly-once delivery\" is tempting but can't be guaranteed end to end. The reason is the same dual-write, at the broker boundary: if the consumer did the work but crashed before getting its `ack` to the broker, the broker redelivers the message — because it never saw the `ack`. \"I processed it\" and \"I reported that I processed it\" are two separate steps, and a crash can fall between them. This is the practical face of the Two Generals problem.\n\nSo the goal isn't exactly-once **delivery**, it's exactly-once **effect**. And you get that by combining two cheap guarantees:\n\n```\nat-least-once delivery  +  idempotent consumer  =  effectively-once\n```\n\nThat is, you stop trying to deliver exactly once and instead make the **repeat harmless**. Under systems that market \"exactly-once,\" this is usually exactly what's there: at-least-once plus a dedupe layer. Look for the guarantee in the effect, not in the delivery.\n\n## Personal data flowing through the event: field-level encryption\n\nOnce the outbox settles in, a new surface appears: the events' payload carries personal data — customer name, tax ID, address — and that payload now sits in a **durable** table (`outbox`) and passes through the broker. If I leave the data as-is, I'm copying GDPR/KVKK-scoped fields, unencrypted, into more than one place.\n\nI didn't want to encrypt the whole payload — I needed fields like `topic`, `aggregate_id`, and `invoice_id` in the clear for relay routing and observability. The need is **field-level** encryption: only the sensitive fields encrypted, the rest in the clear. I marked the sensitive fields in the schema with `x-gdpr-sensitive` and encrypted only those at serialization time:\n\n```php\n$schema = [\n    'invoice_id'    => ['x-gdpr-sensitive' => false],\n    'customer_name' => ['x-gdpr-sensitive' => true],\n    'tax_id'        => ['x-gdpr-sensitive' => true],\n    'total'         => ['x-gdpr-sensitive' => false],\n];\n\n$payload = collect($raw)->map(fn ($value, $field) =>\n    ($schema[$field]['x-gdpr-sensitive'] ?? false)\n        ? Crypt::encryptString((string) $value)  // only the sensitive field is encrypted\n        : $value\n)->all();\n```\n\nThat the schema declares the sensitive field is no accident — combine it with schema validation at the edge of the queue and both questions (\"which field is personal data, and is it the right type?\") get answered [at the edge](/en/notes/validate-schema-at-the-edge). The real engineering decision isn't the encryption itself, it's the three questions around it:\n\n**Key management and rotation.** Laravel's `Crypt` uses AES-256 keyed by `APP_KEY`. Bind yourself to a single key and rotation becomes a nightmare: the new key can't open outbox rows written with the old one. The fix is to store a **key version** (key id) alongside the encrypted value and keep a keyring that recognizes several keys at once — new writes use the current key, old values decrypt with their own version. In Laravel, `key` + `previous_keys` in `config/app.php` is exactly this; decryption tries the older keys in turn. That's why you can rotate without migrating data.\n\n**Searching an encrypted field.** `Crypt::encryptString` produces different ciphertext on every call (a random IV), which is correct for security but makes `WHERE tax_id = ?` impossible. If you need equality search you add a **blind index**: keep a deterministic HMAC of the searchable field in a separate column and query that. The encrypted column carries confidentiality, the blind index carries searchability; they're separate columns because they do two separate jobs.\n\n**Losing the key is losing the data.** Field encryption turns `APP_KEY` into an **availability** dependency: lose the key and the encrypted fields are permanent garbage. Key backup and access control are as much a part of the design as the encryption itself.\n\n## When do you not build this pattern?\n\nOutbox + idempotent consumption + field encryption isn't cheap: it brings a table, a relay process (or a CDC connector), a dedupe table and its pruning, plus key management. If you don't know what you're buying in return, don't pay for it. I don't build it when:\n\n- **Event loss is tolerable.** If what you publish is a best-effort notification (cache invalidation, a \"new content available\" signal) and losing one or two doesn't matter, a direct `publish` is enough. You build the outbox because loss matters.\n- **The write and the publish are in the same system.** If your target is the same database (there's no separate broker), there's no dual-write; you don't need an outbox.\n- **Volume fits a single consumer and ordering is critical.** At low volume a single-consumer queue gives you both order and uniqueness simply; building the multi-relay-and-partition machinery is solving a problem you don't have.\n\nThe need for the pattern starts when three conditions arise **together**: event loss is unacceptable, production/consumption is parallel, and the data goes to a separate system (a broker). If all three aren't present, there's a plainer solution.\n\n---\n\nThe three pieces come down to one discipline: **write the data to a single system, suppress repeats at the consumer, seal the sensitive field before it leaves.** The outbox makes trust in the queue unnecessary, idempotent consumption makes trust in delivery unnecessary, and field encryption makes trust in the payload unnecessary — each moves a guarantee out of the code and into the design.",
      "date_published": "2026-06-22T00:00:00.000Z",
      "tags": [
        "architecture",
        "messaging",
        "idempotency",
        "reliability",
        "security",
        "System"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/notes/when-event-driven-architecture/",
      "url": "https://sade.dev/en/notes/when-event-driven-architecture/",
      "title": "Event-Driven Architecture: What It Solves and Hides",
      "summary": "Events buy loose coupling by letting the producer stay unaware of its consumers, and the same move makes the flow invisible: no compiler verifies an event contract the way it verifies a method call. Two costs are paid up front or not at all — versioned, schema-validated payloads, and a correlation id carried into every log and every downstream event. A single-listener event is a method call in disguise.",
      "content_html": "<p>I once watched a team where no one could give a straight answer to “what happens when an order is created?” In the codebase, eight separate listeners were subscribed to the <code>OrderCreated</code> event; you couldn’t read from any single place which one ran in which order, under which condition. The flow didn’t live in the code — it lived in people’s heads, and incompletely at that.</p>\n<p>Event-driven architecture loosens coupling. This post is a reminder that something else loosens along with it: causality.</p>\n<h2 id=\"what-do-events-solve\">What do events solve?</h2>\n<p>Code that publishes an event doesn’t know who listens to it. <code>OrderCreated</code> is published; billing, notifications, and analytics each listen to it separately. The order code is unaware of all three.</p>\n<p>The benefit is real: adding a new consumer happens without touching the producer. Independent development, independent deployment, fan-out. Parts that don’t know each other.</p>\n<h2 id=\"what-do-events-hide\">What do events hide?</h2>\n<p>The flip side of the same coin: as coupling loosens, <strong>the flow becomes invisible</strong>.</p>\n<p>In a direct method call, the flow <em>is</em> the code — you go to the definition and read on. Not so with an event. To see what happens when <code>OrderCreated</code> is published, you have to find every listener by hand. The compiler won’t help you: a compiler verifies the contract of a method call, but no one verifies the contract of an event.</p>\n<p>The line from <a href=\"/en/journal/why-i-start-with-a-modular-monolith/\">the modular monolith post</a> applies here too — like a network contract, an event contract sits in the compiler’s blind spot.</p>\n<h2 id=\"dont-choose-it-before-paying-two-prerequisites\">Don’t choose it before paying: two prerequisites</h2>\n<p>Before moving to an event-driven architecture, you pay two costs up front.</p>\n<p><strong>Schema discipline.</strong> An event is a contract. When the payload of <code>OrderCreated</code> changes, the eight places listening to it won’t know — not until it breaks in production. Events must be versioned, fields must be added in a backward-compatible way, payloads must be validated against a schema. Without this discipline, loose coupling means “silent breakage.”</p>\n<p><strong>Traceability.</strong> To be able to see a request travel through the system, every event must carry a correlation ID:</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"php\"><code><span class=\"line\"><span style=\"color:#59873A;--shiki-dark:#80A665\">event</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#1E754F;--shiki-dark:#4D9375\">new</span><span style=\"color:#998418;--shiki-dark:#B8A965\"> OrderCreated</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span></span>\n<span class=\"line\"><span style=\"color:#59873A;--shiki-dark:#80A665\">    orderId</span><span style=\"color:#999999;--shiki-dark:#666666\">:</span><span style=\"color:#999999;--shiki-dark:#666666\"> $</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">order</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#B07D48;--shiki-dark:#BD976A\">id</span><span style=\"color:#999999;--shiki-dark:#666666\">,</span></span>\n<span class=\"line\"><span style=\"color:#59873A;--shiki-dark:#80A665\">    correlationId</span><span style=\"color:#999999;--shiki-dark:#666666\">:</span><span style=\"color:#59873A;--shiki-dark:#80A665\"> request</span><span style=\"color:#999999;--shiki-dark:#666666\">()</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">-&gt;</span><span style=\"color:#59873A;--shiki-dark:#80A665\">header</span><span style=\"color:#999999;--shiki-dark:#666666\">(</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#B56959;--shiki-dark:#C98A7D\">X-Correlation-Id</span><span style=\"color:#B5695977;--shiki-dark:#C98A7D77\">&#39;</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\"> ??</span><span style=\"color:#999999;--shiki-dark:#666666\"> (</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">string</span><span style=\"color:#999999;--shiki-dark:#666666\">)</span><span style=\"color:#998418;--shiki-dark:#B8A965\"> Str</span><span style=\"color:#AB5959;--shiki-dark:#CB7676\">::</span><span style=\"color:#59873A;--shiki-dark:#80A665\">uuid</span><span style=\"color:#999999;--shiki-dark:#666666\">(),</span></span>\n<span class=\"line\"><span style=\"color:#999999;--shiki-dark:#666666\">));</span></span></code></pre>\n<p>If this ID isn’t carried into the logs and into subsequent events, then in a multi-listener flow there is no answer to “why did this job run?” Tracing isn’t an optional add-on to event-driven; it’s a prerequisite.</p>\n<h2 id=\"in-laravel-in-process-events--event-driven-architecture\">In Laravel: in-process events ≠ event-driven architecture</h2>\n<p>Laravel’s <code>event()</code>/listener mechanism, when it runs synchronously, is really an organized method call — same process, same transaction, same stack trace. This is safe and traceable; use it freely.</p>\n<p>The difficulties event-driven architecture brings start when an event crosses a <strong>boundary</strong>: into a queue, into a message broker, into another process. The moment you add <code>ShouldQueue</code> to a listener and send the event to RabbitMQ, the schema and tracing debt kicks in. Don’t conflate the two: using in-process listeners does not move you into “event-driven architecture.”</p>\n<h2 id=\"when-should-you-move-to-event-driven\">When should you move to event-driven?</h2>\n<p>Actually carrying an event across a boundary is justified in these cases:</p>\n<ul>\n<li><strong>There’s a real fan-out.</strong> A single event is listened to by many independent consumers that shouldn’t know about each other.</li>\n<li><strong>The consumer must be asynchronous.</strong> The work shouldn’t keep the producer waiting for a response; “accepted” is enough.</li>\n<li><strong>The consumers must scale or be distributed independently.</strong> This also brings us to <a href=\"/en/journal/when-i-move-to-microservices/\">the microservices decision</a> — the same measured-signal threshold.</li>\n</ul>\n<p>Without these, a direct service call is both more readable and safer. An event with a single listener is just a method call in disguise — and a harder one to trace, at that.</p>\n<hr/>\n<p>Event-driven architecture loosens coupling; in doing so, it loosens causality too. If you want the first, pay the bill for the second — schema and tracing — up front.</p>\n<p>Loose coupling isn’t free; you pay for it with invisible flow.</p>",
      "content_text": "I once watched a team where no one could give a straight answer to \"what happens when an order is created?\" In the codebase, eight separate listeners were subscribed to the `OrderCreated` event; you couldn't read from any single place which one ran in which order, under which condition. The flow didn't live in the code — it lived in people's heads, and incompletely at that.\n\nEvent-driven architecture loosens coupling. This post is a reminder that something else loosens along with it: causality.\n\n## What do events solve?\n\nCode that publishes an event doesn't know who listens to it. `OrderCreated` is published; billing, notifications, and analytics each listen to it separately. The order code is unaware of all three.\n\nThe benefit is real: adding a new consumer happens without touching the producer. Independent development, independent deployment, fan-out. Parts that don't know each other.\n\n## What do events hide?\n\nThe flip side of the same coin: as coupling loosens, **the flow becomes invisible**.\n\nIn a direct method call, the flow *is* the code — you go to the definition and read on. Not so with an event. To see what happens when `OrderCreated` is published, you have to find every listener by hand. The compiler won't help you: a compiler verifies the contract of a method call, but no one verifies the contract of an event.\n\nThe line from [the modular monolith post](/en/journal/why-i-start-with-a-modular-monolith) applies here too — like a network contract, an event contract sits in the compiler's blind spot.\n\n## Don't choose it before paying: two prerequisites\n\nBefore moving to an event-driven architecture, you pay two costs up front.\n\n**Schema discipline.** An event is a contract. When the payload of `OrderCreated` changes, the eight places listening to it won't know — not until it breaks in production. Events must be versioned, fields must be added in a backward-compatible way, payloads must be validated against a schema. Without this discipline, loose coupling means \"silent breakage.\"\n\n**Traceability.** To be able to see a request travel through the system, every event must carry a correlation ID:\n\n```php\nevent(new OrderCreated(\n    orderId: $order->id,\n    correlationId: request()->header('X-Correlation-Id') ?? (string) Str::uuid(),\n));\n```\n\nIf this ID isn't carried into the logs and into subsequent events, then in a multi-listener flow there is no answer to \"why did this job run?\" Tracing isn't an optional add-on to event-driven; it's a prerequisite.\n\n## In Laravel: in-process events ≠ event-driven architecture\n\nLaravel's `event()`/listener mechanism, when it runs synchronously, is really an organized method call — same process, same transaction, same stack trace. This is safe and traceable; use it freely.\n\nThe difficulties event-driven architecture brings start when an event crosses a **boundary**: into a queue, into a message broker, into another process. The moment you add `ShouldQueue` to a listener and send the event to RabbitMQ, the schema and tracing debt kicks in. Don't conflate the two: using in-process listeners does not move you into \"event-driven architecture.\"\n\n## When should you move to event-driven?\n\nActually carrying an event across a boundary is justified in these cases:\n\n- **There's a real fan-out.** A single event is listened to by many independent consumers that shouldn't know about each other.\n- **The consumer must be asynchronous.** The work shouldn't keep the producer waiting for a response; \"accepted\" is enough.\n- **The consumers must scale or be distributed independently.** This also brings us to [the microservices decision](/en/journal/when-i-move-to-microservices) — the same measured-signal threshold.\n\nWithout these, a direct service call is both more readable and safer. An event with a single listener is just a method call in disguise — and a harder one to trace, at that.\n\n---\n\nEvent-driven architecture loosens coupling; in doing so, it loosens causality too. If you want the first, pay the bill for the second — schema and tracing — up front.\n\nLoose coupling isn't free; you pay for it with invisible flow.",
      "date_published": "2026-06-20T00:00:00.000Z",
      "tags": [
        "architecture",
        "event-driven",
        "messaging",
        "Note"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/systems/gpu-finops-with-ebpf/",
      "url": "https://sade.dev/en/systems/gpu-finops-with-ebpf/",
      "title": "GPU FinOps with eBPF",
      "summary": "You are billed for a whole GPU and you use 3% of it — and your monitoring cannot tell you whose 3% it was. eBPF attributes GPU work to a pod by watching the syscall boundary; DCGM measures how well the silicon ran. They answer different questions, and a real chargeback needs both.",
      "content_html": "<p>A GPU node costs more per hour than a rack of CPU machines, and the question finance asks about it is the same question they ask about everything: <strong>who used this, and how much?</strong> For CPU, memory, and disk you can answer that — cgroups account it per process, cAdvisor reports it per pod, Prometheus rolls it up per team. For the GPU, the honest answer in most clusters is that nobody knows. You are paying for the most expensive resource in the fleet and you cannot attribute a dollar of it.</p>\n<p>This is not a tooling gap you forgot to fill. It is structural: the GPU is <strong>outside the accounting plane the rest of your stack relies on.</strong> This piece is about why, and about using eBPF to get attribution back — along with a hard line about what eBPF can and cannot see, because the most expensive mistake here is believing one tool answers a question it physically cannot.</p>\n<h2 id=\"why-is-the-gpu-invisible-to-your-existing-metrics\">Why is the GPU invisible to your existing metrics?</h2>\n<p>Three separate blindnesses stack up.</p>\n<p><strong>cgroups don’t account the GPU.</strong> A cgroup is a kernel construct that accounts and limits CPU, memory, block I/O, and PIDs. The GPU is none of those. Compute and framebuffer (VRAM) are managed by the NVIDIA kernel driver and the userspace CUDA runtime, and are opaque to cgroup accounting. There is a <code>device</code> cgroup controller, but it only <em>gates access</em> to the device nodes (<code>/dev/nvidia0</code> and friends) — allow or deny. It does not account a single second of GPU time or a megabyte of VRAM. So the entire cAdvisor → Prometheus pipeline that gives you per-pod CPU and memory has, by construction, <strong>nothing to say about the GPU.</strong></p>\n<p><strong><code>nvidia.com/gpu</code> is an allocation count, not a utilization signal.</strong> Kubernetes learns about GPUs through the NVIDIA device plugin, which registers them to the kubelet as the extended resource <code>nvidia.com/gpu</code>. Pods request it as an integer — whole GPUs. The scheduler matches requested <em>count</em> to available <em>count</em>. That is the only thing the allocation layer knows. <strong>Requesting a GPU is not using a GPU.</strong> A pod can hold a whole device — and be billed for the whole device — while driving it at 3%. The allocation number you could charge back against is precisely the number that tells you nothing about consumption.</p>\n<p><strong>Even <code>nvidia-smi</code>’s utilization number lies to you.</strong> Reach for <code>nvidia-smi</code> and the <code>utilization.gpu</code> field looks like salvation. It is not what you think. NVML defines it as <em>the percent of time over the sample period during which one or more kernels was executing.</em> It measures <strong>temporal presence — was the GPU busy at all — not how much of it was busy.</strong> A single-thread kernel occupying one SM out of dozens can report close to 100%. Microsoft has reported under 10% compute utilization during the memory-bound decode phase of serving an 8B-parameter model on A100s — while a naive reading of “utilization” would call those GPUs full. If you charge back on <code>utilization.gpu</code>, you are billing on a number that says “busy” when the silicon is nearly idle.</p>\n<p>So: the allocation layer knows count-not-use, cgroups know nothing, and the one easy percentage is measuring the wrong thing. That is the gap.</p>\n<h2 id=\"two-questions-and-why-they-need-different-tools\">Two questions, and why they need different tools</h2>\n<p>Before any tooling, separate the two questions, because conflating them is the core error — the same shape as <a href=\"/en/systems/data-intensive-systems-breaking-points/\">confusing the problem a tool solves with the problem at hand</a>.</p>\n<ul>\n<li><strong>Attribution — <em>who</em> did GPU work, and <em>how much</em>?</strong> This is the FinOps question. It needs per-pod, per-team accounting of GPU activity: which workload launched kernels, allocated VRAM, moved data, and for how long. This is what chargeback runs on.</li>\n<li><strong>Efficiency — <em>how well</em> was the silicon used?</strong> Were the SMs actually occupied, were the tensor cores active, was memory bandwidth the bottleneck? This is the performance question. It tells you whether a team’s spend was <em>justified</em>, but it cannot tell you <em>whose</em> spend it was.</li>\n</ul>\n<p>These map onto two different measurement planes, and the rule that organizes everything below is: <strong>eBPF answers attribution; the GPU’s own counters answer efficiency. Neither substitutes for the other.</strong></p>\n<h2 id=\"what-ebpf-can-see-the-control-plane\">What eBPF can see: the control plane</h2>\n<p>eBPF runs in the kernel and attaches to kprobes, uprobes, tracepoints, and syscalls. There is no GPU-utilization tracepoint to read — but there are two surfaces where GPU <em>work is requested</em>, and both are visible from the kernel:</p>\n<ul>\n<li><strong>The driver ioctl boundary.</strong> Every piece of GPU work — kernel submission, memory allocation, synchronization — ultimately becomes an <code>ioctl()</code> to <code>/dev/nvidiactl</code> and <code>/dev/nvidia0…N</code>. A kprobe on the driver’s entry points (<code>nvidia_unlocked_ioctl</code>, <code>nvidia_open</code>) sees that traffic. (The closed driver historically exposes only one tracepoint, <code>nvidia:nvidia_dev_xid</code>, for hardware error events; everything else is kprobed.)</li>\n<li><strong>The CUDA library boundary.</strong> A uprobe on <code>libcuda.so</code> / <code>libcudart.so</code> traces the API itself: <code>cuLaunchKernel</code>, <code>cuMemAlloc</code>, <code>cuMemcpyHtoD</code>, <code>cuStreamSynchronize</code>, and friends. Pairing an entry uprobe with a return uretprobe measures each call’s duration.</li>\n</ul>\n<p>The reason this is <em>attribution</em> is that at every hook point eBPF reads the calling PID/TGID and cgroup natively — <code>bpf_get_current_pid_tgid</code> and the cgroup id are right there. That gives you the chain <strong>PID → cgroup → pod → namespace → team</strong> with no application changes and no cooperation from NVIDIA. Per pod you get: kernel-launch counts, launch dimensions, VRAM allocation sizes, memcpy volume and direction, and call timing. That is a real, defensible chargeback signal derived entirely from the kernel side of the boundary.</p>\n<p>The ecosystem here is young but real. The primitives work today — there are working write-ups and tutorials uprobing the CUDA libraries and kprobing the nvidia ioctl path, and <code>bpftime</code> explores tying eBPF logic to GPU events. Tetragon (Cilium) ships generic <code>process_uprobe</code>, <code>process_kprobe</code>, and ioctl tracing with Kubernetes pod identity, so it <em>can</em> be pointed at the CUDA symbols or the ioctl path — but understand that this is a <code>TracingPolicy</code> you author, not a shipped “GPU FinOps” feature. There is no dominant turnkey eBPF chargeback product yet. Anyone selling you “drop-in eBPF GPU FinOps” is selling further than the ecosystem currently reaches.</p>\n<h2 id=\"what-ebpf-cannot-see-the-silicon\">What eBPF cannot see: the silicon</h2>\n<p>This is the honest limit, and it is not a maturity problem that will be fixed in a release — it is physics of where the data lives.</p>\n<p>eBPF sees the <strong>control plane</strong>: API calls, ioctls, allocation sizes, launch counts, and submit-to-complete timing where you can trace it. It does <strong>not</strong> see inside the GPU. It cannot read SM occupancy, tensor-core utilization, or achieved memory bandwidth, because those are hardware performance counters that live <em>on the device</em> and are exposed only through NVML / DCGM / CUPTI. eBPF has no path to them. It can tell you a pod launched ten thousand kernels and allocated 40 GB of VRAM; it cannot tell you whether those kernels saturated the SMs or left them 90% idle.</p>\n<p>There is a second, subtler limit. Even at the ioctl boundary eBPF can hook, the <em>payloads</em> are largely opaque. The driver’s command structures (the <code>NV_ESC_*</code> Resource Manager API) are complex and effectively proprietary. You can see <em>that</em> an ioctl happened — its command number, the calling PID, the timing — but decoding the semantic content of an arbitrary RM payload is impractical and fragile. You get the fact of the work and its attribution, not a free reading of its meaning.</p>\n<p>A note that closes a tempting door: NVIDIA’s open kernel modules (<code>open-gpu-kernel-modules</code>, Turing and newer) open the <strong>kernel interface layer</strong> — the module init, the ioctl entry points, the <code>NV_ESC_*</code> command surface. That genuinely helps you understand <em>what</em> to hook. But the GPU’s brain stays closed: on Turing+ much of the management runs on on-GPU GSP firmware, and that firmware — along with the user-mode driver components the modules require — is still shipped closed. Opening the kernel modules <strong>does not</strong> expose hardware performance counters to eBPF. The on-device limit is unchanged. If someone claims the open modules let eBPF read SM occupancy, they are wrong on current evidence.</p>\n<h2 id=\"the-userspace-path-you-still-need-dcgm\">The userspace path you still need: DCGM</h2>\n<p>Because eBPF cannot see the silicon, the efficiency half of the answer comes from userspace, and the standard tool is NVIDIA <strong>DCGM</strong> (Data Center GPU Manager) with <strong>dcgm-exporter</strong> for Prometheus. DCGM reads the GPU’s hardware counters through the driver and exposes the fields eBPF can’t reach:</p>\n<ul>\n<li><code>DCGM_FI_PROF_SM_ACTIVE</code> — fraction of time at least one warp was active on a multiprocessor, averaged across all of them.</li>\n<li><code>DCGM_FI_PROF_SM_OCCUPANCY</code> — fraction of resident warps relative to the maximum supported: <em>true</em> occupancy.</li>\n<li><code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE</code> — fraction of cycles the tensor pipe was active.</li>\n<li><code>DCGM_FI_PROF_DRAM_ACTIVE</code> — a memory-bandwidth proxy.</li>\n<li><code>DCGM_FI_DEV_FB_USED</code> — framebuffer (VRAM) actually in use.</li>\n</ul>\n<p>These are the numbers that tell you whether a team’s expensive allocation was earning its keep. DCGM attributes them to pods through the kubelet <strong>Pod Resources API</strong> (a gRPC service at <code>/var/lib/kubelet/pod-resources</code>), which maps each GPU UUID to the pod that holds it.</p>\n<p>But DCGM’s attribution has a hard edge that is exactly where eBPF earns its place: <strong>DCGM produces device-level metrics, so it cannot disambiguate consumers sharing one physical GPU.</strong> Under time-slicing or MPS (Multi-Process Service), all pods sharing a GPU receive <em>identical, duplicated</em> device-level values — including <code>DCGM_FI_DEV_FB_USED</code>. NVIDIA’s own exporter documents that it does not associate metrics to containers when time-slicing is enabled; with <code>--kubernetes-virtual-gpus=true</code>, every sharing pod mirrors the whole physical GPU’s state. Under MIG, attribution shifts to the GPU-instance level, not arbitrary pods. So precisely in the shared-GPU case — the case that exists <em>because</em> whole-GPU allocation wastes money — DCGM cannot tell you who consumed what. eBPF’s PID-level tracing can. The two tools are complementary at exactly the seam where each is weakest.</p>\n<p>(For the deepest efficiency profiling there is <strong>CUPTI</strong>, the CUDA Profiling Tools Interface, which can read counters DCGM doesn’t surface — but metric-replay profiling like Nsight Compute re-runs kernels multiple times and carries heavy overhead. It is a profiling-session tool, not always-on per-tenant telemetry. DCGM is the lower-overhead, sampling-based continuous path; CUPTI is the heavy, deep one.)</p>\n<h2 id=\"the-kernel-vs-userspace-tradeoff-stated-plainly\">The kernel-vs-userspace tradeoff, stated plainly</h2>\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n<div class=\"table-wrap\"><table><thead><tr><th>Plane</th><th>Tool</th><th>Sees</th><th>Cannot see</th></tr></thead><tbody><tr><td>Kernel (control)</td><td>eBPF</td><td>PID→pod attribution, launch counts, alloc sizes, memcpy volume, call timing — <em>including</em> per-pod under time-slicing</td><td>on-device SM/tensor occupancy, memory bandwidth, ioctl payload internals</td></tr><tr><td>Userspace (device)</td><td>DCGM</td><td>true SM occupancy, tensor activity, DRAM activity, VRAM used — from hardware counters</td><td>per-pod attribution under shared GPU (time-slicing/MPS): all sharers get identical values</td></tr></tbody></table></div>\n<p>eBPF is low-overhead, needs no app changes, needs no vendor cooperation, and attributes natively by PID/cgroup — it is the right tool for <em>who and how much</em>. DCGM reads the silicon — it is the right tool for <em>how well</em>. The costs are honest too: uprobes carry real overhead and the CUDA symbols are <strong>versioned</strong> (<code>cuMemAlloc@CUDA_11.0</code> vs <code>@CUDA_12.0</code>), so probes need dynamic symbol resolution and break across driver upgrades; CUDA API calls can fire ten-thousand-plus times a second, so handlers must stay cheap or they slow the very workload they measure. DCGM is userspace polling with its own sampling overhead and a hardware constraint that only certain counter groups can be read together.</p>\n<h2 id=\"why-you-need-both-in-order\">Why you need both, in order</h2>\n<p>The wrong turn is picking one tool and asking it the other tool’s question — billing teams on DCGM’s device-level numbers and silently overcharging everyone who shares a GPU, or trusting eBPF’s launch counts as a proxy for efficiency and concluding a busy-looking workload was well-utilized. The order that actually works:</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"plaintext\"><code><span class=\"line\"><span>1. eBPF: attribute GPU work to pod/team        (who, how much — the bill)</span></span>\n<span class=\"line\"><span>2. DCGM: measure on-device efficiency          (how well — was it justified)</span></span>\n<span class=\"line\"><span>3. Join them on pod identity                    (the team&#39;s spend AND its efficiency)</span></span></code></pre>\n<p>Attribution first, because that is the question finance actually asked and the one your existing stack cannot answer at all. Efficiency second, because it turns the bill into a decision — a team holding an expensive allocation at 4% occupancy is paying a <a href=\"/en/journal/the-cost-of-just-in-case-code/\">just-in-case capacity tax</a> you can now <em>see</em>, where before the GPU was a flat line item nobody could open. Joined on pod identity, you finally have what you have for every other resource: spend, attributed, with the efficiency context to act on it. The reason it took two planes is the same reason it was invisible — the silicon never lived in the accounting plane, and no single probe spans the boundary.</p>\n<hr/>\n<p><em>See also:</em> the <a href=\"https://docs.nvidia.com/datacenter/dcgm/latest/installation/install-dcgm-exporter.html\">DCGM exporter docs</a> cover the pod-attribution path and the <a href=\"https://docs.nvidia.com/datacenter/dcgm/latest/user-guide/feature-overview.html\">DCGM feature overview</a> defines the profiling fields, and the eBPF primitives this leans on are documented at <a href=\"https://ebpf.io/what-is-ebpf/\">ebpf.io</a> and in <a href=\"https://tetragon.io/\">Tetragon</a>.</p>",
      "content_text": "A GPU node costs more per hour than a rack of CPU machines, and the question finance asks about it is the same question they ask about everything: **who used this, and how much?** For CPU, memory, and disk you can answer that — cgroups account it per process, cAdvisor reports it per pod, Prometheus rolls it up per team. For the GPU, the honest answer in most clusters is that nobody knows. You are paying for the most expensive resource in the fleet and you cannot attribute a dollar of it.\n\nThis is not a tooling gap you forgot to fill. It is structural: the GPU is **outside the accounting plane the rest of your stack relies on.** This piece is about why, and about using eBPF to get attribution back — along with a hard line about what eBPF can and cannot see, because the most expensive mistake here is believing one tool answers a question it physically cannot.\n\n## Why is the GPU invisible to your existing metrics?\n\nThree separate blindnesses stack up.\n\n**cgroups don't account the GPU.** A cgroup is a kernel construct that accounts and limits CPU, memory, block I/O, and PIDs. The GPU is none of those. Compute and framebuffer (VRAM) are managed by the NVIDIA kernel driver and the userspace CUDA runtime, and are opaque to cgroup accounting. There is a `device` cgroup controller, but it only *gates access* to the device nodes (`/dev/nvidia0` and friends) — allow or deny. It does not account a single second of GPU time or a megabyte of VRAM. So the entire cAdvisor → Prometheus pipeline that gives you per-pod CPU and memory has, by construction, **nothing to say about the GPU.**\n\n**`nvidia.com/gpu` is an allocation count, not a utilization signal.** Kubernetes learns about GPUs through the NVIDIA device plugin, which registers them to the kubelet as the extended resource `nvidia.com/gpu`. Pods request it as an integer — whole GPUs. The scheduler matches requested *count* to available *count*. That is the only thing the allocation layer knows. **Requesting a GPU is not using a GPU.** A pod can hold a whole device — and be billed for the whole device — while driving it at 3%. The allocation number you could charge back against is precisely the number that tells you nothing about consumption.\n\n**Even `nvidia-smi`'s utilization number lies to you.** Reach for `nvidia-smi` and the `utilization.gpu` field looks like salvation. It is not what you think. NVML defines it as *the percent of time over the sample period during which one or more kernels was executing.* It measures **temporal presence — was the GPU busy at all — not how much of it was busy.** A single-thread kernel occupying one SM out of dozens can report close to 100%. Microsoft has reported under 10% compute utilization during the memory-bound decode phase of serving an 8B-parameter model on A100s — while a naive reading of \"utilization\" would call those GPUs full. If you charge back on `utilization.gpu`, you are billing on a number that says \"busy\" when the silicon is nearly idle.\n\nSo: the allocation layer knows count-not-use, cgroups know nothing, and the one easy percentage is measuring the wrong thing. That is the gap.\n\n## Two questions, and why they need different tools\n\nBefore any tooling, separate the two questions, because conflating them is the core error — the same shape as [confusing the problem a tool solves with the problem at hand](/en/systems/data-intensive-systems-breaking-points).\n\n- **Attribution — *who* did GPU work, and *how much*?** This is the FinOps question. It needs per-pod, per-team accounting of GPU activity: which workload launched kernels, allocated VRAM, moved data, and for how long. This is what chargeback runs on.\n- **Efficiency — *how well* was the silicon used?** Were the SMs actually occupied, were the tensor cores active, was memory bandwidth the bottleneck? This is the performance question. It tells you whether a team's spend was *justified*, but it cannot tell you *whose* spend it was.\n\nThese map onto two different measurement planes, and the rule that organizes everything below is: **eBPF answers attribution; the GPU's own counters answer efficiency. Neither substitutes for the other.**\n\n## What eBPF can see: the control plane\n\neBPF runs in the kernel and attaches to kprobes, uprobes, tracepoints, and syscalls. There is no GPU-utilization tracepoint to read — but there are two surfaces where GPU *work is requested*, and both are visible from the kernel:\n\n- **The driver ioctl boundary.** Every piece of GPU work — kernel submission, memory allocation, synchronization — ultimately becomes an `ioctl()` to `/dev/nvidiactl` and `/dev/nvidia0…N`. A kprobe on the driver's entry points (`nvidia_unlocked_ioctl`, `nvidia_open`) sees that traffic. (The closed driver historically exposes only one tracepoint, `nvidia:nvidia_dev_xid`, for hardware error events; everything else is kprobed.)\n- **The CUDA library boundary.** A uprobe on `libcuda.so` / `libcudart.so` traces the API itself: `cuLaunchKernel`, `cuMemAlloc`, `cuMemcpyHtoD`, `cuStreamSynchronize`, and friends. Pairing an entry uprobe with a return uretprobe measures each call's duration.\n\nThe reason this is *attribution* is that at every hook point eBPF reads the calling PID/TGID and cgroup natively — `bpf_get_current_pid_tgid` and the cgroup id are right there. That gives you the chain **PID → cgroup → pod → namespace → team** with no application changes and no cooperation from NVIDIA. Per pod you get: kernel-launch counts, launch dimensions, VRAM allocation sizes, memcpy volume and direction, and call timing. That is a real, defensible chargeback signal derived entirely from the kernel side of the boundary.\n\nThe ecosystem here is young but real. The primitives work today — there are working write-ups and tutorials uprobing the CUDA libraries and kprobing the nvidia ioctl path, and `bpftime` explores tying eBPF logic to GPU events. Tetragon (Cilium) ships generic `process_uprobe`, `process_kprobe`, and ioctl tracing with Kubernetes pod identity, so it *can* be pointed at the CUDA symbols or the ioctl path — but understand that this is a `TracingPolicy` you author, not a shipped \"GPU FinOps\" feature. There is no dominant turnkey eBPF chargeback product yet. Anyone selling you \"drop-in eBPF GPU FinOps\" is selling further than the ecosystem currently reaches.\n\n## What eBPF cannot see: the silicon\n\nThis is the honest limit, and it is not a maturity problem that will be fixed in a release — it is physics of where the data lives.\n\neBPF sees the **control plane**: API calls, ioctls, allocation sizes, launch counts, and submit-to-complete timing where you can trace it. It does **not** see inside the GPU. It cannot read SM occupancy, tensor-core utilization, or achieved memory bandwidth, because those are hardware performance counters that live *on the device* and are exposed only through NVML / DCGM / CUPTI. eBPF has no path to them. It can tell you a pod launched ten thousand kernels and allocated 40 GB of VRAM; it cannot tell you whether those kernels saturated the SMs or left them 90% idle.\n\nThere is a second, subtler limit. Even at the ioctl boundary eBPF can hook, the *payloads* are largely opaque. The driver's command structures (the `NV_ESC_*` Resource Manager API) are complex and effectively proprietary. You can see *that* an ioctl happened — its command number, the calling PID, the timing — but decoding the semantic content of an arbitrary RM payload is impractical and fragile. You get the fact of the work and its attribution, not a free reading of its meaning.\n\nA note that closes a tempting door: NVIDIA's open kernel modules (`open-gpu-kernel-modules`, Turing and newer) open the **kernel interface layer** — the module init, the ioctl entry points, the `NV_ESC_*` command surface. That genuinely helps you understand *what* to hook. But the GPU's brain stays closed: on Turing+ much of the management runs on on-GPU GSP firmware, and that firmware — along with the user-mode driver components the modules require — is still shipped closed. Opening the kernel modules **does not** expose hardware performance counters to eBPF. The on-device limit is unchanged. If someone claims the open modules let eBPF read SM occupancy, they are wrong on current evidence.\n\n## The userspace path you still need: DCGM\n\nBecause eBPF cannot see the silicon, the efficiency half of the answer comes from userspace, and the standard tool is NVIDIA **DCGM** (Data Center GPU Manager) with **dcgm-exporter** for Prometheus. DCGM reads the GPU's hardware counters through the driver and exposes the fields eBPF can't reach:\n\n- `DCGM_FI_PROF_SM_ACTIVE` — fraction of time at least one warp was active on a multiprocessor, averaged across all of them.\n- `DCGM_FI_PROF_SM_OCCUPANCY` — fraction of resident warps relative to the maximum supported: *true* occupancy.\n- `DCGM_FI_PROF_PIPE_TENSOR_ACTIVE` — fraction of cycles the tensor pipe was active.\n- `DCGM_FI_PROF_DRAM_ACTIVE` — a memory-bandwidth proxy.\n- `DCGM_FI_DEV_FB_USED` — framebuffer (VRAM) actually in use.\n\nThese are the numbers that tell you whether a team's expensive allocation was earning its keep. DCGM attributes them to pods through the kubelet **Pod Resources API** (a gRPC service at `/var/lib/kubelet/pod-resources`), which maps each GPU UUID to the pod that holds it.\n\nBut DCGM's attribution has a hard edge that is exactly where eBPF earns its place: **DCGM produces device-level metrics, so it cannot disambiguate consumers sharing one physical GPU.** Under time-slicing or MPS (Multi-Process Service), all pods sharing a GPU receive *identical, duplicated* device-level values — including `DCGM_FI_DEV_FB_USED`. NVIDIA's own exporter documents that it does not associate metrics to containers when time-slicing is enabled; with `--kubernetes-virtual-gpus=true`, every sharing pod mirrors the whole physical GPU's state. Under MIG, attribution shifts to the GPU-instance level, not arbitrary pods. So precisely in the shared-GPU case — the case that exists *because* whole-GPU allocation wastes money — DCGM cannot tell you who consumed what. eBPF's PID-level tracing can. The two tools are complementary at exactly the seam where each is weakest.\n\n(For the deepest efficiency profiling there is **CUPTI**, the CUDA Profiling Tools Interface, which can read counters DCGM doesn't surface — but metric-replay profiling like Nsight Compute re-runs kernels multiple times and carries heavy overhead. It is a profiling-session tool, not always-on per-tenant telemetry. DCGM is the lower-overhead, sampling-based continuous path; CUPTI is the heavy, deep one.)\n\n## The kernel-vs-userspace tradeoff, stated plainly\n\n| Plane | Tool | Sees | Cannot see |\n| --- | --- | --- | --- |\n| Kernel (control) | eBPF | PID→pod attribution, launch counts, alloc sizes, memcpy volume, call timing — *including* per-pod under time-slicing | on-device SM/tensor occupancy, memory bandwidth, ioctl payload internals |\n| Userspace (device) | DCGM | true SM occupancy, tensor activity, DRAM activity, VRAM used — from hardware counters | per-pod attribution under shared GPU (time-slicing/MPS): all sharers get identical values |\n\neBPF is low-overhead, needs no app changes, needs no vendor cooperation, and attributes natively by PID/cgroup — it is the right tool for *who and how much*. DCGM reads the silicon — it is the right tool for *how well*. The costs are honest too: uprobes carry real overhead and the CUDA symbols are **versioned** (`cuMemAlloc@CUDA_11.0` vs `@CUDA_12.0`), so probes need dynamic symbol resolution and break across driver upgrades; CUDA API calls can fire ten-thousand-plus times a second, so handlers must stay cheap or they slow the very workload they measure. DCGM is userspace polling with its own sampling overhead and a hardware constraint that only certain counter groups can be read together.\n\n## Why you need both, in order\n\nThe wrong turn is picking one tool and asking it the other tool's question — billing teams on DCGM's device-level numbers and silently overcharging everyone who shares a GPU, or trusting eBPF's launch counts as a proxy for efficiency and concluding a busy-looking workload was well-utilized. The order that actually works:\n\n```\n1. eBPF: attribute GPU work to pod/team        (who, how much — the bill)\n2. DCGM: measure on-device efficiency          (how well — was it justified)\n3. Join them on pod identity                    (the team's spend AND its efficiency)\n```\n\nAttribution first, because that is the question finance actually asked and the one your existing stack cannot answer at all. Efficiency second, because it turns the bill into a decision — a team holding an expensive allocation at 4% occupancy is paying a [just-in-case capacity tax](/en/journal/the-cost-of-just-in-case-code) you can now *see*, where before the GPU was a flat line item nobody could open. Joined on pod identity, you finally have what you have for every other resource: spend, attributed, with the efficiency context to act on it. The reason it took two planes is the same reason it was invisible — the silicon never lived in the accounting plane, and no single probe spans the boundary.\n\n---\n\n*See also:* the [DCGM exporter docs](https://docs.nvidia.com/datacenter/dcgm/latest/installation/install-dcgm-exporter.html) cover the pod-attribution path and the [DCGM feature overview](https://docs.nvidia.com/datacenter/dcgm/latest/user-guide/feature-overview.html) defines the profiling fields, and the eBPF primitives this leans on are documented at [ebpf.io](https://ebpf.io/what-is-ebpf/) and in [Tetragon](https://tetragon.io/).",
      "date_published": "2026-06-20T00:00:00.000Z",
      "tags": [
        "ebpf",
        "gpu",
        "finops",
        "observability",
        "kubernetes",
        "System"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/systems/zero-static-authority-multi-cluster-gitops/",
      "url": "https://sade.dev/en/systems/zero-static-authority-multi-cluster-gitops/",
      "title": "Zero Static Authority in Multi-Cluster GitOps",
      "summary": "A stored kubeconfig is a long-lived credential pointed at your whole fleet — the exact thing an attacker wants. Replace it with a workload identity that expires in an hour and is never written to disk. Here is the rule, the order, and the new control plane you take on in return.",
      "content_html": "<p>A GitOps controller — Argo CD, Flux — sits in one cluster and reconciles many. To reach the others it needs to authenticate to each remote <code>kube-apiserver</code>. The default way to do that is to store a credential: a bearer token, a client certificate, a kubeconfig, kept as a Kubernetes <code>Secret</code> in the control cluster. In Argo CD these are the objects labelled <code>argocd.argoproj.io/secret-type: cluster</code>.</p>\n<p>Stop and look at what that <code>Secret</code> is. It is a <strong>long-lived credential, usually with broad rights, pointing at your entire fleet, sitting at rest in one place.</strong> It does not rotate on its own. It is in your backups. It is the single most valuable thing an attacker can find in your control cluster, because owning it is owning every cluster it can reach. The whole discipline of <a href=\"/en/journal/why-boring-architecture/\">boring architecture</a> is about not keeping liabilities you don’t have to — and a stored fleet-wide token is a liability you keep purely because issuing a fresh, short-lived one at the moment of use seemed like more work.</p>\n<p>This piece is about doing that more work, in order. The target is <strong>zero static authority</strong>: no long-lived credential that authenticates the controller to a remote cluster is ever written down. The identity the controller presents is minted on demand, expires in about an hour, and lives only in memory. The mechanism is SPIFFE for the identity model and SPIRE for issuing it.</p>\n<p>Before any of it, this is not a tool for three clusters you could count on one hand. SPIRE is a control plane you operate, not a library you import — it is exactly the kind of capability you should be slow to take on, the way <a href=\"/en/journal/why-i-start-with-a-modular-monolith/\">modular monolith beats microservices until you’ve earned the split</a>. It earns its weight only when you genuinely run a fleet — many clusters, many teams, an audit requirement that a stolen <code>Secret</code> would fail. If a single shared cluster covers you, the cheapest secure credential is the one you never stand up the machinery to issue. The rest of this assumes you have actually arrived at the fleet.</p>\n<h2 id=\"the-one-rule-up-front\">The one rule up front</h2>\n<p>State it before the mechanics: <strong>the credential that crosses a cluster boundary must be short-lived and never stored.</strong> Everything below is in service of that one sentence. If at the end you still have a long-lived token written to disk somewhere on the authentication path, you have built complexity without buying the property you came for — the same wrong turn as adding a read replica to dodge a missing index.</p>\n<p>“Short-lived and never stored” has a precise meaning here. The identity is an X.509 certificate whose default lifetime is <strong>one hour</strong> — or a JWT, whose default is five minutes — fetched by the workload from a local socket with <strong>no token of its own to present</strong>, and rotated automatically at half its life. There is no secret to steal that is worth stealing tomorrow.</p>\n<h2 id=\"what-spiffe-and-spire-actually-are\">What SPIFFE and SPIRE actually are</h2>\n<p>SPIFFE is a spec; SPIRE is the reference implementation that issues the things the spec describes. Four nouns carry the whole design.</p>\n<p>A <strong>SPIFFE ID</strong> is a URI: <code>spiffe://&lt;trust-domain&gt;/&lt;path&gt;</code>, e.g. <code>spiffe://prod.example.org/ns/argocd/sa/application-controller</code>. The authority part is the <strong>trust domain</strong> — one logical root of trust, one CA. The path identifies the workload. That is the name a workload proves it holds.</p>\n<p>An <strong>SVID</strong> (SPIFFE Verifiable Identity Document) is the proof. Two forms:</p>\n<ul>\n<li><strong>X.509-SVID</strong> — an X.509 certificate with the SPIFFE ID in the URI SAN (not the CN, which matters later). You do mTLS with it.</li>\n<li><strong>JWT-SVID</strong> — a signed JWT whose <code>sub</code> is the SPIFFE ID and whose <code>aud</code> you scope to the intended verifier. You present it as a bearer token.</li>\n</ul>\n<p>The lifetimes are the point. In SPIRE’s server config the defaults are <code>default_x509_svid_ttl: 1h</code> and <code>default_jwt_svid_ttl: 5m</code>, and the SVIDs are signed by a CA whose own <code>ca_ttl</code> defaults to <code>24h</code>. These are short on purpose: a leaked SVID is worthless within the hour, which is exactly why it is safe to use it where you used to store a token.</p>\n<p>The thing that makes “never stored” possible is the <strong>Workload API</strong>, served by a SPIRE <strong>agent</strong> over a local Unix domain socket (the documented default is <code>/tmp/spire-agent/public/api.sock</code>; many deployments mount it at <code>/run/spire/sockets/...</code>). The workload connects to that socket and asks for its SVID. <strong>It presents no credential to do so.</strong> The agent identifies the caller by inspecting the calling process through the kernel — its PID, and from that its container, namespace, service account — and performs <em>workload attestation</em> against a set of selectors. Identity is established by <em>what the process verifiably is</em>, not by <em>what secret it holds</em>. That inversion is the entire reason there is nothing to store.</p>\n<p>Underneath, SPIRE is two components and two attestations:</p>\n<ul>\n<li>The <strong>spire-server</strong> holds the CA, signs SVIDs, and keeps the registration entries. Its CA is self-signed by default, or an intermediate chained to your corporate PKI via an <code>UpstreamAuthority</code> plugin — a decision you make once and live with.</li>\n<li>The <strong>spire-agent</strong> runs on every node. It first proves <em>the node</em> to the server — <strong>node attestation</strong> — using a plugin like <code>k8s_psat</code>, which validates a projected service-account token through the Kubernetes <code>TokenReview</code> API. No shared secret is planted on the node. Then it does <strong>workload attestation</strong> for each local process before handing it an SVID.</li>\n</ul>\n<p>A <strong>registration entry</strong> is the server-side record that ties a SPIFFE ID to a parent (the node/agent) and a set of selectors (namespace, service account, image). It is the policy: <em>this workload, attested this way, gets this identity.</em> Managing the lifecycle of these entries is real work, and it is where most of the ongoing operational cost lives.</p>\n<h2 id=\"federation-trusting-an-svid-from-another-cluster\">Federation: trusting an SVID from another cluster</h2>\n<p>One trust domain is one root of trust. A multi-cluster fleet usually means multiple trust domains — one per cluster, or per region — and now a workload in domain A must be able to validate an SVID minted in domain B. That is <strong>federation</strong>, and its unit is the <strong>trust bundle</strong>: the public CA certs and JWT signing keys (a JWKS) that let you verify a domain’s SVIDs.</p>\n<p>Each domain stands up a <strong>bundle endpoint</strong> — a URL serving its current bundle, the SPIFFE analogue of OIDC’s <code>jwks_uri</code> — and each peer polls it to stay current. Two profiles, and the difference is exactly the bootstrap question:</p>\n<ul>\n<li><strong><code>https_web</code></strong> — the endpoint is fronted by a Web-PKI certificate from a public CA. The peer validates it with the ordinary public trust store, so <strong>no initial bundle has to be exchanged out of band.</strong> Trust bootstraps off the existing Web PKI.</li>\n<li><strong><code>https_spiffe</code></strong> — the endpoint authenticates with its own X.509-SVID. To talk to it the first time, the peer must already hold that domain’s initial bundle. So the <strong>very first bundle has to arrive out of band</strong>, after which the latest fetched bundle is used going forward.</li>\n</ul>\n<p>On a registration entry, <code>federatesWith</code> lists the foreign trust domains a workload is allowed to authenticate against; the agent then delivers those foreign bundles to the workload over the Workload API. Bundles refresh on the <code>spiffe_refresh_hint</code> (commonly around five minutes), and you publish a new signing key several refresh cycles <em>before</em> you start using it, so federated peers have already learned it when the rotation lands.</p>\n<p>Note the honest shape of “zero static authority”: federation <strong>relocates</strong> the bootstrap trust decision, it does not erase it. With <code>https_spiffe</code> you ship one initial bundle by hand; with <code>https_web</code> you lean on the public CA system. Either way the root of trust still comes from <em>somewhere</em> — what you’ve eliminated is the long-lived, broadly-scoped, per-cluster credential, not the one-time trust anchor. That is the right trade, but call it what it is.</p>\n<h2 id=\"making-the-apiserver-accept-an-svid\">Making the apiserver accept an SVID</h2>\n<p>Here is where intent meets the Kubernetes API, and where it is easy to be wrong. There are two mechanisms, and only one is clean today.</p>\n<p><strong>The JWT-SVID-as-OIDC path (the one that works).</strong> Kubernetes can be told to trust an external OIDC issuer. SPIRE ships an <strong>OIDC Discovery Provider</strong> that serves <code>/.well-known/openid-configuration</code> and a JWKS backed by SPIRE’s JWT-SVID signing keys. You point the remote (spoke) cluster’s apiserver at that provider as an OIDC issuer. The controller fetches a fresh JWT-SVID from its local Workload API and presents it as a bearer token; the apiserver validates it against SPIRE’s JWKS and maps it to a subject. No <code>Secret</code>, no stored token — a new JWT per request, expiring in minutes.</p>\n<p>This is not a thought experiment. Red Hat ships SPIFFE/SPIRE support as the Zero Trust Workload Identity Manager operator starting in OpenShift 4.20, and documents exactly this wiring as a how-to guide rather than a shipped OpenShift GitOps feature: Argo CD uses a client-go <code>ExecCredential</code> plugin (<code>apiVersion: client.authentication.k8s.io/v1beta1</code>) via <code>execProviderConfig</code>, which on each call reads the SPIFFE socket (<code>SPIFFE_ENDPOINT_SOCKET</code>), requests a JWT-SVID with the audience the spoke apiserver expects (<code>SPIFFE_JWT_AUDIENCE</code>), and hands it over. The credential is manufactured at the moment of use and discarded.</p>\n<p><strong>The mTLS-with-X.509-SVID path (the trap).</strong> The instinct is to do straight mTLS: the controller presents its X.509-SVID, the apiserver trusts the SPIFFE bundle as a client CA, done. It is not done. Kubernetes client-cert auth derives the <strong>username from the certificate’s Subject CN and groups from the Subject O</strong> — and the SPIFFE ID lives in the <strong>URI SAN</strong>, which apiserver client-cert auth does not read. A raw SPIFFE X.509-SVID therefore does not map to a Kubernetes user. To use mTLS you insert a proxy — Ghostunnel or Envoy — in front of the apiserver: it terminates SPIFFE mTLS, validates the URI-SAN SPIFFE ID, and forwards with an identity the apiserver does understand. Ghostunnel consumes the Workload API directly for its own rotating SVIDs. This works, but it is a moving part in front of every apiserver, and the OIDC path avoids it.</p>\n<p>On maturity, be honest. Upstream Argo CD has no native SPIFFE auth; it works through the generic <code>ExecCredential</code> plugin pattern, and the most mature productization is OpenShift’s. Flux can be GitOps-managed to <em>deploy</em> SPIRE, and its OCI registry auth can consume a JWT-SVID, but a first-class “authenticate Flux to a remote cluster via SPIFFE instead of a kubeconfig” feature is not documented — it rides the same OIDC/exec-credential/proxy plumbing. Treat native remote-cluster SPIFFE auth as a pattern you assemble, not a checkbox you enable.</p>\n<h2 id=\"what-breaks-and-what-it-costs\">What breaks, and what it costs</h2>\n<p>This is the part the architecture diagrams omit. You have removed a stored credential; in return you have made an identity service a hard dependency on the critical path, and short-lived things fail in ways long-lived things didn’t.</p>\n<ul>\n<li><strong>SVID rotation is now an availability dependency.</strong> SVIDs are short — X.509 an hour, JWT five minutes — and rotate at half-life. If the agent or the Workload API is down, or the agent can’t reach the server to renew, <strong>the SVID expires and authentication stops.</strong> The SPIRE server is on the critical path for every issuance and renewal. Agents cache credentials and tolerate <em>brief</em> server outages; the agent’s <code>availability_target</code> knob (must be ≥ 24h if set) makes it rotate early to bank headroom for graceful downtime. But the failure mode is real and new: identity-plane down means fleet auth down.</li>\n<li><strong>Clock skew is now an outage class.</strong> A five-minute JWT is unforgiving. A few minutes of drift between a controller and a spoke apiserver rejects valid tokens. NTP discipline across the fleet stops being hygiene and becomes a hard requirement.</li>\n<li><strong>A federation bundle endpoint that goes dark breaks cross-cluster auth — silently, later.</strong> If a peer’s bundle endpoint is unreachable past the refresh window and that domain rotates its keys, your cached bundle goes stale and <strong>cross-domain SVID validation fails</strong>, even though both clusters are individually healthy. The failure shows up at rotation time, not at outage time, which makes it nasty to diagnose.</li>\n<li><strong>The identity plane is now a stateful, HA-critical service.</strong> SPIRE servers in HA share one SQL datastore; the default SQLite is single-node, so production means a highly-available MySQL/PostgreSQL that the <em>entire fleet’s ability to authenticate</em> depends on. For multi-cluster you choose a topology — nested SPIRE (a root server issuing intermediates to downstream servers, surviving a root outage) or federation across per-cluster trust domains — and each adds its own operational surface.</li>\n<li><strong>The operational weight is a whole new control plane.</strong> Server plus agents on every cluster; the registration-entry lifecycle (selectors per workload, kept in sync with deployments); the upstream-CA decision; bundle endpoints and federation relationships; the OIDC Discovery Provider; and, on the mTLS path, a proxy per apiserver. None of this existed when the answer was “store a kubeconfig.”</li>\n</ul>\n<h2 id=\"why-does-the-order-matter\">Why does the order matter?</h2>\n<p>The steps are not a menu; they are a sequence, and skipping it is how teams get the cost without the benefit.</p>\n<pre class=\"astro-code astro-code-themes vitesse-light vitesse-dark\" style=\"background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto\" tabindex=\"0\" data-language=\"plaintext\"><code><span class=\"line\"><span>1. Stand up SPIRE: server + agents, node attestation       (foundation)</span></span>\n<span class=\"line\"><span>2. Issue workload SVIDs to the controller via Workload API</span></span>\n<span class=\"line\"><span>3. Make the spoke apiserver trust SPIRE (OIDC path first)</span></span>\n<span class=\"line\"><span>4. Federate trust domains across clusters</span></span>\n<span class=\"line\"><span>5. Delete the stored kubeconfig Secrets                     (the payoff)</span></span></code></pre>\n<p>Step 5 is the whole point, and it is only safe once 1–4 actually work. The common failure is to keep the old <code>Secret</code> “as a fallback” — which means the long-lived fleet-wide credential is still at rest, still in your backups, still the thing an attacker takes, and you are now also running SPIRE. You have paid for the control plane and kept the liability. Either the stored credential is gone or you have not done this; there is no half-credit.</p>\n<p>The reverse error is reaching for SPIRE before the order can pay off — standing up a federated identity plane for a couple of clusters whose <code>Secret</code> would be fine. That is <a href=\"/en/journal/the-cost-of-just-in-case-code/\">just-in-case complexity</a> wearing a security badge: machinery built for a blast radius you don’t yet have. The machinery is justified by the fleet, the audit requirement, and the blast radius of a stolen token. Below that line, the cheapest secure credential really is the boring one you never have to issue.</p>\n<p>Where this approach genuinely ends is clear: a fleet large enough that a static per-cluster credential is an unacceptable blast radius, with the operational maturity to run a stateful, HA, on-the-critical-path identity service and keep its clocks in sync. A team that has arrived there already pays for that maturity elsewhere — and at that point zero static authority is not gold-plating, it is the credential model the blast radius was always demanding. Not before.</p>\n<hr/>\n<p><em>See also:</em> the <a href=\"https://spiffe.io/docs/latest/spiffe-about/overview/\">SPIFFE</a> and <a href=\"https://spiffe.io/docs/latest/spire-about/\">SPIRE</a> docs define the identity model and the server/agent components, and the <a href=\"https://spiffe.io/docs/latest/spiffe-specs/spiffe_federation/\">SPIFFE Federation spec</a> specifies the bundle-endpoint profiles this design relies on.</p>",
      "content_text": "A GitOps controller — Argo CD, Flux — sits in one cluster and reconciles many. To reach the others it needs to authenticate to each remote `kube-apiserver`. The default way to do that is to store a credential: a bearer token, a client certificate, a kubeconfig, kept as a Kubernetes `Secret` in the control cluster. In Argo CD these are the objects labelled `argocd.argoproj.io/secret-type: cluster`.\n\nStop and look at what that `Secret` is. It is a **long-lived credential, usually with broad rights, pointing at your entire fleet, sitting at rest in one place.** It does not rotate on its own. It is in your backups. It is the single most valuable thing an attacker can find in your control cluster, because owning it is owning every cluster it can reach. The whole discipline of [boring architecture](/en/journal/why-boring-architecture) is about not keeping liabilities you don't have to — and a stored fleet-wide token is a liability you keep purely because issuing a fresh, short-lived one at the moment of use seemed like more work.\n\nThis piece is about doing that more work, in order. The target is **zero static authority**: no long-lived credential that authenticates the controller to a remote cluster is ever written down. The identity the controller presents is minted on demand, expires in about an hour, and lives only in memory. The mechanism is SPIFFE for the identity model and SPIRE for issuing it.\n\nBefore any of it, this is not a tool for three clusters you could count on one hand. SPIRE is a control plane you operate, not a library you import — it is exactly the kind of capability you should be slow to take on, the way [modular monolith beats microservices until you've earned the split](/en/journal/why-i-start-with-a-modular-monolith). It earns its weight only when you genuinely run a fleet — many clusters, many teams, an audit requirement that a stolen `Secret` would fail. If a single shared cluster covers you, the cheapest secure credential is the one you never stand up the machinery to issue. The rest of this assumes you have actually arrived at the fleet.\n\n## The one rule up front\n\nState it before the mechanics: **the credential that crosses a cluster boundary must be short-lived and never stored.** Everything below is in service of that one sentence. If at the end you still have a long-lived token written to disk somewhere on the authentication path, you have built complexity without buying the property you came for — the same wrong turn as adding a read replica to dodge a missing index.\n\n\"Short-lived and never stored\" has a precise meaning here. The identity is an X.509 certificate whose default lifetime is **one hour** — or a JWT, whose default is five minutes — fetched by the workload from a local socket with **no token of its own to present**, and rotated automatically at half its life. There is no secret to steal that is worth stealing tomorrow.\n\n## What SPIFFE and SPIRE actually are\n\nSPIFFE is a spec; SPIRE is the reference implementation that issues the things the spec describes. Four nouns carry the whole design.\n\nA **SPIFFE ID** is a URI: `spiffe://<trust-domain>/<path>`, e.g. `spiffe://prod.example.org/ns/argocd/sa/application-controller`. The authority part is the **trust domain** — one logical root of trust, one CA. The path identifies the workload. That is the name a workload proves it holds.\n\nAn **SVID** (SPIFFE Verifiable Identity Document) is the proof. Two forms:\n\n- **X.509-SVID** — an X.509 certificate with the SPIFFE ID in the URI SAN (not the CN, which matters later). You do mTLS with it.\n- **JWT-SVID** — a signed JWT whose `sub` is the SPIFFE ID and whose `aud` you scope to the intended verifier. You present it as a bearer token.\n\nThe lifetimes are the point. In SPIRE's server config the defaults are `default_x509_svid_ttl: 1h` and `default_jwt_svid_ttl: 5m`, and the SVIDs are signed by a CA whose own `ca_ttl` defaults to `24h`. These are short on purpose: a leaked SVID is worthless within the hour, which is exactly why it is safe to use it where you used to store a token.\n\nThe thing that makes \"never stored\" possible is the **Workload API**, served by a SPIRE **agent** over a local Unix domain socket (the documented default is `/tmp/spire-agent/public/api.sock`; many deployments mount it at `/run/spire/sockets/...`). The workload connects to that socket and asks for its SVID. **It presents no credential to do so.** The agent identifies the caller by inspecting the calling process through the kernel — its PID, and from that its container, namespace, service account — and performs *workload attestation* against a set of selectors. Identity is established by *what the process verifiably is*, not by *what secret it holds*. That inversion is the entire reason there is nothing to store.\n\nUnderneath, SPIRE is two components and two attestations:\n\n- The **spire-server** holds the CA, signs SVIDs, and keeps the registration entries. Its CA is self-signed by default, or an intermediate chained to your corporate PKI via an `UpstreamAuthority` plugin — a decision you make once and live with.\n- The **spire-agent** runs on every node. It first proves *the node* to the server — **node attestation** — using a plugin like `k8s_psat`, which validates a projected service-account token through the Kubernetes `TokenReview` API. No shared secret is planted on the node. Then it does **workload attestation** for each local process before handing it an SVID.\n\nA **registration entry** is the server-side record that ties a SPIFFE ID to a parent (the node/agent) and a set of selectors (namespace, service account, image). It is the policy: *this workload, attested this way, gets this identity.* Managing the lifecycle of these entries is real work, and it is where most of the ongoing operational cost lives.\n\n## Federation: trusting an SVID from another cluster\n\nOne trust domain is one root of trust. A multi-cluster fleet usually means multiple trust domains — one per cluster, or per region — and now a workload in domain A must be able to validate an SVID minted in domain B. That is **federation**, and its unit is the **trust bundle**: the public CA certs and JWT signing keys (a JWKS) that let you verify a domain's SVIDs.\n\nEach domain stands up a **bundle endpoint** — a URL serving its current bundle, the SPIFFE analogue of OIDC's `jwks_uri` — and each peer polls it to stay current. Two profiles, and the difference is exactly the bootstrap question:\n\n- **`https_web`** — the endpoint is fronted by a Web-PKI certificate from a public CA. The peer validates it with the ordinary public trust store, so **no initial bundle has to be exchanged out of band.** Trust bootstraps off the existing Web PKI.\n- **`https_spiffe`** — the endpoint authenticates with its own X.509-SVID. To talk to it the first time, the peer must already hold that domain's initial bundle. So the **very first bundle has to arrive out of band**, after which the latest fetched bundle is used going forward.\n\nOn a registration entry, `federatesWith` lists the foreign trust domains a workload is allowed to authenticate against; the agent then delivers those foreign bundles to the workload over the Workload API. Bundles refresh on the `spiffe_refresh_hint` (commonly around five minutes), and you publish a new signing key several refresh cycles *before* you start using it, so federated peers have already learned it when the rotation lands.\n\nNote the honest shape of \"zero static authority\": federation **relocates** the bootstrap trust decision, it does not erase it. With `https_spiffe` you ship one initial bundle by hand; with `https_web` you lean on the public CA system. Either way the root of trust still comes from *somewhere* — what you've eliminated is the long-lived, broadly-scoped, per-cluster credential, not the one-time trust anchor. That is the right trade, but call it what it is.\n\n## Making the apiserver accept an SVID\n\nHere is where intent meets the Kubernetes API, and where it is easy to be wrong. There are two mechanisms, and only one is clean today.\n\n**The JWT-SVID-as-OIDC path (the one that works).** Kubernetes can be told to trust an external OIDC issuer. SPIRE ships an **OIDC Discovery Provider** that serves `/.well-known/openid-configuration` and a JWKS backed by SPIRE's JWT-SVID signing keys. You point the remote (spoke) cluster's apiserver at that provider as an OIDC issuer. The controller fetches a fresh JWT-SVID from its local Workload API and presents it as a bearer token; the apiserver validates it against SPIRE's JWKS and maps it to a subject. No `Secret`, no stored token — a new JWT per request, expiring in minutes.\n\nThis is not a thought experiment. Red Hat ships SPIFFE/SPIRE support as the Zero Trust Workload Identity Manager operator starting in OpenShift 4.20, and documents exactly this wiring as a how-to guide rather than a shipped OpenShift GitOps feature: Argo CD uses a client-go `ExecCredential` plugin (`apiVersion: client.authentication.k8s.io/v1beta1`) via `execProviderConfig`, which on each call reads the SPIFFE socket (`SPIFFE_ENDPOINT_SOCKET`), requests a JWT-SVID with the audience the spoke apiserver expects (`SPIFFE_JWT_AUDIENCE`), and hands it over. The credential is manufactured at the moment of use and discarded.\n\n**The mTLS-with-X.509-SVID path (the trap).** The instinct is to do straight mTLS: the controller presents its X.509-SVID, the apiserver trusts the SPIFFE bundle as a client CA, done. It is not done. Kubernetes client-cert auth derives the **username from the certificate's Subject CN and groups from the Subject O** — and the SPIFFE ID lives in the **URI SAN**, which apiserver client-cert auth does not read. A raw SPIFFE X.509-SVID therefore does not map to a Kubernetes user. To use mTLS you insert a proxy — Ghostunnel or Envoy — in front of the apiserver: it terminates SPIFFE mTLS, validates the URI-SAN SPIFFE ID, and forwards with an identity the apiserver does understand. Ghostunnel consumes the Workload API directly for its own rotating SVIDs. This works, but it is a moving part in front of every apiserver, and the OIDC path avoids it.\n\nOn maturity, be honest. Upstream Argo CD has no native SPIFFE auth; it works through the generic `ExecCredential` plugin pattern, and the most mature productization is OpenShift's. Flux can be GitOps-managed to *deploy* SPIRE, and its OCI registry auth can consume a JWT-SVID, but a first-class \"authenticate Flux to a remote cluster via SPIFFE instead of a kubeconfig\" feature is not documented — it rides the same OIDC/exec-credential/proxy plumbing. Treat native remote-cluster SPIFFE auth as a pattern you assemble, not a checkbox you enable.\n\n## What breaks, and what it costs\n\nThis is the part the architecture diagrams omit. You have removed a stored credential; in return you have made an identity service a hard dependency on the critical path, and short-lived things fail in ways long-lived things didn't.\n\n- **SVID rotation is now an availability dependency.** SVIDs are short — X.509 an hour, JWT five minutes — and rotate at half-life. If the agent or the Workload API is down, or the agent can't reach the server to renew, **the SVID expires and authentication stops.** The SPIRE server is on the critical path for every issuance and renewal. Agents cache credentials and tolerate *brief* server outages; the agent's `availability_target` knob (must be ≥ 24h if set) makes it rotate early to bank headroom for graceful downtime. But the failure mode is real and new: identity-plane down means fleet auth down.\n- **Clock skew is now an outage class.** A five-minute JWT is unforgiving. A few minutes of drift between a controller and a spoke apiserver rejects valid tokens. NTP discipline across the fleet stops being hygiene and becomes a hard requirement.\n- **A federation bundle endpoint that goes dark breaks cross-cluster auth — silently, later.** If a peer's bundle endpoint is unreachable past the refresh window and that domain rotates its keys, your cached bundle goes stale and **cross-domain SVID validation fails**, even though both clusters are individually healthy. The failure shows up at rotation time, not at outage time, which makes it nasty to diagnose.\n- **The identity plane is now a stateful, HA-critical service.** SPIRE servers in HA share one SQL datastore; the default SQLite is single-node, so production means a highly-available MySQL/PostgreSQL that the *entire fleet's ability to authenticate* depends on. For multi-cluster you choose a topology — nested SPIRE (a root server issuing intermediates to downstream servers, surviving a root outage) or federation across per-cluster trust domains — and each adds its own operational surface.\n- **The operational weight is a whole new control plane.** Server plus agents on every cluster; the registration-entry lifecycle (selectors per workload, kept in sync with deployments); the upstream-CA decision; bundle endpoints and federation relationships; the OIDC Discovery Provider; and, on the mTLS path, a proxy per apiserver. None of this existed when the answer was \"store a kubeconfig.\"\n\n## Why does the order matter?\n\nThe steps are not a menu; they are a sequence, and skipping it is how teams get the cost without the benefit.\n\n```\n1. Stand up SPIRE: server + agents, node attestation       (foundation)\n2. Issue workload SVIDs to the controller via Workload API\n3. Make the spoke apiserver trust SPIRE (OIDC path first)\n4. Federate trust domains across clusters\n5. Delete the stored kubeconfig Secrets                     (the payoff)\n```\n\nStep 5 is the whole point, and it is only safe once 1–4 actually work. The common failure is to keep the old `Secret` \"as a fallback\" — which means the long-lived fleet-wide credential is still at rest, still in your backups, still the thing an attacker takes, and you are now also running SPIRE. You have paid for the control plane and kept the liability. Either the stored credential is gone or you have not done this; there is no half-credit.\n\nThe reverse error is reaching for SPIRE before the order can pay off — standing up a federated identity plane for a couple of clusters whose `Secret` would be fine. That is [just-in-case complexity](/en/journal/the-cost-of-just-in-case-code) wearing a security badge: machinery built for a blast radius you don't yet have. The machinery is justified by the fleet, the audit requirement, and the blast radius of a stolen token. Below that line, the cheapest secure credential really is the boring one you never have to issue.\n\nWhere this approach genuinely ends is clear: a fleet large enough that a static per-cluster credential is an unacceptable blast radius, with the operational maturity to run a stateful, HA, on-the-critical-path identity service and keep its clocks in sync. A team that has arrived there already pays for that maturity elsewhere — and at that point zero static authority is not gold-plating, it is the credential model the blast radius was always demanding. Not before.\n\n---\n\n*See also:* the [SPIFFE](https://spiffe.io/docs/latest/spiffe-about/overview/) and [SPIRE](https://spiffe.io/docs/latest/spire-about/) docs define the identity model and the server/agent components, and the [SPIFFE Federation spec](https://spiffe.io/docs/latest/spiffe-specs/spiffe_federation/) specifies the bundle-endpoint profiles this design relies on.",
      "date_published": "2026-06-20T00:00:00.000Z",
      "tags": [
        "kubernetes",
        "gitops",
        "spiffe",
        "security",
        "architecture",
        "System"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/notes/replaying-a-dead-letter-queue/",
      "url": "https://sade.dev/en/notes/replaying-a-dead-letter-queue/",
      "title": "Replaying a Dead-Letter Queue Without Making It Worse",
      "summary": "Moving messages out of a dead-letter queue is reprocessing live traffic, not moving bytes. A naive replay re-poisons the queue with the messages that died for a real reason and re-fires side effects that already half-ran. The safe shape is ordered: reset the envelope but keep the message id and the trace id, select by the outage signature, dry-run, sandbox, and only then production.",
      "content_html": "<p>A downstream API was down for twenty minutes. Four thousand messages exhausted their retries and landed on the dead-letter queue. The API is back now, the messages are still good, and the obvious move is to put them back where they came from. So you reach for the broker console and start moving messages from <code>orders.dlq</code> to <code>orders</code>.</p>\n<p>Stop there. A dead-letter queue is a holding area, not a graveyard — but moving messages out of it is a loaded operation, and “just move them back” is how you turn a recovered outage into a second incident.</p>\n<h2 id=\"two-ways-a-naive-replay-makes-it-worse\">Two ways a naive replay makes it worse</h2>\n<p><strong>It re-poisons the queue.</strong> Your DLQ rarely holds only the outage’s victims. Mixed in are messages that died for a real reason — a malformed payload, a bug, an unroutable URN — and <em>those</em> will fail again the instant you replay them. If your replay just shovels everything back, the genuinely broken messages fail, hit the DLQ again, and you replay them again. A loop that costs CPU, fills logs, and buries the messages that would actually succeed.</p>\n<p><strong>It re-fires side effects.</strong> A message that dead-lettered after three attempts may have <em>partially</em> run each time. The charge went through but the confirmation email threw; the message failed, retried, dead-lettered. Replay it naively and the handler runs start to finish again — a second charge, on top of the email it now finally sends. The queue moved a message; your customer got billed twice.</p>\n<p>Both traps come from treating replay as “move bytes back.” It isn’t. It’s “<strong>reset a message and reprocess it, safely</strong>” — and each of those words is a safety catch.</p>\n<h2 id=\"reset-but-keep-the-identity\">Reset, but keep the identity</h2>\n<p>A dead-lettered message carries an extra block describing how it died — the reason, the original queue, the attempt count. To replay it you have to <strong>reset</strong> it: drop that block, and set the attempt counter back to zero so it gets a fresh retry budget instead of immediately re-exhausting it.</p>\n<p>But reset is not the same as <em>new</em>. Preserve everything that identifies the message — its id, its payload, and above all its correlation id (<code>trace_id</code>). Two reasons:</p>\n<ul>\n<li>A replayed message you can’t trace is an operational blind spot. Keep the <code>trace_id</code> and the replay shows up in the same trace as the original failure.</li>\n<li>The preserved message id is what makes replay <strong>safe to retry</strong>. If your consumers are <a href=\"/en/notes/idempotency-duplicate-delivery/\">idempotent</a> — deduping on the message id — then replaying a message that already half-succeeded is caught by the dedupe, not re-run. Idempotency and replay are partners: idempotency is what lets you replay a queue without holding your breath.</li>\n</ul>\n<h2 id=\"dont-aim-it-at-production-first\">Don’t aim it at production first</h2>\n<p>Even reset and idempotent, a replay is a write to a live system. Earn confidence in stages:</p>\n<ul>\n<li><strong>Dry-run.</strong> Read the DLQ and report what <em>would</em> be replayed — counts, targets, reasons — and put every message back untouched. You learn the blast radius without firing a shot.</li>\n<li><strong>Sandbox.</strong> Redirect the replay to a non-production queue whose consumers have their external side effects stubbed. The messages flow through real handler logic; nobody gets charged. This is where you find out the “good” messages are actually good.</li>\n<li><strong>Then production</strong>, once the first two are boring.</li>\n</ul>\n<p>The order matters more than any single step. Replaying straight to production because the messages “look fine” is the same confidence that moved them on the broker console in paragraph one.</p>\n<h2 id=\"select-dont-shotgun\">Select; don’t shotgun</h2>\n<p>The outage’s victims share a signature — a window of time, a failure <code>reason</code>, a URN. Replay <em>that set</em>, not the whole queue. Selecting by reason is the difference between “replay the 4,000 that failed on a timeout” and “replay the 4,000 timeouts plus the 30 genuinely poison messages that will just come back.” The poison messages aren’t your problem to replay; they’re your problem to <em>fix</em>, and they should stay on the DLQ where you can see them.</p>\n<h2 id=\"the-side-effect-you-cant-reset-away\">The side effect you can’t reset away</h2>\n<p>Here is the honest hard part. Reset clears the envelope; idempotency dedupes exact replays; sandboxing protects production while you test. But a <em>deliberate</em> replay into production — the real one, the one you actually want — runs the handler, and the handler does what it does: it charges, it emails, it calls the third party. Idempotency stops a <em>duplicate</em>; it does not stop the <em>intended</em> reprocess from doing its job, side effects included.</p>\n<p>The clean fix is to let a handler know it is running a replay so it can skip the external effects that already happened — re-run the database write, but don’t re-send the email. The catch is <em>where to put that flag</em>: a frozen message envelope has nowhere to add it. The answer is the same one distributed tracing uses for the same constraint — carry it <strong>out of band</strong>, as a transport header riding alongside the message, one that the runtime surfaces to the handler. A <code>replay-bypass</code> marker the handler can check, set only on replayed messages, costing nothing on the normal path. It’s the most involved piece, and the right one to build last — after reset, dry-run, sandbox, and select have made replay safe enough to be routine.</p>\n<p>A dead-letter queue is the most useful thing in your system on the worst day of the quarter. Treat replaying it like what it is — reprocessing live traffic — and it stays useful. Treat it like moving bytes, and the DLQ gets its sequel.</p>\n<hr/>\n<p><em>See also:</em> the <a href=\"https://babelqueue.com/docs/spec/1.x/redrive-and-replay\">BabelQueue redrive-and-replay spec</a> standardizes this reset-and-reprocess shape, and the <a href=\"https://github.com/BabelQueue/babelqueue-examples/tree/main/dlq-redrive\"><code>dlq-redrive</code> example</a> is a runnable version of it.</p>",
      "content_text": "A downstream API was down for twenty minutes. Four thousand messages exhausted their retries and landed on the dead-letter queue. The API is back now, the messages are still good, and the obvious move is to put them back where they came from. So you reach for the broker console and start moving messages from `orders.dlq` to `orders`.\n\nStop there. A dead-letter queue is a holding area, not a graveyard — but moving messages out of it is a loaded operation, and \"just move them back\" is how you turn a recovered outage into a second incident.\n\n## Two ways a naive replay makes it worse\n\n**It re-poisons the queue.** Your DLQ rarely holds only the outage's victims. Mixed in are messages that died for a real reason — a malformed payload, a bug, an unroutable URN — and *those* will fail again the instant you replay them. If your replay just shovels everything back, the genuinely broken messages fail, hit the DLQ again, and you replay them again. A loop that costs CPU, fills logs, and buries the messages that would actually succeed.\n\n**It re-fires side effects.** A message that dead-lettered after three attempts may have *partially* run each time. The charge went through but the confirmation email threw; the message failed, retried, dead-lettered. Replay it naively and the handler runs start to finish again — a second charge, on top of the email it now finally sends. The queue moved a message; your customer got billed twice.\n\nBoth traps come from treating replay as \"move bytes back.\" It isn't. It's \"**reset a message and reprocess it, safely**\" — and each of those words is a safety catch.\n\n## Reset, but keep the identity\n\nA dead-lettered message carries an extra block describing how it died — the reason, the original queue, the attempt count. To replay it you have to **reset** it: drop that block, and set the attempt counter back to zero so it gets a fresh retry budget instead of immediately re-exhausting it.\n\nBut reset is not the same as *new*. Preserve everything that identifies the message — its id, its payload, and above all its correlation id (`trace_id`). Two reasons:\n\n- A replayed message you can't trace is an operational blind spot. Keep the `trace_id` and the replay shows up in the same trace as the original failure.\n- The preserved message id is what makes replay **safe to retry**. If your consumers are [idempotent](/en/notes/idempotency-duplicate-delivery) — deduping on the message id — then replaying a message that already half-succeeded is caught by the dedupe, not re-run. Idempotency and replay are partners: idempotency is what lets you replay a queue without holding your breath.\n\n## Don't aim it at production first\n\nEven reset and idempotent, a replay is a write to a live system. Earn confidence in stages:\n\n- **Dry-run.** Read the DLQ and report what *would* be replayed — counts, targets, reasons — and put every message back untouched. You learn the blast radius without firing a shot.\n- **Sandbox.** Redirect the replay to a non-production queue whose consumers have their external side effects stubbed. The messages flow through real handler logic; nobody gets charged. This is where you find out the \"good\" messages are actually good.\n- **Then production**, once the first two are boring.\n\nThe order matters more than any single step. Replaying straight to production because the messages \"look fine\" is the same confidence that moved them on the broker console in paragraph one.\n\n## Select; don't shotgun\n\nThe outage's victims share a signature — a window of time, a failure `reason`, a URN. Replay *that set*, not the whole queue. Selecting by reason is the difference between \"replay the 4,000 that failed on a timeout\" and \"replay the 4,000 timeouts plus the 30 genuinely poison messages that will just come back.\" The poison messages aren't your problem to replay; they're your problem to *fix*, and they should stay on the DLQ where you can see them.\n\n## The side effect you can't reset away\n\nHere is the honest hard part. Reset clears the envelope; idempotency dedupes exact replays; sandboxing protects production while you test. But a *deliberate* replay into production — the real one, the one you actually want — runs the handler, and the handler does what it does: it charges, it emails, it calls the third party. Idempotency stops a *duplicate*; it does not stop the *intended* reprocess from doing its job, side effects included.\n\nThe clean fix is to let a handler know it is running a replay so it can skip the external effects that already happened — re-run the database write, but don't re-send the email. The catch is *where to put that flag*: a frozen message envelope has nowhere to add it. The answer is the same one distributed tracing uses for the same constraint — carry it **out of band**, as a transport header riding alongside the message, one that the runtime surfaces to the handler. A `replay-bypass` marker the handler can check, set only on replayed messages, costing nothing on the normal path. It's the most involved piece, and the right one to build last — after reset, dry-run, sandbox, and select have made replay safe enough to be routine.\n\nA dead-letter queue is the most useful thing in your system on the worst day of the quarter. Treat replaying it like what it is — reprocessing live traffic — and it stays useful. Treat it like moving bytes, and the DLQ gets its sequel.\n\n---\n\n*See also:* the [BabelQueue redrive-and-replay spec](https://babelqueue.com/docs/spec/1.x/redrive-and-replay) standardizes this reset-and-reprocess shape, and the [`dlq-redrive` example](https://github.com/BabelQueue/babelqueue-examples/tree/main/dlq-redrive) is a runnable version of it.",
      "date_published": "2026-06-19T00:00:00.000Z",
      "tags": [
        "queue",
        "dead-letter-queue",
        "reliability",
        "operations",
        "architecture",
        "Note"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/systems/polyglot-queue-distributed-tracing/",
      "url": "https://sade.dev/en/systems/polyglot-queue-distributed-tracing/",
      "title": "Distributed Tracing Across a Polyglot Queue",
      "summary": "The trick is not instrumentation. A UUID trace_id already on the wire is exactly a 16-byte OpenTelemetry TraceID — reuse it and every hop shares one trace with zero wire change. What that buys, and the cross-hop span parenting it deliberately left for a phase two that has since shipped.",
      "content_html": "<p>A message is produced by a PHP service, lands on a queue, is consumed by a Go worker, which publishes a follow-up that a Python service handles. Four hops, three languages, one or more brokers in between. When that flow is slow, or one message in ten thousand dies, the question is always the same: <strong>where did this message actually go, and what happened at each step?</strong></p>\n<p>The honest answer, for most polyglot queue systems, is that nobody knows. You have a correlation id in the logs — if you remembered to log it in all three languages — and you have the patience to <code>grep</code> across three log streams on different hosts. What you do not have is a picture. You cannot see “produced by PHP in 2ms → sat in Redis for 40ms → processed by Go in 210ms, retried twice, dead-lettered” as one connected thing.</p>\n<p>That picture is a <strong>distributed trace</strong>, and OpenTelemetry is the standard way to draw it. This piece is about adding it to a message standard whose wire format is <strong>frozen</strong> and whose cores carry <strong>zero dependencies</strong> — two constraints that, together, make the obvious approach illegal and force a more interesting one.</p>\n<h2 id=\"the-one-rule-up-front\">The one rule up front</h2>\n<p>Let me state it before anything else: <strong>do not add a field to the envelope.</strong></p>\n<p>The instinct, when you want distributed tracing across a message bus, is to carry a W3C <code>traceparent</code> — the standard 55-character string that encodes a trace id, a span id, and flags. HTTP does exactly this in a header. The instinct is correct for HTTP and wrong here, because the envelope is a frozen contract. Every SDK in every language emits the byte-identical shape <code>job</code>, <code>trace_id</code>, <code>data</code>, <code>meta</code>, <code>attempts</code>. Adding a <code>traceparent</code> field — even an optional one — changes that shape, which means a version bump, which means coordinating a wire change across six language implementations and every broker binding. For a feature that is supposed to be <em>optional observability</em>, that is an absurd price.</p>\n<p>So the rule is the constraint: solve tracing <strong>without touching the wire</strong>. Everything below follows from taking that seriously.</p>\n<h2 id=\"the-insight-nobody-uses\">The insight nobody uses</h2>\n<p>Here is the thing the envelope already gives you for free. The <code>trace_id</code> field is a correlation id — a UUID, minted at produce time and <a href=\"/en/systems/data-intensive-systems-breaking-points/\">forwarded unchanged across every hop</a>. It is already on the wire, already propagated, already the one value that ties the whole flow together.</p>\n<p>Now look at what OpenTelemetry uses to tie a trace together: a <strong>TraceID</strong>. In the spec, a TraceID is exactly <strong>16 bytes</strong>.</p>\n<p>A UUID is exactly 16 bytes.</p>\n<p>That is the whole trick. A <code>trace_id</code> UUID maps one-to-one onto an OTel TraceID — strip the hyphens, read the 32 hex characters as the 16-byte id, done. (A <code>trace_id</code> that is <em>not</em> a UUID — say it came from a non-BabelQueue producer — gets hashed to 16 bytes with SHA-256, deterministically.) Every hop that shares a <code>trace_id</code> therefore derives the <strong>same</strong> OTel TraceID, with no agreement protocol and no new field. The correlation id you were already carrying <em>is</em> the distributed trace.</p>\n<p>So the design writes itself:</p>\n<ul>\n<li>On the <strong>consumer</strong> side, wrap the handler. Before it runs, start a span named <code>process &lt;urn&gt;</code>, but force that span into the trace derived from the message’s <code>trace_id</code>. Tag it with the messaging conventions — <code>messaging.system</code>, <code>messaging.destination.name</code>, <code>messaging.message.id</code>, and <code>messaging.message.conversation_id</code> set to the <code>trace_id</code> itself — then run the handler, and record any exception as the span’s error status. The runtime’s retry / dead-letter behaviour is untouched; the span just observes it.</li>\n<li>On the <strong>producer</strong> side, the mirror. Open a <code>publish &lt;urn&gt;</code> span, take <em>its</em> trace id, format it back into a UUID, and stamp that into the message’s <code>trace_id</code> as you build the envelope. The downstream consumer, deriving its TraceID from that same <code>trace_id</code>, lands in the same trace.</li>\n</ul>\n<p>Wire a <code>TracerProvider</code> for Jaeger, Tempo, Honeycomb or Datadog and the flow shows up as one waterfall, across all three languages, with per-hop timing and the retry/error markers in place. Don’t wire one, and nothing changes — it is entirely opt-in.</p>\n<h2 id=\"the-mechanic-and-a-phantom\">The mechanic, and a phantom</h2>\n<p>There is one detail worth being precise about, because it is where the design is both clever and limited.</p>\n<p>To start a span <em>inside a specific trace</em> in OpenTelemetry, you give it a <strong>remote parent</strong> — a span context carrying the trace id you want. But a span context is only valid if it has <em>both</em> a trace id and a span id. We have the trace id (from <code>trace_id</code>); we do <strong>not</strong> have the upstream span’s id, because we deliberately did not propagate one. So we synthesize a deterministic, non-zero span id by hashing the <code>trace_id</code>. The parent is valid, the consumer span lands in the right trace — but its parent points at a span that never existed. A <strong>phantom</strong>.</p>\n<p>This is the honest limit of the design as it stands above, and it is worth stating plainly. Cross-hop spans all share <strong>one trace</strong> — you can see every step of a message’s life grouped together, timed, with errors marked. What this alone does <strong>not</strong> give you is exact parent-child linkage <em>between</em> hops: the consumer’s span is not wired as the child of the producer’s span, because the producer’s real span id was never carried across the wire. Within a single process the hierarchy is correct; across the queue it is flat under one trace.</p>\n<p>Getting true cross-hop parent-child back means propagating a span id, which means a <code>traceparent</code> — and since the envelope is frozen, that <code>traceparent</code> has to ride <strong>out of band</strong>, as a transport header alongside whichever slot each broker binding already uses for the <code>trace_id</code>: a <code>bq-trace-id</code> attribute on SQS, Pulsar and Kafka, the native correlation id on RabbitMQ, Azure Service Bus and ActiveMQ Artemis, and on Redis — which carries no per-message metadata at all — nothing but the envelope body. That is a real feature, but it is a different scale of work: it touches every transport binding in every SDK. So it was left as a deliberate <strong>phase two</strong> — and that phase closed two days after this piece went up: ADR-0028 shipped <code>traceparent</code> transport-header propagation as v0.2 across all six SDK cores on 21 June 2026. A delivered <code>traceparent</code> now upgrades the consumer span to a true child of the producer span, and where it cannot be carried — PHP’s Kafka, Pulsar and STOMP producers are the remaining gap — propagation degrades cleanly back to the v0.1 <code>trace_id</code> correlation described above. The ordering was the point: the phase-one design delivers correlation, per-hop timing and error/retry visibility — the 90% — with zero wire change and a few hundred lines per language, and it stays the floor that v0.2 sits on top of. Shipping the 90% first and the last 10% behind a bigger lift was the right order, the same way <a href=\"/en/systems/data-intensive-systems-breaking-points/\">you do the cheap scaling steps before the expensive ones</a>.</p>\n<h2 id=\"one-semantic-six-packaging-idioms\">One semantic, six packaging idioms</h2>\n<p>The constraint that the <strong>core stays dependency-free</strong> has a consequence: wherever the OpenTelemetry code has to import the OTel API, it cannot live in the core. So in five of the six languages it lives <em>beside</em> the core, reached only when you opt in — and “beside the core, optional” is spelled differently in every ecosystem. (.NET is the exception, for a reason we will get to.) The semantics are identical in all six; the packaging is where each language shows its personality:</p>\n<ul>\n<li><strong>Go</strong> — a separate module (<code>babelqueue-go/otel</code>, its own <code>go.mod</code>), exactly like the transport submodules. The core module never sees the OTel dependency.</li>\n<li><strong>Python</strong> — an <code>[otel]</code> extra. <code>pip install babelqueue[otel]</code> pulls <code>opentelemetry-api</code>; the module imports it, so it is only importable when you asked for it. A TraceID here is a 128-bit <code>int</code>, not bytes — same value, different shape.</li>\n<li><strong>Node</strong> — a subpath export, <code>@babelqueue/core/otel</code>, with <code>@opentelemetry/api</code> as an <em>optional</em> peer dependency. Critically, the tracing code is <strong>not</strong> re-exported from the package root: if it were, importing <code>@babelqueue/core</code> would eagerly load the OTel import and break for anyone who didn’t install the peer. The subpath keeps the main entry truly dependency-free.</li>\n<li><strong>Java</strong> — an <code>optional</code> Maven dependency on <code>opentelemetry-api</code>. Optional dependencies are not transitive, so a consumer who never touches the tracing classes never pulls OTel onto their classpath.</li>\n<li><strong>.NET</strong> — the interesting one. The idiomatic tracing primitive in .NET is <code>System.Diagnostics.ActivitySource</code>, which lives in the <strong>base class library</strong> — and it is <em>exactly</em> what OpenTelemetry .NET is built on. So the .NET module needs <strong>no dependency at all</strong> — which is why it is the one that sits <em>inside</em> the core (<code>BabelQueue.Core / Telemetry</code>) rather than beside it: it emits <code>Activity</code> objects, and the consumer’s OTel pipeline collects them by calling <code>AddSource(&quot;BabelQueue&quot;)</code>. The core stays zero-dep not by isolating the dependency but by not having one.</li>\n<li><strong>PHP</strong> — a Composer <code>suggest</code> plus a dev requirement, mirroring the existing optional helpers. The tracing namespace is there; <code>open-telemetry/api</code> is only needed if you use it.</li>\n</ul>\n<p>Six idioms, one rule held in all of them: <strong>using observability is a choice the consumer makes, never a tax the core charges.</strong></p>\n<h2 id=\"what-do-you-actually-get\">What do you actually get?</h2>\n<p>Strip away the mechanics and the payoff is small to describe and large to have. In your existing tracing backend, a message that used to be a needle in three log haystacks becomes a single waterfall: which service produced it, how long it waited on the broker, how long each consumer took, whether it was retried, whether it was dead-lettered — across languages and brokers you never had to make agree on anything but a UUID they were already passing around.</p>\n<p>And the cost of that, on the wire, is <strong>nothing</strong>. The envelope that shipped before tracing existed and the envelope that ships with it are byte-for-byte identical. The trace was hiding in the <code>trace_id</code> the whole time; all the work was in noticing that a UUID and a TraceID are the same sixteen bytes.</p>\n<hr/>\n<p><em>See also:</em> the <a href=\"https://babelqueue.com/docs/spec/1.x/observability\">BabelQueue observability spec</a> writes this design down as a standard, and the SDKs that implement it — across PHP, Go, Python, Node, Java and .NET — live in the <a href=\"https://github.com/BabelQueue\">BabelQueue ecosystem</a>.</p>",
      "content_text": "A message is produced by a PHP service, lands on a queue, is consumed by a Go worker, which publishes a follow-up that a Python service handles. Four hops, three languages, one or more brokers in between. When that flow is slow, or one message in ten thousand dies, the question is always the same: **where did this message actually go, and what happened at each step?**\n\nThe honest answer, for most polyglot queue systems, is that nobody knows. You have a correlation id in the logs — if you remembered to log it in all three languages — and you have the patience to `grep` across three log streams on different hosts. What you do not have is a picture. You cannot see \"produced by PHP in 2ms → sat in Redis for 40ms → processed by Go in 210ms, retried twice, dead-lettered\" as one connected thing.\n\nThat picture is a **distributed trace**, and OpenTelemetry is the standard way to draw it. This piece is about adding it to a message standard whose wire format is **frozen** and whose cores carry **zero dependencies** — two constraints that, together, make the obvious approach illegal and force a more interesting one.\n\n## The one rule up front\n\nLet me state it before anything else: **do not add a field to the envelope.**\n\nThe instinct, when you want distributed tracing across a message bus, is to carry a W3C `traceparent` — the standard 55-character string that encodes a trace id, a span id, and flags. HTTP does exactly this in a header. The instinct is correct for HTTP and wrong here, because the envelope is a frozen contract. Every SDK in every language emits the byte-identical shape `job`, `trace_id`, `data`, `meta`, `attempts`. Adding a `traceparent` field — even an optional one — changes that shape, which means a version bump, which means coordinating a wire change across six language implementations and every broker binding. For a feature that is supposed to be *optional observability*, that is an absurd price.\n\nSo the rule is the constraint: solve tracing **without touching the wire**. Everything below follows from taking that seriously.\n\n## The insight nobody uses\n\nHere is the thing the envelope already gives you for free. The `trace_id` field is a correlation id — a UUID, minted at produce time and [forwarded unchanged across every hop](/en/systems/data-intensive-systems-breaking-points). It is already on the wire, already propagated, already the one value that ties the whole flow together.\n\nNow look at what OpenTelemetry uses to tie a trace together: a **TraceID**. In the spec, a TraceID is exactly **16 bytes**.\n\nA UUID is exactly 16 bytes.\n\nThat is the whole trick. A `trace_id` UUID maps one-to-one onto an OTel TraceID — strip the hyphens, read the 32 hex characters as the 16-byte id, done. (A `trace_id` that is *not* a UUID — say it came from a non-BabelQueue producer — gets hashed to 16 bytes with SHA-256, deterministically.) Every hop that shares a `trace_id` therefore derives the **same** OTel TraceID, with no agreement protocol and no new field. The correlation id you were already carrying *is* the distributed trace.\n\nSo the design writes itself:\n\n- On the **consumer** side, wrap the handler. Before it runs, start a span named `process <urn>`, but force that span into the trace derived from the message's `trace_id`. Tag it with the messaging conventions — `messaging.system`, `messaging.destination.name`, `messaging.message.id`, and `messaging.message.conversation_id` set to the `trace_id` itself — then run the handler, and record any exception as the span's error status. The runtime's retry / dead-letter behaviour is untouched; the span just observes it.\n- On the **producer** side, the mirror. Open a `publish <urn>` span, take *its* trace id, format it back into a UUID, and stamp that into the message's `trace_id` as you build the envelope. The downstream consumer, deriving its TraceID from that same `trace_id`, lands in the same trace.\n\nWire a `TracerProvider` for Jaeger, Tempo, Honeycomb or Datadog and the flow shows up as one waterfall, across all three languages, with per-hop timing and the retry/error markers in place. Don't wire one, and nothing changes — it is entirely opt-in.\n\n## The mechanic, and a phantom\n\nThere is one detail worth being precise about, because it is where the design is both clever and limited.\n\nTo start a span *inside a specific trace* in OpenTelemetry, you give it a **remote parent** — a span context carrying the trace id you want. But a span context is only valid if it has *both* a trace id and a span id. We have the trace id (from `trace_id`); we do **not** have the upstream span's id, because we deliberately did not propagate one. So we synthesize a deterministic, non-zero span id by hashing the `trace_id`. The parent is valid, the consumer span lands in the right trace — but its parent points at a span that never existed. A **phantom**.\n\nThis is the honest limit of the design as it stands above, and it is worth stating plainly. Cross-hop spans all share **one trace** — you can see every step of a message's life grouped together, timed, with errors marked. What this alone does **not** give you is exact parent-child linkage *between* hops: the consumer's span is not wired as the child of the producer's span, because the producer's real span id was never carried across the wire. Within a single process the hierarchy is correct; across the queue it is flat under one trace.\n\nGetting true cross-hop parent-child back means propagating a span id, which means a `traceparent` — and since the envelope is frozen, that `traceparent` has to ride **out of band**, as a transport header alongside whichever slot each broker binding already uses for the `trace_id`: a `bq-trace-id` attribute on SQS, Pulsar and Kafka, the native correlation id on RabbitMQ, Azure Service Bus and ActiveMQ Artemis, and on Redis — which carries no per-message metadata at all — nothing but the envelope body. That is a real feature, but it is a different scale of work: it touches every transport binding in every SDK. So it was left as a deliberate **phase two** — and that phase closed two days after this piece went up: ADR-0028 shipped `traceparent` transport-header propagation as v0.2 across all six SDK cores on 21 June 2026. A delivered `traceparent` now upgrades the consumer span to a true child of the producer span, and where it cannot be carried — PHP's Kafka, Pulsar and STOMP producers are the remaining gap — propagation degrades cleanly back to the v0.1 `trace_id` correlation described above. The ordering was the point: the phase-one design delivers correlation, per-hop timing and error/retry visibility — the 90% — with zero wire change and a few hundred lines per language, and it stays the floor that v0.2 sits on top of. Shipping the 90% first and the last 10% behind a bigger lift was the right order, the same way [you do the cheap scaling steps before the expensive ones](/en/systems/data-intensive-systems-breaking-points).\n\n## One semantic, six packaging idioms\n\nThe constraint that the **core stays dependency-free** has a consequence: wherever the OpenTelemetry code has to import the OTel API, it cannot live in the core. So in five of the six languages it lives *beside* the core, reached only when you opt in — and \"beside the core, optional\" is spelled differently in every ecosystem. (.NET is the exception, for a reason we will get to.) The semantics are identical in all six; the packaging is where each language shows its personality:\n\n- **Go** — a separate module (`babelqueue-go/otel`, its own `go.mod`), exactly like the transport submodules. The core module never sees the OTel dependency.\n- **Python** — an `[otel]` extra. `pip install babelqueue[otel]` pulls `opentelemetry-api`; the module imports it, so it is only importable when you asked for it. A TraceID here is a 128-bit `int`, not bytes — same value, different shape.\n- **Node** — a subpath export, `@babelqueue/core/otel`, with `@opentelemetry/api` as an *optional* peer dependency. Critically, the tracing code is **not** re-exported from the package root: if it were, importing `@babelqueue/core` would eagerly load the OTel import and break for anyone who didn't install the peer. The subpath keeps the main entry truly dependency-free.\n- **Java** — an `optional` Maven dependency on `opentelemetry-api`. Optional dependencies are not transitive, so a consumer who never touches the tracing classes never pulls OTel onto their classpath.\n- **.NET** — the interesting one. The idiomatic tracing primitive in .NET is `System.Diagnostics.ActivitySource`, which lives in the **base class library** — and it is *exactly* what OpenTelemetry .NET is built on. So the .NET module needs **no dependency at all** — which is why it is the one that sits *inside* the core (`BabelQueue.Core / Telemetry`) rather than beside it: it emits `Activity` objects, and the consumer's OTel pipeline collects them by calling `AddSource(\"BabelQueue\")`. The core stays zero-dep not by isolating the dependency but by not having one.\n- **PHP** — a Composer `suggest` plus a dev requirement, mirroring the existing optional helpers. The tracing namespace is there; `open-telemetry/api` is only needed if you use it.\n\nSix idioms, one rule held in all of them: **using observability is a choice the consumer makes, never a tax the core charges.**\n\n## What do you actually get?\n\nStrip away the mechanics and the payoff is small to describe and large to have. In your existing tracing backend, a message that used to be a needle in three log haystacks becomes a single waterfall: which service produced it, how long it waited on the broker, how long each consumer took, whether it was retried, whether it was dead-lettered — across languages and brokers you never had to make agree on anything but a UUID they were already passing around.\n\nAnd the cost of that, on the wire, is **nothing**. The envelope that shipped before tracing existed and the envelope that ships with it are byte-for-byte identical. The trace was hiding in the `trace_id` the whole time; all the work was in noticing that a UUID and a TraceID are the same sixteen bytes.\n\n---\n\n*See also:* the [BabelQueue observability spec](https://babelqueue.com/docs/spec/1.x/observability) writes this design down as a standard, and the SDKs that implement it — across PHP, Go, Python, Node, Java and .NET — live in the [BabelQueue ecosystem](https://github.com/BabelQueue).",
      "date_published": "2026-06-19T00:00:00.000Z",
      "tags": [
        "opentelemetry",
        "observability",
        "distributed-tracing",
        "messaging",
        "architecture",
        "System"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/notes/validate-schema-at-the-edge/",
      "url": "https://sade.dev/en/notes/validate-schema-at-the-edge/",
      "title": "Validating the Schema at the Edge of the Queue",
      "summary": "A broker moves bytes and never looks inside the payload, so the type safety you have inside one service evaporates the moment the data crosses the wire. Validate at both edges, but know which one pays: producer-side, before publish, fails synchronously in the service that actually has the bug and catches it once. Consumer-side is the safety net that dead-letters a poison message from a producer you do not control.",
      "content_html": "<p>A worker started throwing <code>KeyError</code> in production. The cause wasn’t its code — an upstream service had shipped a change that dropped a field from the event’s payload, and the queue had carried the malformed message across without a word. The bug wasn’t the bad message. It was that nothing checked it at the boundary.</p>\n<h2 id=\"the-queue-carries-bytes-not-types\">The queue carries bytes, not types</h2>\n<p>A request body hits a typed handler; a framework rejects it at the door if it’s the wrong shape. A queue gives you none of that. The payload is opaque JSON — the broker moves bytes and never looks inside. Whatever the producer put in, the consumer gets, valid or not. The type safety you have inside one service evaporates the moment the data crosses the wire.</p>\n<p>So if you want a guarantee about a message’s shape, you have to add it yourself — at the <strong>edge</strong>, where data enters and where it leaves.</p>\n<h2 id=\"validate-where-data-enters-and-where-it-leaves\">Validate where data enters, and where it leaves</h2>\n<p>There are two edges, and they catch different failures:</p>\n<ul>\n<li><strong>Producer-side, before publish — the primary one.</strong> Validate the payload as you produce it. Invalid data never enters the queue, the failure surfaces <em>synchronously</em> in the service that actually has the bug, and it’s caught once — before the message fans out to every consumer. This is the cheap place to fail.</li>\n<li><strong>Consumer-side, on receive — the safety net.</strong> Validate again as you consume. This catches what producer-side can’t: messages from producers you don’t control — another team, an older deployed version, a hand-crafted replay. A message that fails here is a poison message; route it to a dead-letter queue rather than letting it crash the handler in a loop.</li>\n</ul>\n<p>A subtlety worth knowing: most queue runtimes have no “reject immediately” hook, so a consumer-side rejection usually <em>retries</em> before it dead-letters — and invalid data never becomes valid on retry. That’s wasted work, which is exactly why producer-side is where the value is. Consumer-side is the seatbelt, not the steering.</p>\n<h2 id=\"the-schema-lives-with-the-message-not-the-envelope\">The schema lives with the message, not the envelope</h2>\n<p>The thing you validate against is a schema <em>per message type</em> — keyed by the event’s identity, not bolted onto the transport envelope. Keep it in a registry that versions independently, and the same schema you <a href=\"/en/journal/schema-evolution-without-breaking/\">evolve carefully without breaking consumers</a> becomes the schema you enforce at runtime. One contract, checked at change-time (does this edit break anyone?) and at runtime (does this message obey it?).</p>\n<p>This is the structural cousin of <a href=\"/en/notes/idempotency-duplicate-delivery/\">making a duplicate delivery a no-op</a>: both accept that you don’t control what arrives, and put the guarantee in your own boundary instead of trusting the sender.</p>\n<h2 id=\"when-is-it-not-worth-it\">When is it not worth it?</h2>\n<p>A single service’s internal queue — one producer, one consumer, deployed together — doesn’t need this. The type system already spans both ends; edge validation is ceremony. It starts paying the moment a <em>second</em>, independently-deployed producer or consumer exists — a different team, a different language, a different release cadence. That’s also the moment the untyped payload quietly becomes your most fragile contract.</p>\n<hr/>\n<p><em>See also:</em> the <a href=\"https://babelqueue.com/docs/spec/1.x/schema-validation\">BabelQueue schema-validation spec</a> defines the per-URN schema and where it’s enforced, and the <a href=\"https://github.com/BabelQueue/babelqueue-registry\">babelqueue-registry</a> holds those schemas — its <code>bqschema</code> tool is what runs the check.</p>",
      "content_text": "A worker started throwing `KeyError` in production. The cause wasn't its code — an upstream service had shipped a change that dropped a field from the event's payload, and the queue had carried the malformed message across without a word. The bug wasn't the bad message. It was that nothing checked it at the boundary.\n\n## The queue carries bytes, not types\n\nA request body hits a typed handler; a framework rejects it at the door if it's the wrong shape. A queue gives you none of that. The payload is opaque JSON — the broker moves bytes and never looks inside. Whatever the producer put in, the consumer gets, valid or not. The type safety you have inside one service evaporates the moment the data crosses the wire.\n\nSo if you want a guarantee about a message's shape, you have to add it yourself — at the **edge**, where data enters and where it leaves.\n\n## Validate where data enters, and where it leaves\n\nThere are two edges, and they catch different failures:\n\n- **Producer-side, before publish — the primary one.** Validate the payload as you produce it. Invalid data never enters the queue, the failure surfaces *synchronously* in the service that actually has the bug, and it's caught once — before the message fans out to every consumer. This is the cheap place to fail.\n- **Consumer-side, on receive — the safety net.** Validate again as you consume. This catches what producer-side can't: messages from producers you don't control — another team, an older deployed version, a hand-crafted replay. A message that fails here is a poison message; route it to a dead-letter queue rather than letting it crash the handler in a loop.\n\nA subtlety worth knowing: most queue runtimes have no \"reject immediately\" hook, so a consumer-side rejection usually *retries* before it dead-letters — and invalid data never becomes valid on retry. That's wasted work, which is exactly why producer-side is where the value is. Consumer-side is the seatbelt, not the steering.\n\n## The schema lives with the message, not the envelope\n\nThe thing you validate against is a schema *per message type* — keyed by the event's identity, not bolted onto the transport envelope. Keep it in a registry that versions independently, and the same schema you [evolve carefully without breaking consumers](/en/journal/schema-evolution-without-breaking) becomes the schema you enforce at runtime. One contract, checked at change-time (does this edit break anyone?) and at runtime (does this message obey it?).\n\nThis is the structural cousin of [making a duplicate delivery a no-op](/en/notes/idempotency-duplicate-delivery): both accept that you don't control what arrives, and put the guarantee in your own boundary instead of trusting the sender.\n\n## When is it not worth it?\n\nA single service's internal queue — one producer, one consumer, deployed together — doesn't need this. The type system already spans both ends; edge validation is ceremony. It starts paying the moment a *second*, independently-deployed producer or consumer exists — a different team, a different language, a different release cadence. That's also the moment the untyped payload quietly becomes your most fragile contract.\n\n---\n\n*See also:* the [BabelQueue schema-validation spec](https://babelqueue.com/docs/spec/1.x/schema-validation) defines the per-URN schema and where it's enforced, and the [babelqueue-registry](https://github.com/BabelQueue/babelqueue-registry) holds those schemas — its `bqschema` tool is what runs the check.",
      "date_published": "2026-06-18T00:00:00.000Z",
      "tags": [
        "queue",
        "schema",
        "reliability",
        "architecture",
        "Note"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/journal/architecture-decisions-rot/",
      "url": "https://sade.dev/en/journal/architecture-decisions-rot/",
      "title": "Architecture Decisions Rot Because Nothing Runs Them",
      "summary": "An architecture decision is a wish, not a rule, unless something checks it on every commit. A decision has two parts: the rationale, which belongs in prose because a tool cannot hold it, and the constraint, which is a claim about the import graph that a tool can evaluate deterministically and fail the build on. Keep writing the why for humans; hand the boundary to a machine that never gets tired.",
      "content_html": "<p>A new engineer asked me why the domain layer was importing the database package — “doesn’t an ADR (architecture decision record) say it shouldn’t?” It did. It had said so for two years. And for most of those two years, somewhere, the rule had been quietly broken: a helper here, a “just this once” import there, each one reasonable in its own pull request. The decision was still true on the wiki. It had stopped being true in the code months ago, and nobody noticed, because nothing was watching.</p>\n<p>A decision you write down but can’t enforce isn’t a rule. It’s a wish with good formatting.</p>\n<h2 id=\"the-diagram-and-the-code-drift-apart-in-small-reasonable-steps\">The diagram and the code drift apart in small, reasonable steps</h2>\n<p>Nobody decides to violate the architecture. It erodes one defensible commit at a time: a synchronous call added because the async path was slower to write, an import that crosses a boundary because the function you needed happened to live on the other side. Each diff looks fine on its own. The damage is <strong>cumulative and structural</strong> — invisible at the scale of a single change, obvious only when you step back and find the layering gone.</p>\n<p>That’s exactly the scale a human review operates at, and exactly the scale it misses. <span data-scheduled=\"/en/journal/code-review-culture/\">Review works best on the architecture of a change</span>, but a reviewer sees one PR, not the thousandth import that finally dissolved the boundary. You can’t ask a person to hold the whole dependency graph in their head on every commit. The wiki page can’t either; it’s prose, and prose doesn’t fail a build.</p>\n<h2 id=\"make-the-constraint-executable\">Make the constraint executable</h2>\n<p>The fix isn’t a better-written ADR. It’s moving the <em>enforceable part</em> of the decision out of prose and into something that runs.</p>\n<p>“The domain must not depend on infrastructure” is not, at heart, an opinion — it’s a constraint on the import graph: files under <code>domain/</code> may not import <code>db/</code>. That’s a rule a tool can evaluate on every commit, deterministically, and fail the build when it’s broken — before the violating import reaches <code>main</code>, not two years later. The cost of catching it moves from “an archaeology session when something finally breaks” to “a red check on the PR that introduced it.”</p>\n<p>This is the same move as <a href=\"/en/journal/schema-evolution-without-breaking/\">versioning a schema instead of mutating it</a>: a rule everyone <em>agrees</em> on is worth nothing until something mechanical holds the line, because human discipline doesn’t scale to every commit across every contributor.</p>\n<h2 id=\"what-stays-in-the-document-and-what-becomes-code\">What stays in the document, and what becomes code</h2>\n<p>This doesn’t kill the ADR — it splits it. An architecture decision has two parts: the <strong>rationale</strong> (why we chose hexagonal layering, what we traded away, when we’d revisit it) and the <strong>constraint</strong> (domain imports nothing; the API talks to the DB only through a repository). The rationale belongs in prose — it’s context a tool can’t hold and a human needs. The constraint belongs in code, where it can be checked.</p>\n<p>Keep writing the <em>why</em> down. Stop trusting prose to enforce the <em>what</em>.</p>\n<h2 id=\"when-does-this-stop-mattering\">When does this stop mattering?</h2>\n<p>If your whole system is two layers and a team that fits around one table, the boundaries live in everyone’s head, and a CI gate for them is ceremony. The moment a third contributor joins, or the codebase outgrows what one person can hold, the unenforced boundary is <em>already</em> eroding — you just won’t see it until the new engineer asks why domain imports the database.</p>\n<hr/>\n<p>An architecture is not what you decided; it’s what your code currently does. The only decisions that survive contact with a growing codebase are the ones something checks on every commit. Write the rationale for humans — and hand the boundary to a machine that never gets tired of enforcing it.</p>\n<hr/>\n<p><em>See also:</em> <a href=\"https://github.com/muhammetsafak/archlint\">archlint</a> is the tool I built for exactly this — a deterministic import-boundary linter (Go, TypeScript and Python) that reads your layer rules and fails the build on a violation, with a packaged CI Action.</p>",
      "content_text": "A new engineer asked me why the domain layer was importing the database package — \"doesn't an ADR (architecture decision record) say it shouldn't?\" It did. It had said so for two years. And for most of those two years, somewhere, the rule had been quietly broken: a helper here, a \"just this once\" import there, each one reasonable in its own pull request. The decision was still true on the wiki. It had stopped being true in the code months ago, and nobody noticed, because nothing was watching.\n\nA decision you write down but can't enforce isn't a rule. It's a wish with good formatting.\n\n## The diagram and the code drift apart in small, reasonable steps\n\nNobody decides to violate the architecture. It erodes one defensible commit at a time: a synchronous call added because the async path was slower to write, an import that crosses a boundary because the function you needed happened to live on the other side. Each diff looks fine on its own. The damage is **cumulative and structural** — invisible at the scale of a single change, obvious only when you step back and find the layering gone.\n\nThat's exactly the scale a human review operates at, and exactly the scale it misses. Review works best on the architecture of a change, but a reviewer sees one PR, not the thousandth import that finally dissolved the boundary. You can't ask a person to hold the whole dependency graph in their head on every commit. The wiki page can't either; it's prose, and prose doesn't fail a build.\n\n## Make the constraint executable\n\nThe fix isn't a better-written ADR. It's moving the *enforceable part* of the decision out of prose and into something that runs.\n\n\"The domain must not depend on infrastructure\" is not, at heart, an opinion — it's a constraint on the import graph: files under `domain/` may not import `db/`. That's a rule a tool can evaluate on every commit, deterministically, and fail the build when it's broken — before the violating import reaches `main`, not two years later. The cost of catching it moves from \"an archaeology session when something finally breaks\" to \"a red check on the PR that introduced it.\"\n\nThis is the same move as [versioning a schema instead of mutating it](/en/journal/schema-evolution-without-breaking): a rule everyone *agrees* on is worth nothing until something mechanical holds the line, because human discipline doesn't scale to every commit across every contributor.\n\n## What stays in the document, and what becomes code\n\nThis doesn't kill the ADR — it splits it. An architecture decision has two parts: the **rationale** (why we chose hexagonal layering, what we traded away, when we'd revisit it) and the **constraint** (domain imports nothing; the API talks to the DB only through a repository). The rationale belongs in prose — it's context a tool can't hold and a human needs. The constraint belongs in code, where it can be checked.\n\nKeep writing the *why* down. Stop trusting prose to enforce the *what*.\n\n## When does this stop mattering?\n\nIf your whole system is two layers and a team that fits around one table, the boundaries live in everyone's head, and a CI gate for them is ceremony. The moment a third contributor joins, or the codebase outgrows what one person can hold, the unenforced boundary is *already* eroding — you just won't see it until the new engineer asks why domain imports the database.\n\n---\n\nAn architecture is not what you decided; it's what your code currently does. The only decisions that survive contact with a growing codebase are the ones something checks on every commit. Write the rationale for humans — and hand the boundary to a machine that never gets tired of enforcing it.\n\n---\n\n*See also:* [archlint](https://github.com/muhammetsafak/archlint) is the tool I built for exactly this — a deterministic import-boundary linter (Go, TypeScript and Python) that reads your layer rules and fails the build on a violation, with a packaged CI Action.",
      "date_published": "2026-06-18T00:00:00.000Z",
      "tags": [
        "architecture",
        "governance",
        "opinion",
        "Journal"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/notes/idempotency-duplicate-delivery/",
      "url": "https://sade.dev/en/notes/idempotency-duplicate-delivery/",
      "title": "Idempotency: When the Same Message Arrives Twice",
      "summary": "At-least-once is a guarantee to design around, not a bug to remove. The key has to be minted by the sender and never derived from the payload alone, and the gap between doing the work and recording the key decides the shape: record it in the same transaction when you own the effect, keep a seen-set when you do not. Concurrent duplicates need a unique constraint, not a check.",
      "content_html": "<p>A customer got charged twice for one order. Nothing ran twice on purpose: the payment worker charged the card, then crashed before it could ack the message — so the broker, doing exactly what it promised, delivered the message again.</p>\n<p>The duplicate wasn’t the bug. The handler that wasn’t built for a duplicate was.</p>\n<h2 id=\"at-least-once-is-a-promise-not-a-glitch\">At-least-once is a promise, not a glitch</h2>\n<p>Almost every queue — and every retried HTTP call — is <strong>at-least-once</strong>. A handler MAY see the same message more than once: after a crash, a release, a redelivery, or a client that timed out and retried. Exactly-once across a network is impractical without coordination the broker can’t give you cheaply, so few brokers offer it — and those that do, like Google Cloud Pub/Sub, fence it in with narrow conditions: a single region, pull subscriptions only.</p>\n<p>That reframes the problem. The redelivery isn’t a failure to eliminate; it’s a guarantee to design around. (It’s the same trade the <a href=\"/en/notes/sync-vs-async/\">queue</a> makes when it retries a failed job instead of losing it.)</p>\n<h2 id=\"idempotent-means-twice-equals-once\">Idempotent means “twice equals once”</h2>\n<p>An operation is idempotent if applying it many times has the same effect as applying it once — <code>f(f(x)) = f(x)</code>.</p>\n<ul>\n<li>“Set order status to <code>paid</code>” is idempotent.</li>\n<li>“Increment balance by 10” is <strong>not</strong> — twice is +20.</li>\n<li>“Send the welcome email” is <strong>not</strong> — twice is two emails.</li>\n</ul>\n<p>The whole job is turning the second kind of operation into the first.</p>\n<h2 id=\"the-idempotency-key-comes-from-the-sender\">The idempotency key comes from the sender</h2>\n<p>You need a stable identity for <em>this operation</em>, and it must be decided by the <strong>sender</strong>, not the receiver:</p>\n<ul>\n<li>For a queue message, it’s the <strong>per-message id the producer mints</strong> — one id per message, distinct from the trace/correlation id (which spans many messages).</li>\n<li>For an HTTP write, it’s a <strong>client-generated <code>Idempotency-Key</code> header</strong> — the Stripe model.</li>\n</ul>\n<p>Two rules keep the key honest:</p>\n<ul>\n<li><strong>Don’t derive it from the payload alone.</strong> Two legitimately identical requests — the same customer buying the same item twice — would collide and the second would be silently dropped.</li>\n<li><strong>Don’t let the receiver mint it.</strong> The receiver can’t tell a retry from a brand-new call; only the sender knows “this is the same operation I tried before.”</li>\n</ul>\n<h2 id=\"dedupe-remember-what-youve-already-done\">Dedupe: remember what you’ve already done</h2>\n<p>The handler does one check before the work: <em>has this key been processed?</em> If yes, skip and ack. If no, do the work, then record the key. That’s the entire pattern — the weight is in <em>where</em> the record lives.</p>\n<h2 id=\"the-crash-between-the-work-and-the-record\">The crash between the work and the record</h2>\n<p>The hard part is the gap between doing the work and recording the key. Two shapes, and the right one depends on the side effect:</p>\n<ul>\n<li><strong>Seen-set — record after success.</strong> Store the key in Redis or a table once the handler returns. Cheap and broadly applicable. The window: crash <em>after</em> the side effect but <em>before</em> recording, and a redelivery reprocesses. That’s fine when the side effect is itself idempotent — an <code>UPSERT</code>, a “set to paid”.</li>\n<li><strong>Transactional — record with the work.</strong> Write the idempotency record in the <strong>same database transaction</strong> as the business change. No window: the key and the effect commit or roll back together. The cost: the effect must be a DB write you control, and the key store must be that same database — not a separate Redis. For an effect you own, this is what kills the duplicate for real.</li>\n</ul>\n<p>So the side effect chooses for you: a row you own → transactional; a third-party call you can’t enlist in your transaction (email, a charge) → seen-set, and make the downstream idempotent too by forwarding your key as <em>its</em> <code>Idempotency-Key</code>.</p>\n<h2 id=\"concurrent-duplicates-need-a-constraint-not-a-check\">Concurrent duplicates need a constraint, not a check</h2>\n<p>Two deliveries of the same key racing before either records it slip through a “check then write” — that sequence isn’t atomic. A <strong>unique constraint on the key column</strong> is: the second insert becomes a conflict you catch and treat as “already handled.” This is the same shape as the <a href=\"/en/systems/race-conditions-and-gaps-in-sequential-numbering/\">gaps a race opens in sequential numbering</a> — the database, not the application, is where uniqueness is actually enforced.</p>\n<h2 id=\"when-do-you-not-need-any-of-this\">When do you not need any of this?</h2>\n<p>If the handler is already idempotent — a pure <code>UPSERT</code> keyed by a stable id, a “set field to value” — you may need nothing at all. Reach for a key plus dedupe when the operation has a <strong>non-idempotent side effect</strong> (money, email, an external <code>POST</code>) or <strong>accumulates</strong> (increment, append). Spend the complexity only where a second run does real damage.</p>\n<hr/>\n<p>At-least-once isn’t the part you fix; it’s the part you accept. Build the handler so the second delivery is a no-op, and redelivery stops being an incident and goes back to being what it is — the broker keeping its promise.</p>\n<hr/>\n<p><em>See also:</em> the <a href=\"https://babelqueue.com/docs/spec/1.x/idempotency\">BabelQueue idempotency spec</a> pins down where the key comes from and how dedupe behaves, and the <a href=\"https://github.com/BabelQueue/babelqueue-examples/tree/main/idempotency-payments\"><code>idempotency-payments</code> example</a> is exactly this double-charge case made runnable.</p>",
      "content_text": "A customer got charged twice for one order. Nothing ran twice on purpose: the payment worker charged the card, then crashed before it could ack the message — so the broker, doing exactly what it promised, delivered the message again.\n\nThe duplicate wasn't the bug. The handler that wasn't built for a duplicate was.\n\n## At-least-once is a promise, not a glitch\n\nAlmost every queue — and every retried HTTP call — is **at-least-once**. A handler MAY see the same message more than once: after a crash, a release, a redelivery, or a client that timed out and retried. Exactly-once across a network is impractical without coordination the broker can't give you cheaply, so few brokers offer it — and those that do, like Google Cloud Pub/Sub, fence it in with narrow conditions: a single region, pull subscriptions only.\n\nThat reframes the problem. The redelivery isn't a failure to eliminate; it's a guarantee to design around. (It's the same trade the [queue](/en/notes/sync-vs-async) makes when it retries a failed job instead of losing it.)\n\n## Idempotent means \"twice equals once\"\n\nAn operation is idempotent if applying it many times has the same effect as applying it once — `f(f(x)) = f(x)`.\n\n- \"Set order status to `paid`\" is idempotent.\n- \"Increment balance by 10\" is **not** — twice is +20.\n- \"Send the welcome email\" is **not** — twice is two emails.\n\nThe whole job is turning the second kind of operation into the first.\n\n## The idempotency key comes from the sender\n\nYou need a stable identity for *this operation*, and it must be decided by the **sender**, not the receiver:\n\n- For a queue message, it's the **per-message id the producer mints** — one id per message, distinct from the trace/correlation id (which spans many messages).\n- For an HTTP write, it's a **client-generated `Idempotency-Key` header** — the Stripe model.\n\nTwo rules keep the key honest:\n\n- **Don't derive it from the payload alone.** Two legitimately identical requests — the same customer buying the same item twice — would collide and the second would be silently dropped.\n- **Don't let the receiver mint it.** The receiver can't tell a retry from a brand-new call; only the sender knows \"this is the same operation I tried before.\"\n\n## Dedupe: remember what you've already done\n\nThe handler does one check before the work: *has this key been processed?* If yes, skip and ack. If no, do the work, then record the key. That's the entire pattern — the weight is in *where* the record lives.\n\n## The crash between the work and the record\n\nThe hard part is the gap between doing the work and recording the key. Two shapes, and the right one depends on the side effect:\n\n- **Seen-set — record after success.** Store the key in Redis or a table once the handler returns. Cheap and broadly applicable. The window: crash *after* the side effect but *before* recording, and a redelivery reprocesses. That's fine when the side effect is itself idempotent — an `UPSERT`, a \"set to paid\".\n- **Transactional — record with the work.** Write the idempotency record in the **same database transaction** as the business change. No window: the key and the effect commit or roll back together. The cost: the effect must be a DB write you control, and the key store must be that same database — not a separate Redis. For an effect you own, this is what kills the duplicate for real.\n\nSo the side effect chooses for you: a row you own → transactional; a third-party call you can't enlist in your transaction (email, a charge) → seen-set, and make the downstream idempotent too by forwarding your key as *its* `Idempotency-Key`.\n\n## Concurrent duplicates need a constraint, not a check\n\nTwo deliveries of the same key racing before either records it slip through a \"check then write\" — that sequence isn't atomic. A **unique constraint on the key column** is: the second insert becomes a conflict you catch and treat as \"already handled.\" This is the same shape as the [gaps a race opens in sequential numbering](/en/systems/race-conditions-and-gaps-in-sequential-numbering) — the database, not the application, is where uniqueness is actually enforced.\n\n## When do you not need any of this?\n\nIf the handler is already idempotent — a pure `UPSERT` keyed by a stable id, a \"set field to value\" — you may need nothing at all. Reach for a key plus dedupe when the operation has a **non-idempotent side effect** (money, email, an external `POST`) or **accumulates** (increment, append). Spend the complexity only where a second run does real damage.\n\n---\n\nAt-least-once isn't the part you fix; it's the part you accept. Build the handler so the second delivery is a no-op, and redelivery stops being an incident and goes back to being what it is — the broker keeping its promise.\n\n---\n\n*See also:* the [BabelQueue idempotency spec](https://babelqueue.com/docs/spec/1.x/idempotency) pins down where the key comes from and how dedupe behaves, and the [`idempotency-payments` example](https://github.com/BabelQueue/babelqueue-examples/tree/main/idempotency-payments) is exactly this double-charge case made runnable.",
      "date_published": "2026-06-17T00:00:00.000Z",
      "tags": [
        "queue",
        "reliability",
        "architecture",
        "async",
        "Note"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/journal/ai-didnt-remove-the-bottleneck/",
      "url": "https://sade.dev/en/journal/ai-didnt-remove-the-bottleneck/",
      "title": "AI Didn't Remove the Bottleneck — It Moved It",
      "summary": "Making code production three times faster does not triple throughput; it moves the constraint one station downstream and grows a pile of work in progress in front of it. That pile is where the review queue, the flaky suite and the integration bugs come from. The durable investment is the unglamorous downstream: deterministic verification, idempotent integration, and the obvious failure caught at the commit boundary.",
      "content_html": "<p>A team I watched started shipping roughly twice the code once they leaned on agents. Six weeks later they were shipping <em>slower</em>. The pull-request queue had tripled, CI was red more often than green, and two incidents traced back to changes nobody had really read. Output went up; throughput went down.</p>\n<p>Nothing about that is mysterious. They sped up one station on the line and called it a win.</p>\n<h2 id=\"you-cant-speed-up-one-station-and-call-it-throughput\">You can’t speed up one station and call it throughput</h2>\n<p>A delivery pipeline is a line of stations: write → review → integrate → verify → release. Its throughput is set by the <strong>slowest</strong> station, not the fastest. Make writing 3× faster and, if review and verification stay where they were, you haven’t tripled throughput — you’ve just moved the constraint one station downstream and grown a pile of work-in-progress in front of it.</p>\n<p>That pile isn’t free. It’s unreviewed PRs aging into merge conflicts, half-integrated branches, a test suite that grew faster than anyone can keep green. The bottleneck didn’t disappear when code got cheap. It relocated — and got harder to see, because it stopped looking like “typing code” and started looking like “waiting.”</p>\n<h2 id=\"dora-already-has-the-word-for-this-amplifier\">DORA already has the word for this: amplifier</h2>\n<p>Data from the <a href=\"https://dora.dev/research/2024/dora-report/\">2024</a> and <a href=\"https://dora.dev/dora-report-2025/\">2025</a> DORA (DevOps Research and Assessment) reports lands on the same point from the other side: AI is an <strong>amplifier, not a shortcut</strong>. Teams with strong delivery practices get more out of it; teams without them ship their dysfunction faster. Both reports saw AI adoption <em>correlate with lower stability</em> — because the process, not the typing, was always the real constraint.</p>\n<p>An amplifier turns up whatever you feed it. Feed it a clean signal and it’s louder and clearer; feed it noise and it’s just louder noise. AI doesn’t decide which one you’ve got. Your downstream does.</p>\n<h2 id=\"where-the-bottleneck-actually-went\">Where the bottleneck actually went</h2>\n<p>It moved to the parts that were always the expensive ones, and are now drowning:</p>\n<ul>\n<li><strong>Review.</strong> Generation produces PRs faster than humans can give them real attention — so review degrades into rubber-stamping, which is how unread changes reach production.</li>\n<li><strong>Verification.</strong> Tests are now nearly free to write, which is exactly why suites fill with <a href=\"/en/journal/flaky-tests-and-determinism/\">flaky, timing-dependent ones</a> that erode trust faster than they build it.</li>\n<li><strong>Integration.</strong> More code, written faster by more hands, means more coupling and more ways for <a href=\"/en/notes/idempotency-duplicate-delivery/\">the same message or request to arrive twice</a> and corrupt state.</li>\n</ul>\n<p>None of these is a typing problem. All of them are the bill for typing faster.</p>\n<h2 id=\"the-durable-investment-is-the-boring-downstream\">The durable investment is the boring downstream</h2>\n<p>So the engineering edge in this era isn’t “the AI wrote it.” It’s keeping <strong>the cost of trusting what was written</strong> low enough that the new bottleneck doesn’t choke. That work is unglamorous and it’s where I’d spend the time AI gives back:</p>\n<ul>\n<li>Make verification <strong>deterministic</strong>, so a green check means something and review can lean on it.</li>\n<li>Make integration <strong>idempotent and resilient</strong>, so faster, messier change doesn’t turn redelivery into a double-charge.</li>\n<li>Make review <span data-scheduled=\"/en/journal/code-review-culture/\">about architecture, not commas</span> — hand the mechanical parts to tools so the human attention you can’t scale goes to the decisions that matter.</li>\n<li>Catch the obvious failure at the <strong>commit boundary</strong>, not three stations later in CI where it costs a hundred times more.</li>\n</ul>\n<p>This is the same lesson as <a href=\"/en/journal/can-you-ship-vibe-coded-to-production/\">“vibe coding lowers the cost of a first version, not the cost of being wrong”</a> — read at the level of the whole pipeline instead of a single change.</p>\n<h2 id=\"when-does-this-not-apply\">When does this not apply?</h2>\n<p>If your real constraint genuinely <em>is</em> code production — a true greenfield, a solo prototype, a spike where there’s nothing downstream to protect yet — then go fast and don’t build process for a system that doesn’t exist. The shift only bites once there’s something to keep honest: a team, a queue of reviews, a production others depend on.</p>\n<p>Knowing which world you’re in is the actual judgment call. Most teams shipping twice the code are no longer in the first one.</p>\n<hr/>\n<p>AI didn’t make engineering cheaper; it made <em>one part</em> of it cheaper. The discipline is to spend the time you saved on the part that just became the bottleneck — not to pour the saved time back into producing even more of what’s already piling up.</p>",
      "content_text": "A team I watched started shipping roughly twice the code once they leaned on agents. Six weeks later they were shipping *slower*. The pull-request queue had tripled, CI was red more often than green, and two incidents traced back to changes nobody had really read. Output went up; throughput went down.\n\nNothing about that is mysterious. They sped up one station on the line and called it a win.\n\n## You can't speed up one station and call it throughput\n\nA delivery pipeline is a line of stations: write → review → integrate → verify → release. Its throughput is set by the **slowest** station, not the fastest. Make writing 3× faster and, if review and verification stay where they were, you haven't tripled throughput — you've just moved the constraint one station downstream and grown a pile of work-in-progress in front of it.\n\nThat pile isn't free. It's unreviewed PRs aging into merge conflicts, half-integrated branches, a test suite that grew faster than anyone can keep green. The bottleneck didn't disappear when code got cheap. It relocated — and got harder to see, because it stopped looking like \"typing code\" and started looking like \"waiting.\"\n\n## DORA already has the word for this: amplifier\n\nData from the [2024](https://dora.dev/research/2024/dora-report/) and [2025](https://dora.dev/dora-report-2025/) DORA (DevOps Research and Assessment) reports lands on the same point from the other side: AI is an **amplifier, not a shortcut**. Teams with strong delivery practices get more out of it; teams without them ship their dysfunction faster. Both reports saw AI adoption *correlate with lower stability* — because the process, not the typing, was always the real constraint.\n\nAn amplifier turns up whatever you feed it. Feed it a clean signal and it's louder and clearer; feed it noise and it's just louder noise. AI doesn't decide which one you've got. Your downstream does.\n\n## Where the bottleneck actually went\n\nIt moved to the parts that were always the expensive ones, and are now drowning:\n\n- **Review.** Generation produces PRs faster than humans can give them real attention — so review degrades into rubber-stamping, which is how unread changes reach production.\n- **Verification.** Tests are now nearly free to write, which is exactly why suites fill with [flaky, timing-dependent ones](/en/journal/flaky-tests-and-determinism) that erode trust faster than they build it.\n- **Integration.** More code, written faster by more hands, means more coupling and more ways for [the same message or request to arrive twice](/en/notes/idempotency-duplicate-delivery) and corrupt state.\n\nNone of these is a typing problem. All of them are the bill for typing faster.\n\n## The durable investment is the boring downstream\n\nSo the engineering edge in this era isn't \"the AI wrote it.\" It's keeping **the cost of trusting what was written** low enough that the new bottleneck doesn't choke. That work is unglamorous and it's where I'd spend the time AI gives back:\n\n- Make verification **deterministic**, so a green check means something and review can lean on it.\n- Make integration **idempotent and resilient**, so faster, messier change doesn't turn redelivery into a double-charge.\n- Make review about architecture, not commas — hand the mechanical parts to tools so the human attention you can't scale goes to the decisions that matter.\n- Catch the obvious failure at the **commit boundary**, not three stations later in CI where it costs a hundred times more.\n\nThis is the same lesson as [\"vibe coding lowers the cost of a first version, not the cost of being wrong\"](/en/journal/can-you-ship-vibe-coded-to-production) — read at the level of the whole pipeline instead of a single change.\n\n## When does this not apply?\n\nIf your real constraint genuinely *is* code production — a true greenfield, a solo prototype, a spike where there's nothing downstream to protect yet — then go fast and don't build process for a system that doesn't exist. The shift only bites once there's something to keep honest: a team, a queue of reviews, a production others depend on.\n\nKnowing which world you're in is the actual judgment call. Most teams shipping twice the code are no longer in the first one.\n\n---\n\nAI didn't make engineering cheaper; it made *one part* of it cheaper. The discipline is to spend the time you saved on the part that just became the bottleneck — not to pour the saved time back into producing even more of what's already piling up.",
      "date_published": "2026-06-17T00:00:00.000Z",
      "tags": [
        "ai-workflow",
        "productivity",
        "opinion",
        "Journal"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/journal/flaky-tests-and-determinism/",
      "url": "https://sade.dev/en/journal/flaky-tests-and-determinism/",
      "title": "Flaky Tests: Retrying Isn't a Fix",
      "summary": "Retrying, quarantine and a ticket do not make a flaky test deterministic; they hide the symptom after CI has already broken. Most flakiness comes from a small set of known anti-patterns — a fixed sleep, unseeded randomness, a wall-clock assertion, a structural selector — and every one of them is visible statically in the diff the moment the test is written. That line is the cheapest place to catch it.",
      "content_html": "<p>A pull request crossed my review: half the diff was tests, and a model had written them. They passed locally. In CI they failed once, passed on a re-run, then failed again two days later under load. Nobody had touched the code.</p>\n<p>That test wasn’t wrong. It was <strong>flaky</strong> — and flaky is its own kind of broken.</p>\n<h2 id=\"ai-didnt-invent-flakiness--it-mass-produced-it\">AI didn’t invent flakiness — it mass-produced it</h2>\n<p>Writing a test used to cost something, so you wrote fewer and thought harder about each. A model writes them in seconds, from the code’s current shape alone. It can’t see the concurrency, the resource limits, or the real timing of an async call — so it reaches for the patterns that <em>look</em> right and are quietly non-deterministic:</p>\n<ul>\n<li>A fixed sleep to “wait” for async work: <code>time.Sleep(2 * time.Second)</code>, <code>await page.waitForTimeout(2000)</code>, <code>cy.wait(2000)</code>.</li>\n<li>An unseeded random source whose value differs every run: <code>Math.random()</code>, <code>rand.Intn(100)</code>, a <code>faker</code> with no fixed seed.</li>\n<li>A brittle selector pinned to DOM structure — <code>nth-child</code>, an auto-generated class name — that breaks the moment the markup shifts.</li>\n<li>A mock for everything, so the test verifies an interaction, not a result — the same empty green I wrote about in <span data-scheduled=\"/en/journal/a-lean-testing-culture/\">chasing coverage with tests that do nothing</span>.</li>\n</ul>\n<p>Each looks reasonable in isolation. At scale they’re a flakiness factory.</p>\n<h2 id=\"the-suite-gets-less-reliable-as-it-grows\">The suite gets <em>less</em> reliable as it grows</h2>\n<p>A single test with a 0.5% chance of a spurious failure is invisible. Put 800 of those in a suite and the chance that <em>some</em> test fails on a clean run is 1 − 0.995^800 ≈ 98%. The failures aren’t independent of each other either: CI runs on shared, throttled hardware where the async test that finishes in 50 ms on your laptop times out under load.</p>\n<p>As the model keeps adding tests, that count climbs and the suite’s odds of a false red march toward certainty. The pass you care about drowns in noise you don’t.</p>\n<h2 id=\"retrying-is-a-treadmill-not-a-cure\">Retrying is a treadmill, not a cure</h2>\n<p>The reflex is to absorb the noise: auto-retry the job, mark the test flaky, quarantine it, open a ticket. Every one of these is <strong>reactive</strong> — it acts after the test has already broken CI, usually more than once.</p>\n<ul>\n<li>Retries burn real CI minutes and stretch the feedback loop; you pay, twice, to learn nothing new.</li>\n<li>Quarantine grows a silent pile of disabled tests. That pile is debt, and it compounds — each skipped test is a check nobody runs anymore.</li>\n<li>The ticket ages into backlog noise and quietly stops mattering.</li>\n</ul>\n<p>None of it makes the test deterministic. It hides the symptom and bills you for the privilege.</p>\n<h2 id=\"catch-the-anti-pattern-not-the-failure\">Catch the anti-pattern, not the failure</h2>\n<p>A flaky test rarely breaks for a subtle reason. It breaks because of a small set of well-known anti-patterns — and those are <strong>visible in the diff, before the test ever runs.</strong> A fixed sleep, an unseeded RNG, a wall-clock assertion, a structural selector: each one is detectable statically and deterministically, at the moment the test is written.</p>\n<p>I run that check at the commit boundary — a static pass over the added lines of changed test files, flagging the known anti-patterns. Same diff, same result, no model, no network. It won’t catch a clever race, and it isn’t meant to. The point is narrower and worth a lot: stop the <em>obvious</em> flake from ever reaching CI, where catching it costs a hundred times more and a human’s afternoon.</p>\n<h2 id=\"design-the-test-so-theres-nothing-to-detect\">Design the test so there’s nothing to detect</h2>\n<p>Detection is the backstop. The real fix is to write the test deterministically in the first place:</p>\n<ul>\n<li>Replace fixed waits with <strong>condition-based</strong> waiting — poll until the state you expect, or use the framework’s <code>Eventually</code>/<code>waitFor</code>. Wait on a fact, not a clock.</li>\n<li><strong>Seed</strong> every random source, and inject a deterministic clock and IDs instead of reading <code>now()</code> and <code>Math.random()</code> mid-test.</li>\n<li>Select by <strong>role or test id</strong>, never by DOM position.</li>\n<li>Mock the boundary, not the <strong>logic</strong> — and keep at least one real integration path, or the suite verifies a world that doesn’t exist.</li>\n</ul>\n<h2 id=\"when-does-this-stop-being-the-right-answer\">When does this stop being the right answer?</h2>\n<p>A static gate is precision-first: it catches the obvious anti-patterns, not a race condition buried in your own code. Keep its rules conservative — a noisy gate gets ignored, and an ignored gate is worse than none. It will miss the clever flake; don’t let it guess.</p>\n<p>And none of this substitutes for the harder read: a test that’s flaky because the <em>system under test</em> is non-deterministic isn’t telling you about the test. It’s telling you about the system.</p>\n<hr/>\n<p>A green CI you have to re-run to trust isn’t green. The cheapest place to kill a flaky test is the line where it’s written — before it ever costs you a build.</p>\n<hr/>\n<p><em>See also:</em> the commit-boundary check described here is what I shipped in <a href=\"https://commitbrief.com\">CommitBrief</a> — its <a href=\"https://github.com/CommitBrief/commitbrief\">flaky-test detector</a> statically catches fixed sleeps and unseeded randomness in changed test files, deterministically, before the model.</p>",
      "content_text": "A pull request crossed my review: half the diff was tests, and a model had written them. They passed locally. In CI they failed once, passed on a re-run, then failed again two days later under load. Nobody had touched the code.\n\nThat test wasn't wrong. It was **flaky** — and flaky is its own kind of broken.\n\n## AI didn't invent flakiness — it mass-produced it\n\nWriting a test used to cost something, so you wrote fewer and thought harder about each. A model writes them in seconds, from the code's current shape alone. It can't see the concurrency, the resource limits, or the real timing of an async call — so it reaches for the patterns that *look* right and are quietly non-deterministic:\n\n- A fixed sleep to \"wait\" for async work: `time.Sleep(2 * time.Second)`, `await page.waitForTimeout(2000)`, `cy.wait(2000)`.\n- An unseeded random source whose value differs every run: `Math.random()`, `rand.Intn(100)`, a `faker` with no fixed seed.\n- A brittle selector pinned to DOM structure — `nth-child`, an auto-generated class name — that breaks the moment the markup shifts.\n- A mock for everything, so the test verifies an interaction, not a result — the same empty green I wrote about in chasing coverage with tests that do nothing.\n\nEach looks reasonable in isolation. At scale they're a flakiness factory.\n\n## The suite gets *less* reliable as it grows\n\nA single test with a 0.5% chance of a spurious failure is invisible. Put 800 of those in a suite and the chance that *some* test fails on a clean run is 1 − 0.995^800 ≈ 98%. The failures aren't independent of each other either: CI runs on shared, throttled hardware where the async test that finishes in 50 ms on your laptop times out under load.\n\nAs the model keeps adding tests, that count climbs and the suite's odds of a false red march toward certainty. The pass you care about drowns in noise you don't.\n\n## Retrying is a treadmill, not a cure\n\nThe reflex is to absorb the noise: auto-retry the job, mark the test flaky, quarantine it, open a ticket. Every one of these is **reactive** — it acts after the test has already broken CI, usually more than once.\n\n- Retries burn real CI minutes and stretch the feedback loop; you pay, twice, to learn nothing new.\n- Quarantine grows a silent pile of disabled tests. That pile is debt, and it compounds — each skipped test is a check nobody runs anymore.\n- The ticket ages into backlog noise and quietly stops mattering.\n\nNone of it makes the test deterministic. It hides the symptom and bills you for the privilege.\n\n## Catch the anti-pattern, not the failure\n\nA flaky test rarely breaks for a subtle reason. It breaks because of a small set of well-known anti-patterns — and those are **visible in the diff, before the test ever runs.** A fixed sleep, an unseeded RNG, a wall-clock assertion, a structural selector: each one is detectable statically and deterministically, at the moment the test is written.\n\nI run that check at the commit boundary — a static pass over the added lines of changed test files, flagging the known anti-patterns. Same diff, same result, no model, no network. It won't catch a clever race, and it isn't meant to. The point is narrower and worth a lot: stop the *obvious* flake from ever reaching CI, where catching it costs a hundred times more and a human's afternoon.\n\n## Design the test so there's nothing to detect\n\nDetection is the backstop. The real fix is to write the test deterministically in the first place:\n\n- Replace fixed waits with **condition-based** waiting — poll until the state you expect, or use the framework's `Eventually`/`waitFor`. Wait on a fact, not a clock.\n- **Seed** every random source, and inject a deterministic clock and IDs instead of reading `now()` and `Math.random()` mid-test.\n- Select by **role or test id**, never by DOM position.\n- Mock the boundary, not the **logic** — and keep at least one real integration path, or the suite verifies a world that doesn't exist.\n\n## When does this stop being the right answer?\n\nA static gate is precision-first: it catches the obvious anti-patterns, not a race condition buried in your own code. Keep its rules conservative — a noisy gate gets ignored, and an ignored gate is worse than none. It will miss the clever flake; don't let it guess.\n\nAnd none of this substitutes for the harder read: a test that's flaky because the *system under test* is non-deterministic isn't telling you about the test. It's telling you about the system.\n\n---\n\nA green CI you have to re-run to trust isn't green. The cheapest place to kill a flaky test is the line where it's written — before it ever costs you a build.\n\n---\n\n*See also:* the commit-boundary check described here is what I shipped in [CommitBrief](https://commitbrief.com) — its [flaky-test detector](https://github.com/CommitBrief/commitbrief) statically catches fixed sleeps and unseeded randomness in changed test files, deterministically, before the model.",
      "date_published": "2026-06-17T00:00:00.000Z",
      "tags": [
        "testing",
        "ci",
        "ai",
        "quality",
        "Journal"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/journal/schema-evolution-without-breaking/",
      "url": "https://sade.dev/en/journal/schema-evolution-without-breaking/",
      "title": "Schema Evolution Without Breaking the Contract",
      "summary": "You cannot change a cross-service contract atomically, so the only safe changes are the ones where old and new coexist: loosening is safe, tightening is not. Adding an optional field or dropping a required constraint keeps old data valid; adding a required field, renaming, retyping or closing additionalProperties does not. When a change is breaking, mint a new identity and upgrade consumers before producers.",
      "content_html": "<p>Someone added a required <code>customer_id</code> field to an event and deployed the producer. Half the consumers hadn’t shipped the change yet. For twenty minutes, every message that older consumers had handled fine for a year started failing validation — until the rollout caught up. No code was “wrong.” A contract was changed out from under the people bound by it.</p>\n<p>A message’s <code>data</code> shape is an API. The fact that it’s JSON on a queue instead of a response body doesn’t make it less of a contract — it makes it a <em>harder</em> one, because you can’t change both sides at once.</p>\n<h2 id=\"you-cant-change-a-distributed-contract-atomically\">You can’t change a distributed contract atomically</h2>\n<p>An HTTP API has one server you control. A published message has <strong>many consumers, deployed independently, on their own schedules</strong> — and with at-least-once delivery, in-flight messages produced under the old shape are still arriving while you roll out the new one. There is no moment where “everyone is on the new schema.” So the only safe changes are the ones where <strong>old and new can coexist.</strong></p>\n<p>That single constraint gives you the whole rule set.</p>\n<h2 id=\"whats-safe-and-what-isnt\">What’s safe, and what isn’t?</h2>\n<p>A change is backward-compatible if data valid under the old schema is still valid under the new one — so a consumer that upgraded early still accepts messages a not-yet-upgraded producer emits.</p>\n<ul>\n<li><strong>Add an optional field</strong> — safe. Old data simply lacks it.</li>\n<li><strong>Drop a <code>required</code> constraint</strong> (make a field optional) — safe. Old data still satisfies the looser rule.</li>\n<li><strong>Widen an <code>enum</code>, relax a <code>minimum</code></strong> — safe.</li>\n</ul>\n<p>And the ones that bite:</p>\n<ul>\n<li><strong>Add a required field</strong>, or <strong>make an optional field required</strong> — breaking. Old data omits it.</li>\n<li><strong>Remove, rename, or retype a field</strong> — breaking. A rename is just a remove plus an add.</li>\n<li><strong>Tighten the rules</strong> — drop an <code>enum</code> value, raise a <code>minimum</code>, close <code>additionalProperties</code> — breaking.</li>\n</ul>\n<p>The asymmetry is the point: <strong>loosening is safe, tightening is not.</strong> A consumer can tolerate data that’s more permissive than it expected far more easily than data that’s missing something it now demands.</p>\n<h2 id=\"when-its-breaking-version-the-identity--dont-mutate\">When it’s breaking, version the identity — don’t mutate</h2>\n<p>The instinct on a breaking change is to “just update the schema.” Don’t. Mutating the shape behind an existing identity is exactly what broke the rollout above. Instead, <strong>mint a new identity</strong> — a new message URN (<code>urn:babel:orders:created.v2</code>), a new topic, a new event name — and run both in parallel:</p>\n<ol>\n<li>Producers keep emitting <code>v1</code>; you publish <code>v2</code> alongside it.</li>\n<li>Consumers migrate to <code>v2</code> on their own schedules.</li>\n<li>When <code>v1</code> has no consumers left, retire it.</li>\n</ol>\n<p>The rule underneath every step: <strong>consumers upgrade before producers.</strong> Never emit a version no deployed consumer understands. (This is the same shape as <a href=\"/en/notes/idempotency-duplicate-delivery/\">designing the handler so a duplicate delivery is a no-op</a> — you make the change safe to apply in any order, because you don’t control the order.)</p>\n<h2 id=\"make-the-rule-mechanical\">Make the rule mechanical</h2>\n<p>None of this is judgment you want to re-derive in a code review at 5 p.m. on a Friday. The compatibility rules above are deterministic — given the old and new schema, a tool can tell you “additive, ship it” or “breaking, mint a new version” with no opinion involved. So I gate it: a check at the boundary that diffs the two schemas and fails the build on a breaking change, before it reaches anyone downstream. Catching it there costs a comment on a PR; catching it in production costs a rollout window like the one above.</p>\n<p>This is the boring-infrastructure end of the same thesis as <a href=\"/en/journal/ai-didnt-remove-the-bottleneck/\">“the bottleneck moved downstream”</a>: the schema is cheap to change and expensive to change <em>wrongly</em>, so you spend a little tooling to keep the second cost off the table.</p>\n<h2 id=\"when-does-this-stop-mattering\">When does this stop mattering?</h2>\n<p>If a message has exactly one producer and one consumer that deploy together — a single service’s internal queue — the contract isn’t really distributed, and you can change both sides at once. Then the ceremony is overhead. The rules earn their keep the moment a <em>second</em>, independently deployed consumer exists. Most events that outlive their first month reach that point.</p>\n<hr/>\n<p>A schema isn’t a struct you own; it’s a promise other services planned around. Evolve it the way you’d evolve any promise you can’t unmake — additively, or under a new name. Never by quietly redefining the old one.</p>\n<hr/>\n<p><em>See also:</em> the <a href=\"https://babelqueue.com/docs/spec/1.x/schema-validation\">BabelQueue schema-validation spec</a> writes these compatibility rules down, and the <a href=\"https://github.com/BabelQueue/babelqueue-registry\">babelqueue-registry</a> is where per-URN schemas live — its <code>bqschema</code> tool (and packaged Action) is the boundary check that fails the build on a breaking change.</p>",
      "content_text": "Someone added a required `customer_id` field to an event and deployed the producer. Half the consumers hadn't shipped the change yet. For twenty minutes, every message that older consumers had handled fine for a year started failing validation — until the rollout caught up. No code was \"wrong.\" A contract was changed out from under the people bound by it.\n\nA message's `data` shape is an API. The fact that it's JSON on a queue instead of a response body doesn't make it less of a contract — it makes it a *harder* one, because you can't change both sides at once.\n\n## You can't change a distributed contract atomically\n\nAn HTTP API has one server you control. A published message has **many consumers, deployed independently, on their own schedules** — and with at-least-once delivery, in-flight messages produced under the old shape are still arriving while you roll out the new one. There is no moment where \"everyone is on the new schema.\" So the only safe changes are the ones where **old and new can coexist.**\n\nThat single constraint gives you the whole rule set.\n\n## What's safe, and what isn't?\n\nA change is backward-compatible if data valid under the old schema is still valid under the new one — so a consumer that upgraded early still accepts messages a not-yet-upgraded producer emits.\n\n- **Add an optional field** — safe. Old data simply lacks it.\n- **Drop a `required` constraint** (make a field optional) — safe. Old data still satisfies the looser rule.\n- **Widen an `enum`, relax a `minimum`** — safe.\n\nAnd the ones that bite:\n\n- **Add a required field**, or **make an optional field required** — breaking. Old data omits it.\n- **Remove, rename, or retype a field** — breaking. A rename is just a remove plus an add.\n- **Tighten the rules** — drop an `enum` value, raise a `minimum`, close `additionalProperties` — breaking.\n\nThe asymmetry is the point: **loosening is safe, tightening is not.** A consumer can tolerate data that's more permissive than it expected far more easily than data that's missing something it now demands.\n\n## When it's breaking, version the identity — don't mutate\n\nThe instinct on a breaking change is to \"just update the schema.\" Don't. Mutating the shape behind an existing identity is exactly what broke the rollout above. Instead, **mint a new identity** — a new message URN (`urn:babel:orders:created.v2`), a new topic, a new event name — and run both in parallel:\n\n1. Producers keep emitting `v1`; you publish `v2` alongside it.\n2. Consumers migrate to `v2` on their own schedules.\n3. When `v1` has no consumers left, retire it.\n\nThe rule underneath every step: **consumers upgrade before producers.** Never emit a version no deployed consumer understands. (This is the same shape as [designing the handler so a duplicate delivery is a no-op](/en/notes/idempotency-duplicate-delivery) — you make the change safe to apply in any order, because you don't control the order.)\n\n## Make the rule mechanical\n\nNone of this is judgment you want to re-derive in a code review at 5 p.m. on a Friday. The compatibility rules above are deterministic — given the old and new schema, a tool can tell you \"additive, ship it\" or \"breaking, mint a new version\" with no opinion involved. So I gate it: a check at the boundary that diffs the two schemas and fails the build on a breaking change, before it reaches anyone downstream. Catching it there costs a comment on a PR; catching it in production costs a rollout window like the one above.\n\nThis is the boring-infrastructure end of the same thesis as [\"the bottleneck moved downstream\"](/en/journal/ai-didnt-remove-the-bottleneck): the schema is cheap to change and expensive to change *wrongly*, so you spend a little tooling to keep the second cost off the table.\n\n## When does this stop mattering?\n\nIf a message has exactly one producer and one consumer that deploy together — a single service's internal queue — the contract isn't really distributed, and you can change both sides at once. Then the ceremony is overhead. The rules earn their keep the moment a *second*, independently deployed consumer exists. Most events that outlive their first month reach that point.\n\n---\n\nA schema isn't a struct you own; it's a promise other services planned around. Evolve it the way you'd evolve any promise you can't unmake — additively, or under a new name. Never by quietly redefining the old one.\n\n---\n\n*See also:* the [BabelQueue schema-validation spec](https://babelqueue.com/docs/spec/1.x/schema-validation) writes these compatibility rules down, and the [babelqueue-registry](https://github.com/BabelQueue/babelqueue-registry) is where per-URN schemas live — its `bqschema` tool (and packaged Action) is the boundary check that fails the build on a breaking change.",
      "date_published": "2026-06-17T00:00:00.000Z",
      "tags": [
        "architecture",
        "schema",
        "queue",
        "opinion",
        "Journal"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    },
    {
      "id": "https://sade.dev/en/journal/can-you-ship-vibe-coded-to-production/",
      "url": "https://sade.dev/en/journal/can-you-ship-vibe-coded-to-production/",
      "title": "Can You Ship Vibe-Coded Software to Production?",
      "summary": "Vibe coding lowered the cost of the first version, not the cost of being wrong about the data model, the security boundary and the failure mode. Those costs are deferred, not erased, and deferred cost charges interest — the bill arrives in production rather than at build time. What decides output quality is not typing speed but the quality of the question asked and the review performed.",
      "content_html": "<p>“It worked locally” has a new relative: “it worked in the demo.” A product described a week ago and stood up in minutes, with its first users already on board — then the first real load arrives and it slows to a crawl, the first curious user sees someone else’s invoice, the first schema change blows up in a migration. The person who built it is surprised: the code was working.</p>\n<p>It was. But what vibe coding lowered was the cost of the <strong>first version</strong>, not the cost of being wrong. That cost is deferred, not erased — and deferred cost charges interest.</p>\n<h2 id=\"what-vibe-coding-genuinely-makes-cheaper\">What vibe coding genuinely makes cheaper</h2>\n<p>Let’s not strawman it. Shipping a first version has never been cheaper: a CRUD screen, a REST endpoint, a form validation — describe it, watch it work. For a prototype, a throwaway, an internal tool, a learning project, it’s a brilliant accelerator.</p>\n<p>There’s exactly one thing it doesn’t make cheaper: the price of being <strong>wrong</strong> about the data model, the security boundary, and the failure mode. If those three are wrong in production, the bill comes not at build time but much later — usually at 2 a.m.</p>\n<h2 id=\"the-bill-shows-up-in-production-not-at-build-time\">The bill shows up in production, not at build time</h2>\n<p>Four patterns I see again and again in systems shipped with vibe coding. None of them are the “code won’t compile” kind; they all live in the <strong>gap between intent and reality</strong>.</p>\n<p><strong>1. A data model that can’t migrate.</strong> A <code>NOT NULL</code> column added to a populated table, with no <code>DEFAULT</code>. Fine on an empty table, a failure on the production one. Deeper still: a schema with the wrong relationships makes a trivial feature impossible six months later. Fixing the data model afterward isn’t a refactor — it’s a <span data-scheduled=\"/en/journal/big-data-syndrome/\">data-migration risk</span>, done at the most expensive moment, with real data.</p>\n<p><strong>2. Security that never accounted for adversarial input.</strong> Object IDs that pass the identity check but not the ownership check (IDOR), user input reaching a query, secrets hardcoded into the source. The “standard” solution the agent produced doesn’t assume the input could be hostile — because the question it was asked was “make this work,” not “make this withstand an attack.”</p>\n<p><strong>3. An access pattern that ignores scale.</strong> A query inside a loop (N+1), <code>SELECT *</code>, an unpaginated list, a synchronous call where it should be async. Invisible on a ten-row table; it wrecks your p95 at a hundred thousand.</p>\n<p><strong>4. A system you can’t observe or roll back.</strong> No logs, no metrics, no rollback plan. When it breaks, you can’t see <em>why</em> — which is exactly what you’ll need most in a vibe-coded system.</p>\n<p>The common denominator: the tool is most dangerous where it looks safest. It writes common patterns solidly; it produces a “standard” solution without seeing the problem’s unique dimension. <strong>A standard solution is the answer to a problem you didn’t ask.</strong></p>\n<h2 id=\"what-sets-output-quality-not-typing-speed-but-review\">What sets output quality: not typing speed, but review</h2>\n<p>Picture the same agent in three different hands. The difference isn’t who has it type faster; it’s <strong>the quality of the question asked and the review performed.</strong></p>\n<ul>\n<li><strong>Little to no knowledge.</strong> Can’t frame the question, can’t read the output critically. The “standard solution” ships to production as-is, together with the four patterns above. Can’t yet feel the difference between “it works” and “it works correctly.”</li>\n<li><strong>Low-to-mid knowledge.</strong> Frames some questions right, catches part of the output. The ceiling is at the system’s boundaries: where it breaks under load, whether this abstraction is right, what change the data model tolerates. Result: good pieces, a fragile whole.</li>\n<li><strong>Senior + systems architecture.</strong> The agent is a <strong>multiplier</strong>, not a substitute. Have it plan → approve → write step by step → review like a junior’s code → don’t merge a line you can’t defend. The concrete form of that discipline is its own post: <a href=\"/en/journal/ai-assisted-engineering-workflow/\">My AI-Assisted Engineering Workflow</a>. In these hands, speed rises and quality holds — because the reasoning stays in a human.</li>\n</ul>\n<p>This is a budget statement, not an ideology: a senior is the <strong>cheapest insurance</strong> against deferred cost. Building the right data model up front is an afternoon; fixing the wrong one on a live system is a quarter. “We’ll fix it once we make money” is absurd for this reason — the product that makes money is also the one that ties your hands, the one whose migration is riskiest. A senior is cheaper before, not after.</p>\n<h2 id=\"when-is-vibe-coding-the-right-answer\">When is vibe coding the right answer?</h2>\n<p>Like any recommendation, this one has an expiry — and I won’t make a recommendation without stating its limit.</p>\n<p>Vibe coding is the <strong>right</strong> tool for a prototype, a throwaway demo, a hobby, an internal tool with no real data or users, learning. There, the cost of being wrong is low, so deferring it is rational.</p>\n<p>The equation flips the moment one of three things comes to the table: <strong>real users, real data, real money</strong> (or a real uptime commitment). Past that threshold, deferred cost starts charging its interest, and you need someone holding the wheel.</p>\n<hr/>\n<p>Producing with AI agents is possible, and genuinely fast. But the agent changed who <em>writes</em> the code, not who’s <em>responsible</em> for it. <strong>Vibe coding made the first version cheaper, not being wrong</strong> — and that cost depends on whose hands shipped it to production.</p>",
      "content_text": "\"It worked locally\" has a new relative: \"it worked in the demo.\" A product described a week ago and stood up in minutes, with its first users already on board — then the first real load arrives and it slows to a crawl, the first curious user sees someone else's invoice, the first schema change blows up in a migration. The person who built it is surprised: the code was working.\n\nIt was. But what vibe coding lowered was the cost of the **first version**, not the cost of being wrong. That cost is deferred, not erased — and deferred cost charges interest.\n\n## What vibe coding genuinely makes cheaper\n\nLet's not strawman it. Shipping a first version has never been cheaper: a CRUD screen, a REST endpoint, a form validation — describe it, watch it work. For a prototype, a throwaway, an internal tool, a learning project, it's a brilliant accelerator.\n\nThere's exactly one thing it doesn't make cheaper: the price of being **wrong** about the data model, the security boundary, and the failure mode. If those three are wrong in production, the bill comes not at build time but much later — usually at 2 a.m.\n\n## The bill shows up in production, not at build time\n\nFour patterns I see again and again in systems shipped with vibe coding. None of them are the \"code won't compile\" kind; they all live in the **gap between intent and reality**.\n\n**1. A data model that can't migrate.** A `NOT NULL` column added to a populated table, with no `DEFAULT`. Fine on an empty table, a failure on the production one. Deeper still: a schema with the wrong relationships makes a trivial feature impossible six months later. Fixing the data model afterward isn't a refactor — it's a data-migration risk, done at the most expensive moment, with real data.\n\n**2. Security that never accounted for adversarial input.** Object IDs that pass the identity check but not the ownership check (IDOR), user input reaching a query, secrets hardcoded into the source. The \"standard\" solution the agent produced doesn't assume the input could be hostile — because the question it was asked was \"make this work,\" not \"make this withstand an attack.\"\n\n**3. An access pattern that ignores scale.** A query inside a loop (N+1), `SELECT *`, an unpaginated list, a synchronous call where it should be async. Invisible on a ten-row table; it wrecks your p95 at a hundred thousand.\n\n**4. A system you can't observe or roll back.** No logs, no metrics, no rollback plan. When it breaks, you can't see *why* — which is exactly what you'll need most in a vibe-coded system.\n\nThe common denominator: the tool is most dangerous where it looks safest. It writes common patterns solidly; it produces a \"standard\" solution without seeing the problem's unique dimension. **A standard solution is the answer to a problem you didn't ask.**\n\n## What sets output quality: not typing speed, but review\n\nPicture the same agent in three different hands. The difference isn't who has it type faster; it's **the quality of the question asked and the review performed.**\n\n- **Little to no knowledge.** Can't frame the question, can't read the output critically. The \"standard solution\" ships to production as-is, together with the four patterns above. Can't yet feel the difference between \"it works\" and \"it works correctly.\"\n- **Low-to-mid knowledge.** Frames some questions right, catches part of the output. The ceiling is at the system's boundaries: where it breaks under load, whether this abstraction is right, what change the data model tolerates. Result: good pieces, a fragile whole.\n- **Senior + systems architecture.** The agent is a **multiplier**, not a substitute. Have it plan → approve → write step by step → review like a junior's code → don't merge a line you can't defend. The concrete form of that discipline is its own post: [My AI-Assisted Engineering Workflow](/en/journal/ai-assisted-engineering-workflow). In these hands, speed rises and quality holds — because the reasoning stays in a human.\n\nThis is a budget statement, not an ideology: a senior is the **cheapest insurance** against deferred cost. Building the right data model up front is an afternoon; fixing the wrong one on a live system is a quarter. \"We'll fix it once we make money\" is absurd for this reason — the product that makes money is also the one that ties your hands, the one whose migration is riskiest. A senior is cheaper before, not after.\n\n## When is vibe coding the right answer?\n\nLike any recommendation, this one has an expiry — and I won't make a recommendation without stating its limit.\n\nVibe coding is the **right** tool for a prototype, a throwaway demo, a hobby, an internal tool with no real data or users, learning. There, the cost of being wrong is low, so deferring it is rational.\n\nThe equation flips the moment one of three things comes to the table: **real users, real data, real money** (or a real uptime commitment). Past that threshold, deferred cost starts charging its interest, and you need someone holding the wheel.\n\n---\n\nProducing with AI agents is possible, and genuinely fast. But the agent changed who *writes* the code, not who's *responsible* for it. **Vibe coding made the first version cheaper, not being wrong** — and that cost depends on whose hands shipped it to production.",
      "date_published": "2026-06-13T00:00:00.000Z",
      "tags": [
        "ai-workflow",
        "vibe-coding",
        "production",
        "architecture",
        "opinion",
        "Journal"
      ],
      "authors": [
        {
          "name": "Muhammet Şafak",
          "url": "https://www.muhammetsafak.com.tr"
        }
      ]
    }
  ]
}