<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Daniel Isaac</title>
    <description>The latest articles on DEV Community by Daniel Isaac (@dannstorm).</description>
    <link>https://dev.to/dannstorm</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4062422%2Fa7bc96fd-32c3-47a9-ab32-31cc57ab4cee.png</url>
      <title>DEV Community: Daniel Isaac</title>
      <link>https://dev.to/dannstorm</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dannstorm"/>
    <language>en</language>
    <item>
      <title>Model the lifecycle as a state machine, not a bag of booleans</title>
      <dc:creator>Daniel Isaac</dc:creator>
      <pubDate>Wed, 05 Aug 2026 16:12:18 +0000</pubDate>
      <link>https://dev.to/dannstorm/model-the-lifecycle-as-a-state-machine-not-a-bag-of-booleans-2a62</link>
      <guid>https://dev.to/dannstorm/model-the-lifecycle-as-a-state-machine-not-a-bag-of-booleans-2a62</guid>
      <description>&lt;p&gt;Every system I've worked on with a lifecycle started the same way: a few boolean columns. A payment gets &lt;code&gt;is_pending&lt;/code&gt;, &lt;code&gt;is_paid&lt;/code&gt;, &lt;code&gt;is_failed&lt;/code&gt;, &lt;code&gt;is_refunded&lt;/code&gt;. A mandate gets &lt;code&gt;is_active&lt;/code&gt;, &lt;code&gt;is_cancelled&lt;/code&gt;. It feels harmless, until the day you open a row that is somehow both paid and failed, nobody can say how it got there, and the code can't answer the one question you actually need: can I act on this right now?&lt;/p&gt;

&lt;p&gt;The trouble with flags is that nothing stops them combining. Four of them describe sixteen states, most of them nonsense, and every bit of code that touches the row is supposed to remember which combinations are real. Something always forgets.&lt;/p&gt;

&lt;p&gt;What I do now is replace the flags with one status and an explicit list of legal moves.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PENDING&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PROCESSING&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PAID&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FAILED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;REFUNDED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LEGAL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;PENDING&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PROCESSING&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FAILED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;PROCESSING&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PAID&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FAILED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;PAID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;REFUNDED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;FAILED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PENDING&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;      &lt;span class="c1"&gt;// a retry&lt;/span&gt;
  &lt;span class="na"&gt;REFUNDED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;[],&lt;/span&gt;              &lt;span class="c1"&gt;// terminal&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That alone kills a whole class of bugs. The value is one of five things, never a contradiction, and "can I act on this" becomes a lookup instead of a guess. Every change goes through one guarded function, so an illegal move throws at the boundary where you can see it, instead of quietly writing something wrong.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;LEGAL&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IllegalTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// ... persist the change (see below)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second half is history. If &lt;code&gt;status&lt;/code&gt; is a single column you overwrite, every change erases the one before it, and when something goes wrong you have no idea how the row got there. So I write each transition to an append-only log. The mutable status column stays, because the guard needs a fast answer to "where is this now", but it is a cache; the log is the record of how it got there, and the thing I trust if the two ever disagree.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;events: (entity_id, from, to, reason, actor, at)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the history is something that happened, not a field I can overwrite. I can see when a payment failed and what we tried next, rebuild the state by replaying, and a stuck record shows itself instead of hiding behind a flag nobody checks.&lt;/p&gt;

&lt;p&gt;This is the same shape as the &lt;a href="https://dev.to/dannstormy/exactly-once-money-movement-surviving-retries-and-duplicate-webhooks-3laa"&gt;exactly-once work&lt;/a&gt; I wrote about. The conditional update is just a transition guard in SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PAID'&lt;/span&gt;
 &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'PENDING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'PROCESSING'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If no row changes, the move wasn't legal from where the record was, which is also exactly how you swallow a duplicate webhook. The state machine and the idempotency guard turn out to be the same idea in two places.&lt;/p&gt;

