<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Vinícius Campos – Vinícius Campos</title><link>https://viniciusscampos.dev/</link><description>Recent content on Vinícius Campos</description><generator>Hugo -- gohugo.io</generator><language>en-US</language><lastBuildDate>Sat, 26 Sep 2026 00:00:00 -0300</lastBuildDate><atom:link href="https://viniciusscampos.dev/index.xml" rel="self" type="application/rss+xml"/><item><title>Property-based testing</title><link>https://viniciusscampos.dev/property-based-testing/</link><pubDate>Sat, 26 Sep 2026 00:00:00 -0300</pubDate><guid>https://viniciusscampos.dev/property-based-testing/</guid><description>
&lt;p&gt;When we think about testing we usually remember the classic test pyramid, with unit tests at the bottom and e2e tests at the top, but there is much more around testing and testing strategies than just that.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://viniciusscampos.dev/images/property-based-testing/test-pyramid.png" alt="The test pyramid: many unit tests at the bottom, fewer integration tests in the middle, and few end-to-end tests at the top" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;There are MANY different kinds of tests like load tests, performance tests, regression tests, and there are also property-based tests, but what are property-based tests?&lt;/p&gt;
&lt;p&gt;Property-based tests are basic tests that, as the name suggests, are based on a given property, but what&amp;rsquo;s a property? A property can be seen as a function f(x) -&amp;gt; boolean, so given an input it outputs true or false. For example, imagine that we&amp;rsquo;re creating a sorting algorithm and we want to validate its implementation with tests. A sorting algorithm has many properties (conditions) that it must meet, but we can list at least two: for ALL consecutive x,y in the sorted list, x &amp;lt;= y, and the other property is that &lt;code&gt;len(sorted(list)) == len(list)&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;How it works&lt;span class="hx:absolute hx:-mt-20" id="how-it-works"&gt;&lt;/span&gt;
&lt;a href="#how-it-works" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Property-based testing has 3 main concepts, a generator, a property and a runner. A property is a function that must ALWAYS return true for any input to the code under test. A generator is the code responsible for generating, given a definition, an arbitrary number of inputs (usually 100, but it&amp;rsquo;s configurable). The runner, as its name suggests, is responsible for validating the generated inputs against the properties defined until one fails - it does more than that, as it then tries to shrink it to the smallest example that breaks the property.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://viniciusscampos.dev/images/property-based-testing/how-it-works.gif" alt="The generator creates lists, the property checks them, and after a failure the runner shrinks the input down to [0, 0]" loading="lazy" /&gt;&lt;/p&gt;
&lt;h2&gt;Why use it&lt;span class="hx:absolute hx:-mt-20" id="why-use-it"&gt;&lt;/span&gt;
&lt;a href="#why-use-it" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The main reason for using it is the way of thinking about tests. Usually we think about arbitrary test inputs for the function, and this has bias. When we start to think about properties we shift our way of thought to try to extract properties inherent to the code that we&amp;rsquo;re trying to test. So we shift from thinking about which tests to write to writing code that generates tests from probability distributions. More inputs get tested, which makes it more likely to find edge cases before they reach the end users.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://viniciusscampos.dev/images/property-based-testing/probability-distribution.gif" alt="Three hand-picked inputs all miss a bug in the negative numbers, while generated inputs spread across the whole range, pile up on edges like 0 and huge values, and hit the bug many times" loading="lazy" /&gt;&lt;/p&gt;
&lt;h2&gt;Finding properties&lt;span class="hx:absolute hx:-mt-20" id="finding-properties"&gt;&lt;/span&gt;
&lt;a href="#finding-properties" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The properties can be as custom as you want them to be, but there are some common patterns already, and we can highlight 4:&lt;/p&gt;
&lt;h3&gt;1. Round-trip&lt;span class="hx:absolute hx:-mt-20" id="1-round-trip"&gt;&lt;/span&gt;
&lt;a href="#1-round-trip" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;&lt;code&gt;g(f(x)) == x&lt;/code&gt; -&amp;gt; common use case is serialization/deserialization&lt;/p&gt;
&lt;p&gt;&lt;img src="https://viniciusscampos.dev/images/property-based-testing/round-trip.gif" alt="A value is encoded, decoded back, and compared with the original" loading="lazy" /&gt;&lt;/p&gt;
&lt;h3&gt;2. Invariant&lt;span class="hx:absolute hx:-mt-20" id="2-invariant"&gt;&lt;/span&gt;
&lt;a href="#2-invariant" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Something that never changes, the &lt;code&gt;len(list) == len(sorted(list))&lt;/code&gt; for example&lt;/p&gt;
&lt;p&gt;&lt;img src="https://viniciusscampos.dev/images/property-based-testing/invariant.gif" alt="Bars being sorted while the length, the sum, and the elements stay the same" loading="lazy" /&gt;&lt;/p&gt;
&lt;h3&gt;3. Idempotency&lt;span class="hx:absolute hx:-mt-20" id="3-idempotency"&gt;&lt;/span&gt;
&lt;a href="#3-idempotency" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;&lt;code&gt;f(f(x)) == f(x)&lt;/code&gt;. This property is essential in distributed systems and when we&amp;rsquo;re handling money operations.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://viniciusscampos.dev/images/property-based-testing/idempotency.gif" alt="The same charge request is retried three times, but the account is only charged once" loading="lazy" /&gt;&lt;/p&gt;
&lt;h3&gt;4. Oracle&lt;span class="hx:absolute hx:-mt-20" id="4-oracle"&gt;&lt;/span&gt;
&lt;a href="#4-oracle" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;&lt;code&gt;f(x) == g(x)&lt;/code&gt;. This is very useful when we are refactoring some code or making changes. The same idea powers migrations in shadow mode.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://viniciusscampos.dev/images/property-based-testing/oracle.gif" alt="The same input goes to the legacy and the new implementation, and the outputs are compared until a negative price makes them diverge" loading="lazy" /&gt;&lt;/p&gt;
&lt;h2&gt;Examples with Hypothesis&lt;span class="hx:absolute hx:-mt-20" id="examples-with-hypothesis"&gt;&lt;/span&gt;
&lt;a href="#examples-with-hypothesis" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;In Python, the most used library for property-based testing is &lt;a href="https://hypothesis.readthedocs.io/"target="_blank" rel="noopener"&gt;Hypothesis&lt;/a&gt;. It plays the generator and the runner roles, so we only need to describe the inputs with strategies and write the property as a regular test. It works with pytest out of the box:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pip install hypothesis pytest&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Copy code"
aria-label="Copy code"
data-copied-label="Copied!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3&gt;Round-trip&lt;span class="hx:absolute hx:-mt-20" id="round-trip"&gt;&lt;/span&gt;
&lt;a href="#round-trip" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;For serialization, we can generate any JSON-like value and check that encoding and decoding gives us the same value back. &lt;code&gt;st.recursive&lt;/code&gt; builds nested lists and dictionaries from the basic types:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;json&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;hypothesis&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;given&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;hypothesis&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;strategies&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;json_values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;none&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;booleans&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;integers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;children&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dictionaries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;children&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@given&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json_values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_json_round_trip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Copy code"
aria-label="Copy code"
data-copied-label="Copied!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;code&gt;json_values&lt;/code&gt; is the generator, the &lt;code&gt;assert&lt;/code&gt; is the property, and &lt;code&gt;@given&lt;/code&gt; hands it to the runner.&lt;/p&gt;
&lt;p&gt;Try adding &lt;code&gt;st.floats()&lt;/code&gt; to the base types: Hypothesis will usually find &lt;code&gt;nan&lt;/code&gt;, and since &lt;code&gt;nan != nan&lt;/code&gt;, the property fails (and &lt;code&gt;json.dumps&lt;/code&gt; writes it as &lt;code&gt;NaN&lt;/code&gt;, which isn&amp;rsquo;t even valid JSON). That&amp;rsquo;s exactly the kind of edge case we wouldn&amp;rsquo;t pick by hand.&lt;/p&gt;
&lt;h3&gt;Invariant&lt;span class="hx:absolute hx:-mt-20" id="invariant"&gt;&lt;/span&gt;
&lt;a href="#invariant" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Now a sorting function that has a bug: it uses a &lt;code&gt;set&lt;/code&gt; under the hood, so it drops duplicated values.&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;my_sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;# bug: drops duplicates&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@given&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;integers&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_sort_keeps_length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;my_sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Copy code"
aria-label="Copy code"
data-copied-label="Copied!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;Running &lt;code&gt;pytest&lt;/code&gt; fails with:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;E assert 1 == 2
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;E + where 1 = len([0])
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;E + where [0] = my_sort([0, 0])
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;E + and 2 = len([0, 0])
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;E Failing test case: test_sort_keeps_length(
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;E xs=[0, 0],
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;E )&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Copy code"
aria-label="Copy code"
data-copied-label="Copied!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;The first list that broke the property was probably something bigger and noisier, but Hypothesis shrank it to &lt;code&gt;[0, 0]&lt;/code&gt;, the smallest input that shows the bug: two equal values.&lt;/p&gt;
&lt;p&gt;The length alone doesn&amp;rsquo;t prove that a function sorts, though. For that, we check that each element is less than or equal to the next one, and that the result has exactly the same elements as the input:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Counter&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@given&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;integers&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_sort_is_ordered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;my_sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nb"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Copy code"
aria-label="Copy code"
data-copied-label="Copied!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;The order check passes, but the &lt;code&gt;Counter&lt;/code&gt; check fails on &lt;code&gt;[0, 0]&lt;/code&gt; too, since the duplicate is gone. Once &lt;code&gt;my_sort&lt;/code&gt; returns &lt;code&gt;sorted(xs)&lt;/code&gt;, both tests pass.&lt;/p&gt;
&lt;h3&gt;Idempotency&lt;span class="hx:absolute hx:-mt-20" id="idempotency"&gt;&lt;/span&gt;
&lt;a href="#idempotency" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;For a charge, retrying with the same &lt;code&gt;order_id&lt;/code&gt; must leave the ledger in the same state as charging once:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@given&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uuids&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;integers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;integers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_charge_is_idempotent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;once&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;ledger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;ledger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;once&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Copy code"
aria-label="Copy code"
data-copied-label="Copied!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3&gt;Oracle&lt;span class="hx:absolute hx:-mt-20" id="oracle"&gt;&lt;/span&gt;
&lt;a href="#oracle" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;When rewriting a function, the old implementation becomes the source of truth for the new one. Here the new version looks simpler, but it forgot that the legacy one never returns a negative price:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;legacy_discount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;new_discount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# forgot the negative case&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@given&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;floats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_value&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allow_nan&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_new_discount_matches_legacy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;new_discount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;legacy_discount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Copy code"
aria-label="Copy code"
data-copied-label="Copied!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;E assert -0.9 == 0
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;E + where -0.9 = new_discount(-1.0)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;E + and 0 = legacy_discount(-1.0)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;E Failing test case: test_new_discount_matches_legacy(
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;E price=-1.0,
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;E )&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Copy code"
aria-label="Copy code"
data-copied-label="Copied!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;We didn&amp;rsquo;t write a single test case with a negative price, and it was found anyway.&lt;/p&gt;
&lt;h3&gt;Settings and replay&lt;span class="hx:absolute hx:-mt-20" id="settings-and-replay"&gt;&lt;/span&gt;
&lt;a href="#settings-and-replay" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;By default, Hypothesis runs 100 examples per test. We can raise it for the properties that matter the most:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;hypothesis&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;given&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@settings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_examples&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@given&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;floats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_value&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allow_nan&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_new_discount_matches_legacy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;new_discount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;legacy_discount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Copy code"
aria-label="Copy code"
data-copied-label="Copied!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;It also saves the failing examples in a local database and replays them first on the next run, so a bug it found once doesn&amp;rsquo;t disappear on the next random run.&lt;/p&gt;
&lt;h2&gt;Wrapping up&lt;span class="hx:absolute hx:-mt-20" id="wrapping-up"&gt;&lt;/span&gt;
&lt;a href="#wrapping-up" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Property-based tests don&amp;rsquo;t replace the tests we already have. They add another way of looking at the same code. Instead of asking &amp;ldquo;which inputs should I test?&amp;rdquo;, we ask &amp;ldquo;what must always be true?&amp;rdquo;, and let the generator explore inputs we wouldn&amp;rsquo;t think of. To start:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Look for round-trips: anything we encode, we should be able to decode.&lt;/li&gt;
&lt;li&gt;Look for invariants: what doesn&amp;rsquo;t change after the function runs.&lt;/li&gt;
&lt;li&gt;Look for idempotency: operations that can be retried, especially with money.&lt;/li&gt;
&lt;li&gt;Use the old code as an oracle when refactoring or migrating.&lt;/li&gt;
&lt;li&gt;Trust the shrinking: the smallest failing example usually points straight to the bug.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A good first step is picking a function that already has a few example-based tests and asking which property those examples were trying to check.&lt;/p&gt;</description></item><item><title>The dual-write problem</title><link>https://viniciusscampos.dev/dual-write-problem/</link><pubDate>Wed, 23 Sep 2026 00:00:00 -0300</pubDate><guid>https://viniciusscampos.dev/dual-write-problem/</guid><description>
&lt;p&gt;When we build a checkout flow, it&amp;rsquo;s common to save the order, charge the customer with a payment provider, and then update the order with the result. It looks simple, but it hides a classic distributed systems problem: the dual-write problem.&lt;/p&gt;
&lt;p&gt;It consists of having more than one system we write to in a given flow. This could be a DB and a third-party provider, but also other systems on the same cloud infrastructure, like a DB and a cache. Since these systems can&amp;rsquo;t share a single transaction, there&amp;rsquo;s no way to guarantee that all writes succeed or none do, so any failure between them can leave us in an inconsistent state.&lt;/p&gt;
&lt;p&gt;Going back to our checkout flow, let&amp;rsquo;s say an API handles it in three steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Insert the order in our DB as &lt;code&gt;PENDING&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Charge the customer with the payment provider.&lt;/li&gt;
&lt;li&gt;Update the order in our DB to &lt;code&gt;PAID&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The API can die between any of these steps, or the charge can time out. Pick a scenario below to see the end state of each system, and keep the toggle on &lt;strong&gt;Naive flow&lt;/strong&gt; for now.&lt;/p&gt;
&lt;figure class="dw" data-dw&gt;
&lt;div class="dw-controls"&gt;
&lt;div class="dw-group dw-scenarios" role="group" aria-label="Scenario"&gt;
&lt;button type="button" data-scenario="ok" aria-pressed="false"&gt;All OK&lt;/button&gt;
&lt;button type="button" data-scenario="after1" aria-pressed="false"&gt;Dies after 1&lt;/button&gt;
&lt;button type="button" data-scenario="after2" aria-pressed="false"&gt;Dies after 2&lt;/button&gt;
&lt;button type="button" data-scenario="timeout" aria-pressed="false"&gt;Timeout on 2&lt;/button&gt;
&lt;/div&gt;
&lt;div class="dw-group dw-mode" role="group" aria-label="Flow"&gt;
&lt;button type="button" data-mode="naive" aria-pressed="true"&gt;Naive flow&lt;/button&gt;
&lt;button type="button" data-mode="heal" aria-pressed="false"&gt;Self-healing flow&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;svg class="dw-diagram" data-svg role="img" aria-labelledby="dw-0-title dw-0-desc"&gt;
&lt;title id="dw-0-title"&gt;Checkout system diagram&lt;/title&gt;
&lt;desc id="dw-0-desc" data-desc&gt;&lt;/desc&gt;
&lt;defs&gt;
&lt;marker id="dw-0-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"&gt;
&lt;path class="dw-arrow" d="M0,0 L10,5 L0,10 z"&gt;&lt;/path&gt;
&lt;/marker&gt;
&lt;marker id="dw-0-arrow-done" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"&gt;
&lt;path class="dw-arrow dw-arrow--done" d="M0,0 L10,5 L0,10 z"&gt;&lt;/path&gt;
&lt;/marker&gt;
&lt;marker id="dw-0-arrow-timeout" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"&gt;
&lt;path class="dw-arrow dw-arrow--timeout" d="M0,0 L10,5 L0,10 z"&gt;&lt;/path&gt;
&lt;/marker&gt;
&lt;/defs&gt;
&lt;rect class="dw-boundary" data-boundary rx="10"&gt;&lt;/rect&gt;
&lt;text class="dw-boundary-label" data-boundary-label&gt;&lt;tspan class="dw-boundary-name"&gt;Checkout system&lt;/tspan&gt;&lt;tspan class="dw-boundary-type" dx="8"&gt;[Software system]&lt;/tspan&gt;&lt;/text&gt;
&lt;g data-edges&gt;&lt;/g&gt;
&lt;g data-nodes&gt;&lt;/g&gt;
&lt;g data-overlay&gt;&lt;/g&gt;
&lt;/svg&gt;
&lt;div class="dw-legend" aria-hidden="true"&gt;
&lt;span&gt;&lt;i class="dw-swatch dw-swatch--container"&gt;&lt;/i&gt;Container&lt;/span&gt;
&lt;span&gt;&lt;i class="dw-swatch dw-swatch--external"&gt;&lt;/i&gt;External system&lt;/span&gt;
&lt;span&gt;&lt;i class="dw-swatch dw-swatch--boundary"&gt;&lt;/i&gt;System boundary&lt;/span&gt;
&lt;/div&gt;
&lt;p class="dw-caption" data-caption aria-live="polite"&gt;Pick a scenario to run the flow.&lt;/p&gt;
&lt;p class="dw-verdict" data-verdict&gt;&lt;/p&gt;
&lt;details class="dw-events" data-events hidden&gt;
&lt;summary&gt;Show all events&lt;/summary&gt;
&lt;ol data-log&gt;&lt;/ol&gt;
&lt;/details&gt;
&lt;/figure&gt;
&lt;style&gt;
.dw {
--dw-ink: #0f172a; --dw-muted: #64748b; --dw-line: #a3adbb; --dw-bg: #f8fafc; --dw-surface: #ffffff;
--dw-accent: var(--hx-color-primary-600, #2563eb);
--dw-container-fill: color-mix(in srgb, var(--dw-accent) 9%, #ffffff);
--dw-external-fill: #eceff3; --dw-external-stroke: #8a94a3;
--dw-ok: #15803d; --dw-bad: #b91c1c; --dw-warn: #b45309;
margin: 1.5rem 0; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 8px; background: var(--dw-bg);
color: var(--dw-ink); font-size: 0.95rem;
}
html.dark .dw {
--dw-ink: #e5e7eb; --dw-muted: #9ca3af; --dw-line: #5b6472; --dw-bg: #161618; --dw-surface: #111111;
--dw-accent: var(--hx-color-primary-400, #60a5fa);
--dw-container-fill: color-mix(in srgb, var(--dw-accent) 13%, #161618);
--dw-external-fill: #25262a; --dw-external-stroke: #7b818c;
--dw-ok: #4ade80; --dw-bad: #f87171; --dw-warn: #fbbf24;
border-color: #2a2b30;
}
.dw-controls { display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: space-between; }
.dw-group { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.dw button {
border: 1px solid #d4d8de; background: var(--dw-surface); color: inherit; font: inherit; font-size: 0.85rem;
border-radius: 6px; padding: 0.3rem 0.75rem; cursor: pointer;
}
html.dark .dw button { border-color: #3a3b40; }
.dw button:hover { border-color: var(--dw-accent); }
.dw button[aria-pressed="true"] { border-color: var(--dw-accent); color: var(--dw-accent); font-weight: 600; }
.dw-mode { gap: 0; }
.dw-mode button:first-child { border-radius: 6px 0 0 6px; }
.dw-mode button:last-child { border-radius: 0 6px 6px 0; margin-left: -1px; }
.dw-mode button[aria-pressed="true"] { position: relative; background: color-mix(in srgb, var(--dw-accent) 10%, var(--dw-surface)); }
@media (max-width: 480px) {
.dw-scenarios { display: grid; grid-template-columns: 1fr 1fr; width: 100%; }
}
.dw-diagram { display: block; width: 100%; height: auto; margin: 0.5rem 0 0.25rem; overflow: visible; }
.dw-diagram text { font-family: inherit; fill: var(--dw-ink); }
.dw-boundary {
fill: none; stroke: var(--dw-muted); stroke-width: 1.25; stroke-dasharray: 6 5;
transition: x 0.45s ease, y 0.45s ease, width 0.45s ease, height 0.45s ease;
}
.dw-boundary-label { transition: transform 0.45s ease; }
.dw-boundary-name { font-size: 13px; font-weight: 600; }
.dw-boundary-type { font-size: 11px; fill: var(--dw-muted) !important; }
.dw-node { transition: transform 0.45s ease, opacity 0.35s ease; }
.dw-node[data-hidden] { opacity: 0; pointer-events: none; }
.dw-shape { stroke-width: 1.5; transition: stroke 0.2s, fill 0.2s; }
.dw-node--container .dw-shape, .dw-node--db .dw-shape { fill: var(--dw-container-fill); stroke: var(--dw-accent); }
.dw-node--db .dw-lip { fill: none; stroke: var(--dw-accent); stroke-width: 1.5; }
.dw-node--external .dw-shape { fill: var(--dw-external-fill); stroke: var(--dw-external-stroke); }
.dw-node[data-crashed] .dw-shape { stroke: var(--dw-bad); stroke-width: 2.5; }
.dw-node-name { font-size: 14px; font-weight: 600; text-anchor: middle; }
.dw-node-type { font-size: 11px; text-anchor: middle; fill: var(--dw-muted) !important; }
.dw-node-state { font-size: 12px; text-anchor: middle; font-variant-numeric: tabular-nums; }
.dw-state-muted { fill: var(--dw-muted) !important; }
.dw-state-ok { fill: var(--dw-ok) !important; font-weight: 600; }
.dw-state-warn { fill: var(--dw-warn) !important; font-weight: 600; }
.dw-edge { transition: opacity 0.3s ease; }
.dw-edge[data-hidden] { opacity: 0; }
.dw-edge-line { fill: none; stroke: var(--dw-line); stroke-width: 1.5; transition: stroke 0.2s; }
.dw-edge--quiet .dw-edge-line { stroke-dasharray: 3 4; }
.dw-edge[data-done] .dw-edge-line { stroke: var(--dw-ink); }
.dw-edge[data-timeout] .dw-edge-line { stroke: var(--dw-warn); stroke-dasharray: 5 4; }
.dw-edge-label {
font-size: 12px; fill: var(--dw-muted) !important;
paint-order: stroke; stroke: var(--dw-bg); stroke-width: 5px; stroke-linejoin: round;
}
.dw-edge[data-done] .dw-edge-label { fill: var(--dw-ink) !important; }
.dw-arrow { fill: var(--dw-line); }
.dw-arrow--done { fill: var(--dw-ink); }
.dw-arrow--timeout { fill: var(--dw-warn); }
.dw-dot { fill: var(--dw-accent); }
.dw-dot--ack { fill: var(--dw-ok); }
.dw-badge rect { fill: var(--dw-bad); }
.dw-badge text { fill: #ffffff !important; font-size: 11px; font-weight: 600; text-anchor: middle; }
.dw-badge--timeout circle { fill: var(--dw-warn); }
.dw-badge--timeout text { fill: #ffffff !important; font-size: 12px; }
html.dark .dw-badge text { fill: #111111 !important; }
.dw-legend { display: flex; flex-wrap: wrap; gap: 0.35rem 1rem; font-size: 0.8rem; color: var(--dw-muted); }
.dw-legend span { display: inline-flex; align-items: center; gap: 0.4rem; }
.dw-swatch { display: inline-block; width: 14px; height: 10px; border-radius: 2px; border: 1.5px solid; }
.dw-swatch--container { background: var(--dw-container-fill); border-color: var(--dw-accent); }
.dw-swatch--external { background: var(--dw-external-fill); border-color: var(--dw-external-stroke); }
.dw-swatch--boundary { border: 1.5px dashed var(--dw-muted); }
.dw-caption { margin: 0.9rem 0 0; min-height: 1.5em; }
.dw-verdict { margin: 0.35rem 0 0; font-weight: 600; min-height: 0; }
.dw-verdict:empty { display: none; }
.dw-verdict[data-kind="ok"] { color: var(--dw-ok); }
.dw-verdict[data-kind="bad"] { color: var(--dw-bad); }
.dw-verdict[data-kind="warn"] { color: var(--dw-warn); }
.dw-events { margin-top: 0.75rem; font-size: 0.85rem; color: var(--dw-muted); }
.dw-events summary { cursor: pointer; width: fit-content; }
.dw-events ol { margin: 0.5rem 0 0; padding-left: 1.4rem; }
.dw-events li { margin: 0.15rem 0; }
.dw-events li.dw-attempt { list-style: none; margin: 0.5rem 0 0.2rem -1.4rem; font-weight: 600; color: var(--dw-ink); }
&lt;/style&gt;
&lt;script&gt;
(() =&gt; {
const SVG = "http://www.w3.org/2000/svg";
const NODES = {
api: { kind: "container", name: "API", type: "[Container: HTTP API]",
tip: { naive: "Receives the checkout and runs all three steps itself.", heal: "Receives the checkout with its order_id, publishes a message, and returns." } },
queue: { kind: "container", name: "Queue", type: "[Container: queue]", tip: "Keeps the message until the worker ACKs it, and redelivers it otherwise." },
dlq: { kind: "container", name: "DLQ", type: "[Container: queue]", tip: "Receives messages that failed N times. Alert on it and redrive them." },
worker: { kind: "container", name: "Worker", type: "[Container: consumer]", tip: "Runs the three steps and ACKs only after the flow ends." },
db: { kind: "db", name: "Orders DB", type: "[Container: database]", tip: "Stores the order and its status." },
webhook: { kind: "container", name: "Webhook handler", type: "[Container: HTTP API]", tip: "Receives payment results and moves the order forward if it's still PENDING." },
psp: { kind: "external", name: "Payment provider", type: "[External system]", tip: "Charges the customer, and deduplicates requests by idempotency key." },
};
const EDGES = {
naive: [
{ id: "step1", from: ["api", "r", -11], to: ["db", "l", -11], label: ["1. insert order", "above"], short: "1" },
{ id: "step3", from: ["api", "r", 11], to: ["db", "l", 11], label: ["3. mark PAID", "below"], short: "3" },
{ id: "step2", from: ["api", "b", 0], to: ["psp", "t", 0], label: ["2. charge", "right"] },
],
heal: [
{ id: "publish", from: ["api", "r", 0], to: ["queue", "l", 0], label: ["publish", "above"], narrow: { from: ["api", "b", 0], to: ["queue", "t", 0], label: ["publish", "right"] } },
{ id: "deliver", from: ["queue", "r", 0], to: ["worker", "l", 0], label: ["deliver", "above"], narrow: { from: ["queue", "b", 0], to: ["worker", "t", 0], label: ["deliver", "right"] } },
{ id: "dead", quiet: true, from: ["queue", "b", 0], to: ["dlq", "t", 0], label: ["after N retries", "right"], narrow: { from: ["queue", "r", 0], to: ["dlq", "l", 0], label: ["retries", "above"] } },
{ id: "step1", from: ["worker", "r", -11], to: ["db", "l", -11], label: ["1. insert", "above"], short: "1" },
{ id: "step3", from: ["worker", "r", 11], to: ["db", "l", 11], label: ["3. mark PAID", "below"], short: "3" },
{ id: "step2", from: ["worker", "b", 0], to: ["psp", "t", 0], label: ["2. charge", "right"] },
{ id: "hook", from: ["psp", "r", 0], to: ["webhook", "b", 0], label: ["webhook", "above", 0.3] },
{ id: "hookdb", from: ["webhook", "t", 0], to: ["db", "b", 0], label: ["PAID if PENDING", "left"] },
],
};
const LAYOUTS = {
wide: {
width: 760, w: 130, h: { container: 58, db: 88, external: 76 }, withType: true,
naive: { height: 296, boundary: [180, 26, 420, 154],
nodes: { api: [210, 70], db: [450, 55], psp: [210, 208] } },
heal: { height: 408, boundary: [6, 26, 748, 264],
nodes: { api: [16, 70], queue: [206, 70], worker: [396, 70], db: [610, 55], dlq: [206, 200], webhook: [610, 208], psp: [396, 322] } },
},
narrow: {
width: 360, w: 140, h: { container: 50, db: 72, external: 62 }, withType: false,
naive: { height: 272, boundary: [6, 22, 348, 120],
nodes: { api: [16, 58], db: [204, 47], psp: [16, 200] } },
heal: { height: 578, boundary: [6, 22, 348, 450],
nodes: { api: [16, 58], queue: [16, 158], dlq: [204, 158], worker: [16, 270], db: [204, 259], webhook: [204, 395], psp: [16, 505] } },
},
};
const DESC = {
naive: "Naive flow: the API inserts the order in the Orders DB, charges the external payment provider, and marks the order as PAID.",
heal: "Self-healing flow: the API publishes a message to a queue. A worker inserts the order, charges the payment provider with the order_id as idempotency key, and marks the order as PAID, then ACKs. Failed messages go to a DLQ. The provider also sends a webhook to a handler that marks the order if it's still PENDING.",
};
const S = {
naive: {
ok: [
{ go: "step1", say: "Step 1: the API inserts order 123 as PENDING.", db: "PENDING" },
{ go: "step2", say: "Step 2: the API charges the customer.", psp: "charged" },
{ go: "step3", say: "Step 3: the API marks the order as PAID.", db: "PAID" },
{ verdict: ["ok", "Consistent: order PAID, customer charged once."] },
],
after1: [
{ go: "step1", say: "Step 1: the API inserts order 123 as PENDING.", db: "PENDING" },
{ crash: "api", say: "The API crashes before charging." },
{ verdict: ["warn", "Order stuck in PENDING. Nobody was charged, but from our DB we can't know that."] },
],
after2: [
{ go: "step1", say: "Step 1: the API inserts order 123 as PENDING.", db: "PENDING" },
{ go: "step2", say: "Step 2: the API charges the customer.", psp: "charged" },
{ crash: "api", say: "The API crashes before updating the order." },
{ verdict: ["bad", "Customer charged, order stuck in PENDING. Our DB looks exactly like \"Dies after 1\"."] },
],
timeout: [
{ go: "step1", say: "Step 1: the API inserts order 123 as PENDING.", db: "PENDING" },
{ timeout: "step2", say: "Step 2: the charge request times out. The provider charged the customer, but we never saw the response.", psp: "charged" },
{ verdict: ["bad", "Order in PENDING, and from our side we can't tell whether the customer was charged."] },
],
},
heal: {
ok: [
{ go: "publish", say: "The API publishes order 123 to the queue and returns." },
{ go: "deliver", say: "The queue delivers the message to the worker." },
{ go: "step1", say: "Step 1: the worker inserts order 123 as PENDING.", db: "PENDING" },
{ go: "step2", say: "Step 2: the worker charges the customer with Idempotency-Key 123.", psp: "charged" },
{ go: "step3", say: "Step 3: the worker marks the order as PAID, since it's still PENDING.", db: "PAID" },
{ ack: true, say: "The worker ACKs the message." },
{ verdict: ["ok", "Consistent: order PAID, customer charged once."] },
],
after1: [
{ go: "publish", say: "The API publishes order 123 to the queue and returns." },
{ go: "deliver", say: "The queue delivers the message to the worker." },
{ go: "step1", say: "Step 1: the worker inserts order 123 as PENDING.", db: "PENDING" },
{ crash: "worker", say: "The worker crashes before charging, so there's no ACK." },
{ attempt: 2 },
{ go: "deliver", say: "The queue redelivers the message." },
{ go: "step1", say: "Step 1: the order already exists, so the insert is skipped." },
{ go: "step2", say: "Step 2: the worker charges the customer with Idempotency-Key 123.", psp: "charged" },
{ go: "step3", say: "Step 3: the worker marks the order as PAID, since it's still PENDING.", db: "PAID" },
{ ack: true, say: "The worker ACKs the message." },
{ verdict: ["ok", "Healed by redelivery. One charge, order PAID."] },
],
after2: [
{ go: "publish", say: "The API publishes order 123 to the queue and returns." },
{ go: "deliver", say: "The queue delivers the message to the worker." },
{ go: "step1", say: "Step 1: the worker inserts order 123 as PENDING.", db: "PENDING" },
{ go: "step2", say: "Step 2: the worker charges the customer with Idempotency-Key 123.", psp: "charged" },
{ crash: "worker", say: "The worker crashes before updating the order, so there's no ACK." },
{ go: "hook", say: "The provider sends a webhook with metadata.order_id = 123." },
{ go: "hookdb", say: "The webhook handler marks the order as PAID, since it's still PENDING.", db: "PAID" },
{ attempt: 2 },
{ go: "deliver", say: "The queue redelivers the message." },
{ go: "step1", say: "The order is already PAID, so there's nothing left to do." },
{ ack: true, say: "The worker ACKs the message." },
{ verdict: ["ok", "Healed by the webhook. The retry found the order PAID and just ACKed. Still one charge."] },
],
timeout: [
{ go: "publish", say: "The API publishes order 123 to the queue and returns." },
{ go: "deliver", say: "The queue delivers the message to the worker." },
{ go: "step1", say: "Step 1: the worker inserts order 123 as PENDING.", db: "PENDING" },
{ timeout: "step2", say: "Step 2: the charge request times out, so there's no ACK. The provider did charge the customer.", psp: "charged" },
{ attempt: 2 },
{ go: "deliver", say: "The queue redelivers the message." },
{ go: "step1", say: "Step 1: the order already exists, so the insert is skipped." },
{ go: "step2", say: "Step 2: with the same Idempotency-Key 123, the provider replays the first response instead of charging again." },
{ go: "step3", say: "Step 3: the worker marks the order as PAID, since it's still PENDING.", db: "PAID" },
{ ack: true, say: "The worker ACKs the message." },
{ verdict: ["ok", "Healed by redelivery. One charge, and we never needed to know what happened."] },
],
},
};
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const TRAVEL = reduced ? 0 : 650;
const PAUSE = reduced ? 0 : 450;
const sleep = (ms) =&gt; new Promise((r) =&gt; setTimeout(r, ms));
const el = (tag, attrs = {}, parent) =&gt; {
const node = document.createElementNS(SVG, tag);
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
if (parent) parent.appendChild(node);
return node;
};
function init(root) {
const svg = root.querySelector("[data-svg]");
const arrowId = svg.querySelector("marker").id.replace(/-arrow$/, "");
const layers = { edges: svg.querySelector("[data-edges]"), nodes: svg.querySelector("[data-nodes]"), overlay: svg.querySelector("[data-overlay]") };
const boundary = svg.querySelector("[data-boundary]");
const boundaryLabel = svg.querySelector("[data-boundary-label]");
const caption = root.querySelector("[data-caption]");
const verdict = root.querySelector("[data-verdict]");
const events = root.querySelector("[data-events]");
const logList = root.querySelector("[data-log]");
const state = { mode: "naive", scenario: null, layout: null, run: 0, vbHeight: null, db: null, psp: null };
const nodeEls = {};
let edgeEls = {};
const L = () =&gt; LAYOUTS[state.layout];
const nodeBox = (name) =&gt; {
const lay = L();
const pos = lay[state.mode].nodes[name] || lay.heal.nodes[name];
return { x: pos[0], y: pos[1], w: lay.w, h: lay.h[NODES[name].kind] };
};
function buildNodes() {
layers.nodes.innerHTML = "";
const lay = L();
for (const [name, def] of Object.entries(NODES)) {
const w = lay.w, h = lay.h[def.kind];
const g = el("g", { class: `dw-node dw-node--${def.kind}` }, layers.nodes);
g.appendChild(document.createElementNS(SVG, "title"));
if (def.kind === "db") {
const ry = 9;
el("path", { class: "dw-shape", d: `M0,${ry} A${w / 2},${ry} 0 0 1 ${w},${ry} V${h - ry} A${w / 2},${ry} 0 0 1 0,${h - ry} Z` }, g);
el("path", { class: "dw-lip", d: `M0,${ry} A${w / 2},${ry} 0 0 0 ${w},${ry}` }, g);
} else {
el("rect", { class: "dw-shape", width: w, height: h, rx: 6 }, g);
}
const hasState = def.kind !== "container";
const rows = lay.withType
? (def.kind === "db" ? [38, 55, 75] : def.kind === "external" ? [25, 43, 63] : [26, 45])
: (def.kind === "db" ? [36, null, 56] : def.kind === "external" ? [26, null, 47] : [31]);
el("text", { class: "dw-node-name", x: w / 2, y: rows[0] }, g).textContent = def.name;
if (lay.withType) el("text", { class: "dw-node-type", x: w / 2, y: rows[1] }, g).textContent = def.type;
if (hasState) el("text", { class: "dw-node-state", x: w / 2, y: rows[2], "data-state": "" }, g);
nodeEls[name] = g;
}
}
function anchor(name, side, off) {
const b = nodeBox(name);
if (side === "r") return [b.x + b.w, b.y + b.h / 2 + off];
if (side === "l") return [b.x, b.y + b.h / 2 + off];
if (side === "t") return [b.x + b.w / 2 + off, b.y];
return [b.x + b.w / 2 + off, b.y + b.h];
}
function route(from, to) {
const [a, as] = from, [b, bs] = to;
const horiz = (s) =&gt; s === "l" || s === "r";
if (horiz(as) &amp;&amp; horiz(bs)) {
if (a[1] === b[1]) return `M${a} L${b}`;
const mx = (a[0] + b[0]) / 2;
return `M${a} H${mx} V${b[1]} H${b[0]}`;
}
if (!horiz(as) &amp;&amp; !horiz(bs)) {
if (a[0] === b[0]) return `M${a} L${b}`;
const my = (a[1] + b[1]) / 2;
return `M${a} V${my} H${b[0]} V${b[1]}`;
}
return horiz(as) ? `M${a} H${b[0]} V${b[1]}` : `M${a} V${b[1]} H${b[0]}`;
}
function buildEdges() {
layers.edges.innerHTML = "";
edgeEls = {};
const narrow = state.layout === "narrow";
for (const spec of EDGES[state.mode]) {
const e = narrow &amp;&amp; spec.narrow ? { ...spec, ...spec.narrow } : spec;
const a = anchor(...e.from), b = anchor(...e.to);
const g = el("g", { class: `dw-edge${e.quiet ? " dw-edge--quiet" : ""}`, "data-hidden": "" }, layers.edges);
const path = el("path", { class: "dw-edge-line", d: route([a, e.from[1]], [b, e.to[1]]), "marker-end": `url(#${arrowId}-arrow)` }, g);
const [text, place, frac = 0.5] = e.label;
const labelText = narrow &amp;&amp; e.short ? e.short : text;
const len = path.getTotalLength();
const p = path.getPointAtLength(len * frac);
const offs = { above: [0, -7, "middle"], below: [0, 16, "middle"], right: [7, 4, "start"], left: [-7, 4, "end"] }[place];
el("text", { class: "dw-edge-label", x: p.x + offs[0], y: p.y + offs[1], "text-anchor": offs[2] }, g).textContent = labelText;
edgeEls[e.id] = { g, path };
}
requestAnimationFrame(() =&gt; Object.values(edgeEls).forEach(({ g }) =&gt; g.removeAttribute("data-hidden")));
}
function placeNodes() {
const lay = L(), m = lay[state.mode];
for (const [name, g] of Object.entries(nodeEls)) {
const b = nodeBox(name);
g.style.transform = `translate(${b.x}px, ${b.y}px)`;
g.setAttribute("transform", `translate(${b.x} ${b.y})`);
if (m.nodes[name]) g.removeAttribute("data-hidden"); else g.setAttribute("data-hidden", "");
const tip = NODES[name].tip;
g.querySelector("title").textContent = `${NODES[name].name}: ${typeof tip === "string" ? tip : tip[state.mode]}`;
}
const [x, y, w, h] = m.boundary;
for (const [k, v] of Object.entries({ x, y, width: w, height: h })) {
boundary.setAttribute(k, v);
boundary.style[k] = `${v}px`;
}
boundaryLabel.style.transform = `translate(${x + 12}px, ${y + 18}px)`;
boundaryLabel.setAttribute("transform", `translate(${x + 12} ${y + 18})`);
svg.querySelector("[data-desc]").textContent = DESC[state.mode];
tweenHeight(m.height);
}
function tweenHeight(target) {
const width = L().width;
const from = state.vbHeight ?? target;
state.vbHeight = target;
if (reduced || from === target) return svg.setAttribute("viewBox", `0 0 ${width} ${target}`);
const t0 = performance.now();
const step = (t) =&gt; {
const k = Math.min(1, (t - t0) / 450), e = 1 - Math.pow(1 - k, 3);
svg.setAttribute("viewBox", `0 0 ${width} ${from + (target - from) * e}`);
if (k &lt; 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
function renderState() {
const db = nodeEls.db.querySelector("[data-state]");
db.innerHTML = "";
if (!state.db) {
el("tspan", { class: "dw-state-muted" }, db).textContent = "no orders";
} else {
el("tspan", {}, db).textContent = "order 123: ";
el("tspan", { class: state.db === "PAID" ? "dw-state-ok" : "dw-state-warn" }, db).textContent = state.db;
}
const psp = nodeEls.psp.querySelector("[data-state]");
psp.innerHTML = "";
if (state.psp) {
el("tspan", {}, psp).textContent = "ch_1: ";
el("tspan", { class: "dw-state-ok" }, psp).textContent = "succeeded";
} else {
el("tspan", { class: "dw-state-muted" }, psp).textContent = "no charge";
}
}
function clearMarks() {
layers.overlay.innerHTML = "";
Object.values(nodeEls).forEach((g) =&gt; g.removeAttribute("data-crashed"));
Object.values(edgeEls).forEach(({ g, path }) =&gt; {
g.removeAttribute("data-timeout");
path.setAttribute("marker-end", `url(#${arrowId}-arrow${g.hasAttribute("data-done") ? "-done" : ""})`);
});
}
function reset() {
clearMarks();
Object.values(edgeEls).forEach(({ g, path }) =&gt; {
g.removeAttribute("data-done");
path.setAttribute("marker-end", `url(#${arrowId}-arrow)`);
});
state.db = null;
state.psp = null;
renderState();
logList.innerHTML = "";
events.hidden = true;
verdict.textContent = "";
verdict.dataset.kind = "";
}
async function travel(id, run, { reverse = false, cls = "" } = {}) {
const { g, path } = edgeEls[id];
if (TRAVEL) {
const dot = el("circle", { class: `dw-dot ${cls}`, r: 5.5 }, layers.overlay);
const len = path.getTotalLength();
const t0 = performance.now();
await new Promise((resolve) =&gt; {
const step = (t) =&gt; {
if (run !== state.run) { dot.remove(); return resolve(); }
const k = Math.min(1, (t - t0) / TRAVEL), e = k &lt; 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2;
const p = path.getPointAtLength(len * (reverse ? 1 - e : e));
dot.setAttribute("cx", p.x);
dot.setAttribute("cy", p.y);
if (k &lt; 1) requestAnimationFrame(step); else { dot.remove(); resolve(); }
};
requestAnimationFrame(step);
});
}
if (!reverse) {
g.setAttribute("data-done", "");
if (!g.hasAttribute("data-timeout")) path.setAttribute("marker-end", `url(#${arrowId}-arrow-done)`);
}
}
function crashBadge(name) {
const b = nodeBox(name);
nodeEls[name].setAttribute("data-crashed", "");
const g = el("g", { class: "dw-badge", transform: `translate(${b.x - 12} ${b.y - 12})` }, layers.overlay);
el("rect", { width: 60, height: 20, rx: 10 }, g);
el("text", { x: 30, y: 14 }, g).textContent = "crashed";
}
function timeoutMark(id) {
const { g, path } = edgeEls[id];
g.setAttribute("data-timeout", "");
path.setAttribute("marker-end", `url(#${arrowId}-arrow-timeout)`);
const p = path.getPointAtLength(path.getTotalLength() / 2);
const badge = el("g", { class: "dw-badge dw-badge--timeout", transform: `translate(${p.x - 18} ${p.y})` }, layers.overlay);
el("circle", { r: 10 }, badge);
el("text", { y: 4.5 }, badge).textContent = "?";
}
function log(text, cls) {
const li = document.createElement("li");
li.textContent = text;
if (cls) li.className = cls;
logList.appendChild(li);
}
async function play() {
const run = ++state.run;
reset();
if (!state.scenario) {
caption.textContent = "Pick a scenario to run the flow.";
return;
}
events.hidden = false;
for (const ev of S[state.mode][state.scenario]) {
if (run !== state.run) return;
if (ev.attempt) {
clearMarks();
caption.textContent = `Attempt ${ev.attempt}: the message was never ACKed.`;
log(`Attempt ${ev.attempt}`, "dw-attempt");
await sleep(PAUSE);
continue;
}
if (ev.verdict) {
verdict.dataset.kind = ev.verdict[0];
verdict.textContent = ev.verdict[1];
continue;
}
caption.textContent = ev.say;
log(ev.say);
if (ev.go) await travel(ev.go, run);
if (ev.ack) await travel("deliver", run, { reverse: true, cls: "dw-dot--ack" });
if (ev.timeout) { await travel(ev.timeout, run); if (run === state.run) timeoutMark(ev.timeout); }
if (ev.crash) crashBadge(ev.crash);
if (run !== state.run) return;
if (ev.db) state.db = ev.db;
if (ev.psp) state.psp = ev.psp;
renderState();
await sleep(ev.crash || ev.timeout ? PAUSE * 2 : PAUSE);
}
}
function setLayout() {
const layout = root.clientWidth &lt; 560 ? "narrow" : "wide";
if (layout === state.layout) return;
state.layout = layout;
state.vbHeight = null;
buildNodes();
placeNodes();
buildEdges();
play();
}
root.querySelectorAll("[data-scenario]").forEach((btn) =&gt;
btn.addEventListener("click", () =&gt; {
state.scenario = btn.dataset.scenario;
root.querySelectorAll("[data-scenario]").forEach((b) =&gt; b.setAttribute("aria-pressed", b === btn));
play();
})
);
root.querySelectorAll("[data-mode]").forEach((btn) =&gt;
btn.addEventListener("click", async () =&gt; {
if (btn.dataset.mode === state.mode) return;
state.mode = btn.dataset.mode;
root.querySelectorAll("[data-mode]").forEach((b) =&gt; b.setAttribute("aria-pressed", b === btn));
state.run++;
layers.overlay.innerHTML = "";
placeNodes();
buildEdges();
const run = state.run;
if (!reduced) await sleep(450);
if (run === state.run) play();
})
);
new ResizeObserver(setLayout).observe(root);
setLayout();
}
const start = () =&gt; document.querySelectorAll("[data-dw]:not([data-dw-ready])").forEach((node) =&gt; {
node.dataset.dwReady = "";
init(node);
});
document.readyState === "loading" ? document.addEventListener("DOMContentLoaded", start) : start();
})();
&lt;/script&gt;
&lt;h2&gt;What each failure leaves behind&lt;span class="hx:absolute hx:-mt-20" id="what-each-failure-leaves-behind"&gt;&lt;/span&gt;
&lt;a href="#what-each-failure-leaves-behind" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;When all three steps succeed, both systems agree: the order is &lt;code&gt;PAID&lt;/code&gt; and the customer was charged once. Let&amp;rsquo;s start with the worst failure.&lt;/p&gt;
&lt;p&gt;When the process dies after step 2, the customer was charged: the money left their account or was charged to their credit card. The business, on the other hand, has no visibility of that payment, and the order stays &lt;code&gt;PENDING&lt;/code&gt;. It&amp;rsquo;s a double bad experience for the customer: they paid, and nothing happens. Worse, they&amp;rsquo;ll probably try again, and a new order means a new charge.&lt;/p&gt;
&lt;p&gt;Dying after step 1 and before step 2 wouldn&amp;rsquo;t be a problem by itself: the order is &lt;code&gt;PENDING&lt;/code&gt; and nobody was charged. But how could we differentiate it from a timeout when calling the provider? It would be essentially the same. From our database, all three failures look identical, so we have to treat every &lt;code&gt;PENDING&lt;/code&gt; order as possibly charged.&lt;/p&gt;
&lt;h2&gt;Making the flow heal itself&lt;span class="hx:absolute hx:-mt-20" id="making-the-flow-heal-itself"&gt;&lt;/span&gt;
&lt;a href="#making-the-flow-heal-itself" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Since we can&amp;rsquo;t tell whether a &lt;code&gt;PENDING&lt;/code&gt; order was charged, we don&amp;rsquo;t try to guess. We make sure every order reaches a final state, &lt;code&gt;PAID&lt;/code&gt; or &lt;code&gt;FAILED&lt;/code&gt;, by retrying the flow until it gets a definitive answer, and we make those retries safe.&lt;/p&gt;
&lt;p&gt;Instead of running the three steps inside the API request, the client generates the &lt;code&gt;order_id&lt;/code&gt; (a UUID, for example) when the checkout starts and sends it to the API. The API publishes a message with it to a queue and returns, and a worker consumes the message and runs the three steps. Publishing is a single write, so the API itself has no dual-write problem: if the publish fails, nothing happened, and the client can retry. Since a retry carries the same &lt;code&gt;order_id&lt;/code&gt;, it doesn&amp;rsquo;t create a new order. The whole flow looks like this:&lt;/p&gt;
&lt;div role="img" aria-label="Diagram"&gt;
&lt;pre class="mermaid hx:mt-6"&gt;
%%{init: {&amp;#34;sequence&amp;#34;: {&amp;#34;width&amp;#34;: 120, &amp;#34;actorMargin&amp;#34;: 24, &amp;#34;mirrorActors&amp;#34;: false}}}%%
sequenceDiagram
participant API
participant Queue
participant Worker
participant DB as Orders DB
participant Provider as Payment provider
participant Webhook as Webhook handler
API-&amp;gt;&amp;gt;Queue: publish order_id
Queue-&amp;gt;&amp;gt;Worker: deliver
Worker-&amp;gt;&amp;gt;DB: insert PENDING if absent
Worker-&amp;gt;&amp;gt;Provider: charge (key: order_id)
Provider--&amp;gt;&amp;gt;Worker: result
alt succeeded
Worker-&amp;gt;&amp;gt;DB: PAID if PENDING
else declined
Worker-&amp;gt;&amp;gt;DB: FAILED if PENDING
end
Worker-&amp;gt;&amp;gt;Queue: ACK
Provider-)Webhook: payment result
Webhook-&amp;gt;&amp;gt;DB: PAID/FAILED if PENDING
&lt;/pre&gt;
&lt;/div&gt;&lt;p&gt;A few details make this work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The message is only ACKed after the flow ends.&lt;/strong&gt; If the worker crashes, it can&amp;rsquo;t ACK, so the queue redelivers the message and a worker retries the whole flow.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Every step must be safe to run again.&lt;/strong&gt; On redelivery the worker starts from step 1: the insert is skipped if the order already exists, and if the order is already &lt;code&gt;PAID&lt;/code&gt; or &lt;code&gt;FAILED&lt;/code&gt;, the worker just ACKs. The charge uses the &lt;code&gt;order_id&lt;/code&gt; as the idempotency key, so if the provider already saw that key, it returns the result of the first request instead of charging the customer again.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Definitive answers end the flow.&lt;/strong&gt; If the charge succeeds, the worker marks the order as &lt;code&gt;PAID&lt;/code&gt;; if it&amp;rsquo;s declined, as &lt;code&gt;FAILED&lt;/code&gt;. Only unknown outcomes, like a crash, a timeout, or a 5xx, are retried.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The webhook is a safety net.&lt;/strong&gt; It arrives at any time after the charge and covers the window where the customer was charged but the order is still &lt;code&gt;PENDING&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The &lt;code&gt;order_id&lt;/code&gt; also goes in the charge metadata.&lt;/strong&gt; The webhook only carries the idempotency key when our request triggered the event directly, while the metadata travels with the payment itself, so the webhook handler can always find the order.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Updates are conditional.&lt;/strong&gt; The worker and the webhook can race, so both only move the order forward if it&amp;rsquo;s still &lt;code&gt;PENDING&lt;/code&gt;. Whoever arrives second does nothing.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now switch the widget above to &lt;strong&gt;Self-healing flow&lt;/strong&gt; and replay the failure scenarios to see each one heal.&lt;/p&gt;
&lt;p&gt;Once retries are exhausted, the message goes to a dead-letter queue (DLQ). While it sits there, the webhook still fixes the order if the customer was charged, and since the flow is idempotent, it&amp;rsquo;s safe to reprocess the message later. What&amp;rsquo;s left is monitoring: alert on the DLQ, and redrive before its retention expires, otherwise the message and the record of the pending work disappear.&lt;/p&gt;
&lt;p&gt;Redriving has one catch: idempotency keys also expire at the provider (Stripe can remove them once they&amp;rsquo;re 24 hours old). If the message is older than that, the retry could create a second charge. So before processing an old message, search the provider by the &lt;code&gt;order_id&lt;/code&gt; in the metadata (Stripe&amp;rsquo;s Search API supports &lt;code&gt;metadata['order_id']&lt;/code&gt;), and only charge if there&amp;rsquo;s no successful payment for it; if there is one, mark the order as &lt;code&gt;PAID&lt;/code&gt;. Search results aren&amp;rsquo;t real-time, which is fine here, since by then the payment is long indexed.&lt;/p&gt;
&lt;h2&gt;Wrapping up&lt;span class="hx:absolute hx:-mt-20" id="wrapping-up"&gt;&lt;/span&gt;
&lt;a href="#wrapping-up" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Every &lt;code&gt;PENDING&lt;/code&gt; order is possibly charged, so the flow has to keep going until it gets a definitive answer. What makes that safe:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ACK only after the flow ends.&lt;/li&gt;
&lt;li&gt;Use the same &lt;code&gt;order_id&lt;/code&gt; everywhere: generated by the client, as the idempotency key, and in the metadata.&lt;/li&gt;
&lt;li&gt;Retry unknown outcomes, and end the flow on definitive ones.&lt;/li&gt;
&lt;li&gt;Use the webhook as a safety net.&lt;/li&gt;
&lt;li&gt;Only move an order forward if it&amp;rsquo;s still &lt;code&gt;PENDING&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Monitor the DLQ.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This works because the API makes a single write: it only publishes a message. If your API needs to write to the DB and publish a message, look at the transactional outbox pattern. The outbox also delivers messages at least once, so the consumer still has to be idempotent.&lt;/p&gt;
&lt;p&gt;Whenever we do one or more write operations on our system, having idempotency is a must in order to guarantee replayability, and help us out on doing reconciliation without needing to infer anything.&lt;/p&gt;</description></item><item><title>Devcontainer</title><link>https://viniciusscampos.dev/devcontainer/</link><pubDate>Sun, 25 Jan 2026 00:00:00 -0300</pubDate><guid>https://viniciusscampos.dev/devcontainer/</guid><description>
&lt;p&gt;In the past we&amp;rsquo;d have the problem of artifact mismatch between development and production. Containerization mitigated this, because we could build a Docker image once and promote it from environment to environment. The code would run as expected regardless of the host, since it lived inside the container.&lt;/p&gt;
&lt;p&gt;Even so, we still develop and run the solution locally on our main machines, and that brings issues like incompatibility. A global library version can conflict with the one required by the project. It also adds another attack surface, because we might run malicious code without noticing it if a dependency gets poisoned (supply chain attack).&lt;/p&gt;
&lt;p&gt;There are solutions that help mitigate this, and one of them is using a &lt;a href="https://containers.dev/"target="_blank" rel="noopener"&gt;devcontainer&lt;/a&gt;. With it, we not only create the final image of the project, but we keep the whole development cycle inside a container. That isolates it from our OS, avoids unwanted incompatibilities, and prevents it from accessing sensitive data on our machine, like credentials.&lt;/p&gt;
&lt;p&gt;This matters even more now with AI coding assistants like &lt;a href="https://openai.com/index/codex/"target="_blank" rel="noopener"&gt;Codex&lt;/a&gt;, &lt;a href="https://www.anthropic.com/claude-code"target="_blank" rel="noopener"&gt;Claude Code&lt;/a&gt;, and &lt;a href="https://opencode.ai/"target="_blank" rel="noopener"&gt;OpenCode&lt;/a&gt;. They could potentially access our root repositories (especially if we run in yolo mode) and create catastrophic scenarios, like deleting important files and folders.&lt;/p&gt;
&lt;p&gt;As a simple example, we can create a devcontainer in the repository and add OpenCode to it, so the AI agent won&amp;rsquo;t have access to anything outside the repository folder, since the mounted volume contains only the repository files. It&amp;rsquo;s important to note that the Docker container still has access to the network, so if we want to make it 100% isolated we should restrict that as well, but that&amp;rsquo;s not our case.&lt;/p&gt;
&lt;p&gt;To configure it, create a &lt;code&gt;.devcontainer/&lt;/code&gt; folder in the repository root directory and add the &lt;code&gt;devcontainer.json&lt;/code&gt; and &lt;code&gt;Dockerfile&lt;/code&gt; files to it.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;devcontainer.json&lt;/code&gt; specifies the name of the sandbox devcontainer environment, how it can be built, the user, and the workspaceFolder, which guarantees it will only have access to this mounted volume, not to all OS folders. Here&amp;rsquo;s a minimal example.&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;name&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;opencode-sandbox-alpine&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;build&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;dockerfile&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Dockerfile&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;remoteUser&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;dev&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;workspaceFolder&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;/workspaces/${localWorkspaceFolderBasename}&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;runArgs&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;--cap-drop=ALL&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;--security-opt=no-new-privileges&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;--pids-limit=256&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Copy code"
aria-label="Copy code"
data-copied-label="Copied!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;The Dockerfile just represents the development environment, so it must install the necessary libraries to do development, including any additional AI coding agent like Claude Code, Codex, and OpenCode.&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-Dockerfile" data-lang="Dockerfile"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;alpine:3.20&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Basics + Node + npm&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;RUN&lt;/span&gt; apk add --no-cache bash ca-certificates git nodejs npm&lt;span class="err"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Install opencode (adjust package name if yours differs)&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;RUN&lt;/span&gt; npm i -g opencode-ai&lt;span class="err"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Non-root user&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;RUN&lt;/span&gt; adduser -D -u &lt;span class="m"&gt;1000&lt;/span&gt; dev&lt;span class="err"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;dev&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;/workspaces&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CMD&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;bash&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Copy code"
aria-label="Copy code"
data-copied-label="Copied!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;Next, install the devcontainers/cli in our system and use it to build and enter the development container as shown below.&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;npm i -g @devcontainers/cli
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;devcontainer up --workspace-folder .
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;devcontainer &lt;span class="nb"&gt;exec&lt;/span&gt; --workspace-folder . bash&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Copy code"
aria-label="Copy code"
data-copied-label="Copied!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;Once it&amp;rsquo;s running, you&amp;rsquo;ll have a simple development environment where you can make all the changes safely inside a container that doesn&amp;rsquo;t have access to other files, avoiding the risk of corrupting any OS related files, for example.&lt;/p&gt;</description></item></channel></rss>