&lt;p&gt;It's a little more structure than a boolean, and I used to think it wasn't worth it. Then I spent enough late nights trying to reconstruct how a record reached a state that should have been impossible. Now I reach for it first, especially when the thing changing state is someone's money.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>database</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Exactly-once money movement: surviving retries and duplicate webhooks</title>
      <dc:creator>Daniel Isaac</dc:creator>
      <pubDate>Tue, 04 Aug 2026 11:50:13 +0000</pubDate>
      <link>https://dev.to/dannstorm/exactly-once-money-movement-surviving-retries-and-duplicate-webhooks-3laa</link>
      <guid>https://dev.to/dannstorm/exactly-once-money-movement-surviving-retries-and-duplicate-webhooks-3laa</guid>
      <description>&lt;p&gt;Most of my work has been on systems where money moves, and the bugs I learned to fear were never the loud ones. They were quiet. A customer charged twice. A repayment that went through but never got recorded. You rarely see these in a demo. You see them days later, in reconciliation, when the numbers stop agreeing and someone has to work out why.&lt;/p&gt;

&lt;p&gt;Two ordinary things cause most of them. Operations fail halfway, so something retries them. And a payment provider only promises to deliver its callback at least once, which in practice means sometimes twice, sometimes minutes late, sometimes out of order. Put those together and the obvious version of a payment handler will, eventually, do the wrong thing.&lt;/p&gt;

&lt;p&gt;The first fix everyone reaches for is to check before acting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;alreadyPaid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;markPaid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks right. It isn't, the moment there is more than one worker or a webhook gets redelivered, and in production there always is. Two callbacks for the same order arrive together, both ask "already paid?", both hear no, both charge. The gap between the check and the write is where the double charge lives. In-memory locks don't help, because they don't reach across processes.&lt;/p&gt;

&lt;p&gt;What worked for me was to stop being careful in application code and let the database settle it. Claim a unique key for the operation before you touch the money, and let a unique constraint reject the duplicate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;idempotency_keys&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;key&lt;/span&gt;         &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;-- e.g. the provider reference&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;-- PENDING | DONE&lt;/span&gt;
  &lt;span class="n"&gt;response&lt;/span&gt;    &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;insertIfAbsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;storedResponseFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// replay, do not re-charge&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;span class="nf"&gt;finalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                          &lt;span class="c1"&gt;// status = DONE, store response&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now two callbacks racing on the same key collide on the primary key. One wins and does the work; the other reads back the stored result and returns it. There's no window between the check and the act, because the check is the act.&lt;/p&gt;

&lt;p&gt;One honest edge: if the duplicate arrives while the first call is still charging, the key exists but the result isn't stored yet. For a webhook that is fine, the first call is already doing the work, so you acknowledge the duplicate and move on. For a caller waiting on an answer, return a "still processing, retry" instead of an empty result. And if the process dies between charging and storing the result, the money moved but the key never got marked done, the one gap idempotency can't close on its own, which is what the reconciliation job is for.&lt;/p&gt;

&lt;p&gt;For a status change the same idea is even smaller. Make the update conditional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;
   &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PAID'&lt;/span&gt;
 &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
   &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'PENDING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'PROCESSING'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If no row changed, someone already moved it, and you quietly do nothing.&lt;/p&gt;

&lt;p&gt;A few things I got wrong before I got them right. The key has to be stable across retries, the provider's transaction reference, not a timestamp. Store the response, not just the fact it happened, so a duplicate gets the same answer the first call did. And keep a reconciliation job as a backstop, because idempotency can't help with the callback you never received.&lt;/p&gt;

&lt;p&gt;I came to engineering from law, and the habit that carried over is looking for the edge case that bites later. With money, that edge case is almost always right here: two callbacks in the same millisecond, a retry mid-write. It isn't the interesting part of the work. It's most of the work.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>webdev</category>
      <category>distributedsystems</category>
    </item>
  </channel>
</rss>
