<?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: Odumosu Matthew</title>
    <description>The latest articles on DEV Community by Odumosu Matthew (@iamcymentho).</description>
    <link>https://dev.to/iamcymentho</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%2F1126949%2F4b0e70aa-41cc-4934-af8a-602120489e35.JPG</url>
      <title>DEV Community: Odumosu Matthew</title>
      <link>https://dev.to/iamcymentho</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/iamcymentho"/>
    <language>en</language>
    <item>
      <title>A practical guide to race conditions and five ways engineers make sure it's never both</title>
      <dc:creator>Odumosu Matthew</dc:creator>
      <pubDate>Sat, 14 Nov 2026 14:26:33 +0000</pubDate>
      <link>https://dev.to/iamcymentho/a-practical-guide-to-race-conditions-and-five-ways-engineers-make-sure-its-never-both-203g</link>
      <guid>https://dev.to/iamcymentho/a-practical-guide-to-race-conditions-and-five-ways-engineers-make-sure-its-never-both-203g</guid>
      <description>&lt;h1&gt;
  
  
  Two Travellers, One Hotel Room, the Same Second: Who Gets It?
&lt;/h1&gt;

&lt;p&gt;Sofia is in Madrid. James is in Toronto.&lt;/p&gt;

&lt;p&gt;Both are planning the same weekend in Lisbon. Both find the same small hotel by the river. Both see the same message: "Only 1 room left!"&lt;/p&gt;

&lt;p&gt;At exactly the same second, both tap "Book now."&lt;/p&gt;

&lt;p&gt;Who gets the room?&lt;/p&gt;

&lt;p&gt;In a badly built system, both do. Two confirmation emails. Two happy travellers. One room. And one very awkward conversation at the front desk.&lt;/p&gt;

&lt;p&gt;This is a &lt;strong&gt;race condition&lt;/strong&gt;, and it's one of the most common and most dangerous bugs in software. It hides in the few milliseconds nobody tests, and it shows up in production when real people do things at the same time.&lt;/p&gt;

&lt;p&gt;In payments, I deal with the same problem, just with money instead of rooms: two withdrawals hitting the same balance at the same moment. Get it wrong, and someone spends money that isn't there.&lt;/p&gt;

&lt;p&gt;Let's break down why it happens and how to prevent it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What is a race condition?
&lt;/h2&gt;

&lt;p&gt;A race condition happens when two operations run at the same time, and each makes a decision based on information that is about to become wrong.&lt;/p&gt;

&lt;p&gt;Here's the hotel booking, step by step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Time   Sofia's request                James's request
----   ---------------                ---------------
t1     READ: rooms_left = 1
t2                                    READ: rooms_left = 1
t3     1 &amp;gt; 0, so book it
t4                                    1 &amp;gt; 0, so book it
t5     WRITE: rooms_left = 0
t6                                    WRITE: rooms_left = 0
       Result: two bookings, one room
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each check was correct when it was made. The problem is the gap between &lt;strong&gt;checking&lt;/strong&gt; and &lt;strong&gt;acting&lt;/strong&gt;. Engineers call this the &lt;strong&gt;check-then-act&lt;/strong&gt; bug, and the gap is the &lt;strong&gt;race window&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The naive code looks perfectly reasonable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;room&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Rooms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FindAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;roomId&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="n"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RoomsLeft&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RoomsLeft&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;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Bookings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Booking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;guestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;night&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It passes every test written by one developer clicking one button. It fails the first busy Friday night.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fix 1: Make check-and-act a single atomic step
&lt;/h2&gt;

&lt;p&gt;The simplest and often best fix is to let the database do the check and the update in one statement:&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;rooms&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;rooms_left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rooms_left&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&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="mi"&gt;42&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;rooms_left&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If two requests run this at the same time, the database serialises them. The first changes one row. The second changes &lt;strong&gt;zero rows&lt;/strong&gt;, because &lt;code&gt;rooms_left&lt;/code&gt; is no longer greater than zero.&lt;/p&gt;

&lt;p&gt;Your code checks the affected row count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ExecuteSqlInterpolatedAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    $&lt;span class="s2"&gt;"UPDATE Rooms SET RoomsLeft = RoomsLeft - 1 WHERE Id = {roomId} AND RoomsLeft &amp;gt; 0"&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="n"&gt;updated&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;BookingResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SoldOut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is exactly how a safe balance debit works in a payment system:&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;accounts&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nd"&gt;@amount&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="nd"&gt;@accountId&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nd"&gt;@amount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- 0 rows updated = insufficient funds, even under concurrency&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Fix 2: Pessimistic locking
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pessimistic&lt;/strong&gt; locking assumes conflicts are likely, so it locks the record before working on it. The second request waits until the first is finished, then sees the updated value.&lt;/p&gt;

&lt;p&gt;In PostgreSQL or MySQL:&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;rooms_left&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;rooms&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="mi"&gt;42&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- row is now locked until COMMIT&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;rooms&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;rooms_left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rooms_left&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&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="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;bookings&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In SQL Server, the equivalent hint is &lt;code&gt;WITH (UPDLOCK, ROWLOCK)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In Spring Data JPA:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LockModeType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;PESSIMISTIC_WRITE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"select r from Room r where r.id = :id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Room&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByIdForUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&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;&lt;strong&gt;Trade-off:&lt;/strong&gt; it's simple and safe, but locks make other requests wait. Hold them only for the shortest possible time, never across a slow external call like a payment provider.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fix 3: Optimistic concurrency
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Optimistic&lt;/strong&gt; concurrency assumes conflicts are rare. Instead of locking, every record carries a &lt;strong&gt;version number&lt;/strong&gt;. An update only succeeds if the version hasn't changed since you read it.&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;rooms&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;rooms_left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&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="mi"&gt;42&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- 0 rows updated = someone else changed it first&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In EF Core, mark a concurrency token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Room&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nc"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nc"&gt;RoomsLeft&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Timestamp&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="nc"&gt;RowVersion&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DbUpdateConcurrencyException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Someone else booked first: re-read, then retry or tell the user.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In JPA:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Room&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Id&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;roomsLeft&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Version&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// A conflicting update throws OptimisticLockException.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Trade-off:&lt;/strong&gt; no waiting and great performance when conflicts are rare, but under heavy contention many requests fail and retry.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fix 4: Let the database be the final referee
&lt;/h2&gt;

&lt;p&gt;Even with careful application code, bugs happen. A &lt;strong&gt;unique constraint&lt;/strong&gt; is your last line of defence:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;bookings&lt;/span&gt;
&lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;uq_room_night&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;room_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;night&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;If every other layer fails, the database simply refuses the second booking for the same room and night. In payments, the equivalent is a unique constraint on a transaction reference or idempotency key, so the same payment can never be recorded twice.&lt;/p&gt;

&lt;p&gt;Constraints don't replace good logic. They catch what your logic misses.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fix 5: Hold, then confirm
&lt;/h2&gt;

&lt;p&gt;Many booking sites say: "We're holding this room for you for 10 minutes."&lt;/p&gt;

&lt;p&gt;That's a &lt;strong&gt;reservation with a time-to-live (TTL)&lt;/strong&gt;. The room is set aside the moment you start checking out, so you never enter card details for a room that's already gone. If you don't complete payment in time, the hold expires and the room is released.&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;holds&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;room_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;night&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;guest_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2026-11-14'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7781&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="kt"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'10 minutes'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- with a unique constraint on (room_id, night) for active holds&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A background job, or the next booking attempt, clears expired holds. The same pattern is used in payments for &lt;strong&gt;authorisation holds&lt;/strong&gt;: funds are reserved first, then captured or released.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which fix should you use?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Atomic conditional update:&lt;/strong&gt; your default for counters, balances and stock levels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pessimistic locking:&lt;/strong&gt; when you must read, decide and write several things together, and conflicts are common.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimistic concurrency:&lt;/strong&gt; when conflicts are rare and you want to avoid waiting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unique constraints:&lt;/strong&gt; always, as a safety net.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reservation holds:&lt;/strong&gt; when there's a gap between choosing and paying.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most production systems combine several. A booking might use a hold during checkout, an atomic update to confirm, and a unique constraint as the final guard.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to catch race conditions before production
&lt;/h2&gt;

&lt;p&gt;Race conditions rarely appear when one developer tests one request. You have to create the race on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Write integration tests that fire many identical requests &lt;strong&gt;in parallel&lt;/strong&gt; and assert that only one succeeds.&lt;/li&gt;
&lt;li&gt;Load-test busy endpoints like checkout, transfers and withdrawals.&lt;/li&gt;
&lt;li&gt;In code reviews, look for any &lt;strong&gt;read, then decide, then write&lt;/strong&gt; sequence on shared data and ask: "What if two of these run at once?"
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nc"&gt;Task&lt;/span&gt; &lt;span class="nc"&gt;Only_one_booking_succeeds_for_the_last_room&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Enumerable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PostAsJsonAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/bookings"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;roomId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;guestId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;responses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nc"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WhenAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nc"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Created&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What James sees
&lt;/h2&gt;

&lt;p&gt;So, if Sofia wins, what does James see?&lt;/p&gt;

&lt;p&gt;Not a crash. Not a charge for a room he didn't get. Just an honest message:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Sorry, this room was just booked. Here are similar rooms nearby."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's what good engineering feels like from the outside: calm, clear and fair, even when two people reach for the same thing at once.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A race condition happens in the gap between &lt;strong&gt;checking&lt;/strong&gt; and &lt;strong&gt;acting&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Close the gap with &lt;strong&gt;atomic conditional updates&lt;/strong&gt; wherever possible.&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;pessimistic locks&lt;/strong&gt; when conflicts are frequent, and keep them short.&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;optimistic concurrency&lt;/strong&gt; when conflicts are rare.&lt;/li&gt;
&lt;li&gt;Add &lt;strong&gt;unique constraints&lt;/strong&gt; as the final referee.&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;reservation holds&lt;/strong&gt; when there's a delay between choosing and paying.&lt;/li&gt;
&lt;li&gt;Test concurrency &lt;strong&gt;on purpose&lt;/strong&gt;, because it won't show up by accident.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Race conditions hide in the milliseconds nobody tests. Good engineering decides, calmly and in advance, who wins.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Matthew Odumosu is a Senior Software Engineer with 8+ years of experience building payment, banking and insurance systems. He writes about engineering principles in plain language, for engineers at every level.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Connect on LinkedIn: linkedin.com/in/matthew-odumosu&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; Software Engineering, Concurrency, Databases, System Design, Backend Development&lt;/p&gt;

</description>
      <category>software</category>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
      <category>docker</category>
    </item>
    <item>
      <title>Money In Freely, Money Out Carefully: The 24-Hour Rule That Stops Fraudsters</title>
      <dc:creator>Odumosu Matthew</dc:creator>
      <pubDate>Sat, 14 Nov 2026 14:24:24 +0000</pubDate>
      <link>https://dev.to/iamcymentho/money-in-freely-money-out-carefully-the-24-hour-rule-that-stops-fraudsters-165i</link>
      <guid>https://dev.to/iamcymentho/money-in-freely-money-out-carefully-the-24-hour-rule-that-stops-fraudsters-165i</guid>
      <description>&lt;h3&gt;
  
  
  How payment systems protect people from fraud without turning customers away
&lt;/h3&gt;

&lt;p&gt;Tolu opened a new bank account on Monday morning.&lt;/p&gt;

&lt;p&gt;By Monday afternoon, a client paid her for a big job. The alert came in. Joy.&lt;/p&gt;

&lt;p&gt;Then she tried to pay her supplier.&lt;/p&gt;

&lt;p&gt;"Limit exceeded."&lt;/p&gt;

&lt;p&gt;The money was right there in her account. Nothing was wrong with it. So why couldn't she move it?&lt;/p&gt;

&lt;p&gt;To Tolu, it felt like the bank was getting in her way. But what she couldn't see was that the same rule stopping her that afternoon was protecting someone else's life savings.&lt;/p&gt;

&lt;p&gt;This article explains that rule, why it exists, and the engineering decisions behind enforcing it well.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fraudster's playbook
&lt;/h2&gt;

&lt;p&gt;Some of the most valuable tools in a fraudster's kit are brand new bank accounts.&lt;/p&gt;

&lt;p&gt;The pattern looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A victim is tricked into sending money, or their account is compromised.&lt;/li&gt;
&lt;li&gt;The money is sent to a freshly opened account, often one opened only hours earlier.&lt;/li&gt;
&lt;li&gt;Within minutes, the money is moved on to a second account, then a third.&lt;/li&gt;
&lt;li&gt;By the time the victim reports it, the money has passed through several accounts and is gone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These pass-through accounts are often called &lt;strong&gt;mule accounts&lt;/strong&gt;, and moving money quickly through several of them is called &lt;strong&gt;layering&lt;/strong&gt;. Speed is the fraudster's greatest advantage. The faster money can leave a new account, the harder it is to recover.&lt;/p&gt;

&lt;p&gt;So a new account is, in effect, the getaway car.&lt;/p&gt;




&lt;h2&gt;
  
  
  The rule: let money in, slow money out
&lt;/h2&gt;

&lt;p&gt;The protection is simple in principle: for a short period after an account is opened, and especially before the customer has completed fuller identity verification, limit how much money can &lt;strong&gt;leave&lt;/strong&gt; the account.&lt;/p&gt;

&lt;p&gt;Notice what this rule does not do. It does not reject money coming in.&lt;/p&gt;

&lt;p&gt;That distinction matters more than it first appears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If the system rejected inflows:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tolu's client gets an error and assumes something is wrong with her account.&lt;/li&gt;
&lt;li&gt;Tolu loses a payment, and possibly a client.&lt;/li&gt;
&lt;li&gt;The bank or fintech loses a customer on her first day.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;If the system limits outflows:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tolu gets paid. Her money is safe in her account.&lt;/li&gt;
&lt;li&gt;She can still spend within a reasonable limit straight away.&lt;/li&gt;
&lt;li&gt;Larger amounts can move once the window passes or she upgrades her account.&lt;/li&gt;
&lt;li&gt;A fraudster who receives stolen money can't run with it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The customer still gets paid. The business keeps the customer. And stolen money has nowhere to go.&lt;/p&gt;

&lt;p&gt;This connects to &lt;strong&gt;tiered KYC&lt;/strong&gt; (Know Your Customer), used in many markets including Nigeria: the more a customer verifies their identity, the more freedom their account has. A brand new, lightly verified account starts with tighter limits, and those limits widen as trust is established.&lt;/p&gt;




&lt;h2&gt;
  
  
  The engineering behind it
&lt;/h2&gt;

&lt;p&gt;Enforcing a rule like this sounds easy: "if the account is new and the amount is above the limit, block it." In production, there's more to get right. I've worked on enforcing this kind of rule in payment infrastructure, and these are the principles that matter.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Enforce it on the right path
&lt;/h3&gt;

&lt;p&gt;The check belongs on the &lt;strong&gt;outflow&lt;/strong&gt; path: transfers out, withdrawals, card payments. Inflows should still be credited normally.&lt;/p&gt;

&lt;p&gt;Putting the check in the wrong place either blocks legitimate customers or leaves a gap fraudsters will find. Every way money can leave an account (app transfers, USSD, cards, POS, agent withdrawals) needs to go through the same check, ideally in one shared component rather than copied into each channel.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Measure a rolling window, not a single transaction
&lt;/h3&gt;

&lt;p&gt;A limit on a single transfer is easy to get around: just send several smaller ones. The rule needs to look at the &lt;strong&gt;total outflow&lt;/strong&gt; over the window.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nc"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LimitCheckResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;CheckOutflowAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;Account&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;rules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_limits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CurrentValue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// from configuration&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;accountAge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UtcNow&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OpenedAtUtc&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="n"&gt;accountAge&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NewAccountWindow&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;KycTier&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ExemptFromTier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;LimitCheckResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;windowStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UtcNow&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NewAccountWindow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;alreadySent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Transactions&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AccountId&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Id&lt;/span&gt;
                 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Direction&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;Direction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Debit&lt;/span&gt;
                 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nc"&gt;TransactionStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Failed&lt;/span&gt;
                 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CreatedAtUtc&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;windowStart&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SumAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NewAccountOutflowLimit&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alreadySent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt;
        &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;LimitCheckResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;LimitCheckResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Blocked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OpenedAtUtc&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NewAccountWindow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Keep the rule outside the code
&lt;/h3&gt;

&lt;p&gt;Limits change. Regulators adjust them. Risk teams tune them after studying fraud patterns. If the limit is hardcoded, every change means a code release.&lt;/p&gt;

&lt;p&gt;So the values live in &lt;strong&gt;configuration&lt;/strong&gt;, ideally a dynamic configuration service that can be updated without redeploying or restarting anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NewAccountLimitOptions&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;TimeSpan&lt;/span&gt; &lt;span class="nc"&gt;NewAccountWindow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FromHours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="nc"&gt;NewAccountOutflowLimit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nc"&gt;ExemptFromTier&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Program.cs&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Configure&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NewAccountLimitOptions&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GetSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Limits:NewAccount"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Inject IOptionsMonitor&amp;lt;NewAccountLimitOptions&amp;gt; so updated values&lt;/span&gt;
&lt;span class="c1"&gt;// are picked up without a restart.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During a fraud wave, being able to tighten a limit in minutes rather than days can make a real difference.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Beware of two withdrawals at the same moment
&lt;/h3&gt;

&lt;p&gt;Here's the subtle bug. Two outgoing transfers arrive at almost exactly the same time. Both check the limit. Both see enough headroom. Both go through. Together, they exceed the limit.&lt;/p&gt;

&lt;p&gt;That's a &lt;strong&gt;race condition&lt;/strong&gt;, and it's exactly the gap a scripted fraud attempt will try to exploit. The fix is to make the check and the update a single atomic step: lock the account row while checking and debiting, or track the window's total in a counter that's updated with a conditional update. (I cover race conditions in depth in a separate article.)&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Say "not yet" kindly and clearly
&lt;/h3&gt;

&lt;p&gt;A rule that protects people can still frustrate them if it's communicated badly.&lt;/p&gt;

&lt;p&gt;"Transaction failed" tells Tolu nothing. She'll assume the bank is broken and call customer care.&lt;/p&gt;

&lt;p&gt;Compare that with:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"For your security, new accounts can send up to [limit] in the first 24 hours. You'll be able to send more from [time], or straight away if you upgrade your account."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Same rule. Completely different experience. The second message turns a frustration into a reassurance.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Leave an audit trail
&lt;/h3&gt;

&lt;p&gt;Every block should be logged with the account, the amount, the remaining limit and the reason. Risk teams use this to tune the rules, support teams use it to answer customers, and compliance teams use it to show regulators the rule is being enforced.&lt;/p&gt;




&lt;h2&gt;
  
  
  The balance every payment system has to strike
&lt;/h2&gt;

&lt;p&gt;Every extra security step protects customers. Every extra security step also risks losing them.&lt;/p&gt;

&lt;p&gt;Good engineering in fintech lives in that tension. Block too much, and legitimate customers leave. Block too little, and fraudsters move in.&lt;/p&gt;

&lt;p&gt;The "money in freely, money out carefully" approach is a good example of finding the balance: maximum protection with minimum disruption to honest customers.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;New accounts are a common tool for moving stolen money quickly.&lt;/li&gt;
&lt;li&gt;Limiting &lt;strong&gt;outflows&lt;/strong&gt;, not inflows, protects victims without losing customers.&lt;/li&gt;
&lt;li&gt;Measure the &lt;strong&gt;rolling total&lt;/strong&gt;, not individual transactions.&lt;/li&gt;
&lt;li&gt;Keep limits in &lt;strong&gt;configuration&lt;/strong&gt; so they can change without a release.&lt;/li&gt;
&lt;li&gt;Guard against &lt;strong&gt;race conditions&lt;/strong&gt; on the limit check.&lt;/li&gt;
&lt;li&gt;Explain blocks &lt;strong&gt;clearly&lt;/strong&gt;, with when and how the limit lifts.&lt;/li&gt;
&lt;li&gt;Log every decision for &lt;strong&gt;audit&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To Tolu, that limit felt like an obstacle. To someone else, the same rule was the reason their stolen savings were still there when they called the bank.&lt;/p&gt;

&lt;p&gt;Good engineering doesn't always say no. Sometimes it just says "not yet."&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Matthew Odumosu is a Senior Software Engineer with 8+ years of experience building payment, banking and insurance systems. He writes about engineering principles in plain language, for engineers at every level.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Connect on LinkedIn: linkedin.com/in/matthew-odumosu&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; Fintech, Fraud Prevention, Software Engineering, Payments, System Design&lt;/p&gt;

</description>
      <category>development</category>
      <category>software</category>
      <category>softwaredevelopment</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Message queues and asynchronous processing, explained through a trip to the dry cleaner</title>
      <dc:creator>Odumosu Matthew</dc:creator>
      <pubDate>Mon, 09 Nov 2026 10:41:49 +0000</pubDate>
      <link>https://dev.to/iamcymentho/-message-queues-and-asynchronous-processing-explained-through-a-trip-to-the-dry-cleaner-16mb</link>
      <guid>https://dev.to/iamcymentho/-message-queues-and-asynchronous-processing-explained-through-a-trip-to-the-dry-cleaner-16mb</guid>
      <description>&lt;h2&gt;
  
  
  Why Your Bank App Says "Successful" Before the SMS Arrives
&lt;/h2&gt;

&lt;p&gt;When you drop your clothes at the dry cleaner, nobody asks you to wait at the counter while they wash, dry and iron everything.&lt;/p&gt;

&lt;p&gt;They hand you a ticket and say, "We'll call you when it's ready."&lt;/p&gt;

&lt;p&gt;You go about your day. The work still happens. It just doesn't happen while you stand there waiting.&lt;/p&gt;

&lt;p&gt;That simple idea is behind one of the most important patterns in modern software. It's the reason your banking app can say "Transfer successful" in about a second, while the SMS alert, the receipt and the rewards points arrive a few moments later.&lt;/p&gt;

&lt;p&gt;Engineers call it &lt;strong&gt;asynchronous processing&lt;/strong&gt;, and the tool that makes it work is the &lt;strong&gt;message queue&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In this article, I'll explain what a message queue is, why payment systems depend on it, and the rules experienced engineers follow so that nothing gets lost in between.&lt;/p&gt;




&lt;h2&gt;
  
  
  What must happen now, and what can happen next
&lt;/h2&gt;

&lt;p&gt;When you tap "Send" on a transfer, a lot happens. But not all of it needs to happen before you see a result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some things must happen immediately:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Check that you have enough money&lt;/li&gt;
&lt;li&gt;Debit your account&lt;/li&gt;
&lt;li&gt;Record the transaction in the ledger&lt;/li&gt;
&lt;li&gt;Confirm the result to you&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Other things can safely happen a moment later:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Send the SMS or push notification&lt;/li&gt;
&lt;li&gt;Generate the receipt&lt;/li&gt;
&lt;li&gt;Update rewards or loyalty points&lt;/li&gt;
&lt;li&gt;Feed fraud analytics and reporting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a payment service did all of this in one long chain, every transfer would be as slow as its slowest step. And if the SMS provider had a bad day, your transfer would fail even though the money part worked perfectly.&lt;/p&gt;

&lt;p&gt;So instead, the payment service does the critical work, then drops a message into a queue that says, in effect, "A payment just completed. Whoever needs to know, here are the details." Then it moves on.&lt;/p&gt;

&lt;p&gt;Other services pick up that message and do their part in the background.&lt;/p&gt;




&lt;h2&gt;
  
  
  How a message queue works
&lt;/h2&gt;

&lt;p&gt;There are three main roles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Producer:&lt;/strong&gt; the service that publishes a message. Here, the payment service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Broker:&lt;/strong&gt; the system that stores messages safely until they're processed. Apache Kafka and RabbitMQ are the most common.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consumer:&lt;/strong&gt; a service that reads messages and acts on them. The SMS service, the receipt service, the rewards service.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Payment Service] --publishes--&amp;gt; [ payment-completed queue ] --&amp;gt; [SMS Service]
   (must happen NOW)                                         --&amp;gt; [Receipt Service]
                                                             --&amp;gt; [Rewards Service]
                                                             --&amp;gt; [Fraud Analytics]
                                                                (can happen NEXT)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The beauty of this design is that the producer doesn't need to know who is listening. Tomorrow, the team can add a new consumer, say a monthly spending insights service, without touching the payment service at all.&lt;/p&gt;

&lt;p&gt;That's called &lt;strong&gt;loose coupling&lt;/strong&gt;, and it's one of the reasons large systems can keep growing without collapsing under their own weight.&lt;/p&gt;




&lt;h2&gt;
  
  
  The rules experienced engineers follow
&lt;/h2&gt;

&lt;p&gt;Queues make systems faster and more resilient. They also introduce new ways for things to go wrong. Here are the rules I hold teams to when building this way.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Make every consumer idempotent
&lt;/h3&gt;

&lt;p&gt;Most message brokers guarantee that a message will be delivered &lt;strong&gt;at least once&lt;/strong&gt;. Not exactly once. At least once.&lt;/p&gt;

&lt;p&gt;That means the same message can arrive twice: after a network hiccup, a consumer restart, or a retry. If your consumer isn't prepared for that, one payment can turn into two SMS alerts. Annoying. Or one credit can turn into two credits. Expensive.&lt;/p&gt;

&lt;p&gt;An operation is &lt;strong&gt;idempotent&lt;/strong&gt; if doing it once or ten times gives the same result. The common way to achieve this is to record every message ID you've processed, protected by a unique constraint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@KafkaListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"payment-completed"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;groupId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"sms-service"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onPaymentCompleted&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentCompletedEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Insert the event ID into a table with a UNIQUE constraint.&lt;/span&gt;
    &lt;span class="c1"&gt;// Returns false if this event was already processed.&lt;/span&gt;
    &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;firstTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;processedEvents&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;markProcessed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;firstTime&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Duplicate event {} ignored"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;smsService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sendPaymentAlert&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;customerPhone&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For anything that moves money, this isn't optional. One credit must never become two.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Retry with backoff, then park it in a dead letter queue
&lt;/h3&gt;

&lt;p&gt;Sometimes a message fails because of a temporary problem: the SMS provider is slow, the database is busy. Retrying makes sense.&lt;/p&gt;

&lt;p&gt;But retrying instantly and forever just hammers a system that's already struggling. So we retry with &lt;strong&gt;exponential backoff&lt;/strong&gt;: wait one second, then two, then four, then eight.&lt;/p&gt;

&lt;p&gt;If it still fails after a few attempts, we don't let it block every message behind it. We move it to a &lt;strong&gt;dead letter queue (DLQ)&lt;/strong&gt;, where it can be inspected and replayed later.&lt;/p&gt;

&lt;p&gt;In Spring Kafka, that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;DefaultErrorHandler&lt;/span&gt; &lt;span class="nf"&gt;errorHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KafkaTemplate&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;template&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;recoverer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DeadLetterPublishingRecoverer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;template&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;backOff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ExponentialBackOffWithMaxRetries&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;backOff&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setInitialInterval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1_000L&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 1 second&lt;/span&gt;
    &lt;span class="n"&gt;backOff&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setMultiplier&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// 1s, 2s, 4s, 8s&lt;/span&gt;
    &lt;span class="n"&gt;backOff&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setMaxInterval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10_000L&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DefaultErrorHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recoverer&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;backOff&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Failed messages land on a topic like &lt;code&gt;payment-completed.DLT&lt;/code&gt;, and nothing behind them gets stuck.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Choose your keys carefully when order matters
&lt;/h3&gt;

&lt;p&gt;Kafka splits a topic into &lt;strong&gt;partitions&lt;/strong&gt; so it can process messages in parallel. Order is only guaranteed &lt;strong&gt;within&lt;/strong&gt; a partition.&lt;/p&gt;

&lt;p&gt;If events for the same account must be processed in order (a debit before a reversal, for example), give them the same &lt;strong&gt;key&lt;/strong&gt;. Kafka sends every message with the same key to the same partition.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;kafkaTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"account-events"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;accountNumber&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;//                                    ^ key: same account, same partition, same order&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick the wrong key, or no key, and two events for the same account can be processed out of order.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Watch the backlog
&lt;/h3&gt;

&lt;p&gt;The danger with background work is that it can fall behind quietly. Customers won't complain about a slow queue. They'll complain that their alert never came.&lt;/p&gt;

&lt;p&gt;So monitor &lt;strong&gt;consumer lag&lt;/strong&gt;: the gap between the latest message published and the latest message processed. If lag keeps growing, consumers can't keep up, and you want to know before your customers do.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Don't lose the message between the database and the queue
&lt;/h3&gt;

&lt;p&gt;Here's a subtle trap. The payment service writes to the ledger, then publishes a message. What if the database write succeeds and the publish fails? The money moved, but no SMS, no receipt, no fraud check.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;transactional outbox pattern&lt;/strong&gt; solves this. Instead of publishing directly, the service writes the ledger entry &lt;strong&gt;and&lt;/strong&gt; an "outbox" row in the same database transaction. A separate relay reads the outbox and publishes to the queue.&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;ledger_entries&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(...);&lt;/span&gt;
  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- A relay process publishes outbox rows to Kafka, then marks them sent.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either both are saved, or neither is. Nothing gets lost in between.&lt;/p&gt;




&lt;h2&gt;
  
  
  When not to use a queue
&lt;/h2&gt;

&lt;p&gt;Asynchronous processing isn't free. It adds moving parts, makes debugging harder, and means data is briefly out of sync.&lt;/p&gt;

&lt;p&gt;Don't make something asynchronous if the user genuinely needs the result before continuing. Checking the balance and debiting the account must happen synchronously. A customer can wait a few seconds for an SMS. They can't wait a few seconds to find out whether their money moved.&lt;/p&gt;

&lt;p&gt;The skill is in drawing that line well.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the customer feels
&lt;/h2&gt;

&lt;p&gt;Customers never see a queue. What they feel is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Transfer successful" in about a second&lt;/li&gt;
&lt;li&gt;The alert and receipt arriving shortly after&lt;/li&gt;
&lt;li&gt;An app that keeps working even when one part of the system is having a bad day&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And when it's done well, nothing gets lost along the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Split work into what must happen &lt;strong&gt;now&lt;/strong&gt; and what can happen &lt;strong&gt;next&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Message queues let services communicate without waiting on each other.&lt;/li&gt;
&lt;li&gt;Delivery is at least once, so &lt;strong&gt;consumers must be idempotent&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Retry with &lt;strong&gt;exponential backoff&lt;/strong&gt;, then move failures to a &lt;strong&gt;dead letter queue&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Use message &lt;strong&gt;keys&lt;/strong&gt; to keep related events in order.&lt;/li&gt;
&lt;li&gt;Monitor &lt;strong&gt;consumer lag&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Use the &lt;strong&gt;outbox pattern&lt;/strong&gt; so a database write and a published event can't drift apart.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Good engineering isn't doing everything at once. It's knowing what must happen now, what can happen next, and making sure nothing gets lost in between.&lt;/p&gt;

&lt;p&gt;Just like a good dry cleaner never loses the ticket.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Matthew Odumosu is a Senior Software Engineer with 8+ years of experience building payment, banking and insurance systems. He writes about engineering principles in plain language, for engineers at every level.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Connect on LinkedIn: linkedin.com/in/matthew-odumosu&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; Software Engineering, System Design, Kafka, Microservices, Fintech&lt;/p&gt;

</description>
      <category>software</category>
      <category>softwareengineering</category>
      <category>softwaredevelopment</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>API Versioning: How to Evolve Your API Without Breaking Existing Clients</title>
      <dc:creator>Odumosu Matthew</dc:creator>
      <pubDate>Thu, 29 Oct 2026 11:29:13 +0000</pubDate>
      <link>https://dev.to/iamcymentho/api-versioning-how-to-evolve-your-api-without-breaking-existing-clients-1l7m</link>
      <guid>https://dev.to/iamcymentho/api-versioning-how-to-evolve-your-api-without-breaking-existing-clients-1l7m</guid>
      <description>&lt;p&gt;𝐃𝐚𝐧𝐢𝐞𝐥 𝐥𝐚𝐧𝐝𝐬 𝐢𝐧 𝐑𝐨𝐦𝐞 𝐟𝐨𝐫 𝐚 𝐜𝐨𝐧𝐟𝐞𝐫𝐞𝐧𝐜𝐞. 𝐏𝐡𝐨𝐧𝐞 𝐚𝐭 4%.&lt;/p&gt;

&lt;p&gt;He reaches the hotel, pulls out his charger, and stops.&lt;/p&gt;

&lt;p&gt;The plug doesn’t fit the socket.&lt;/p&gt;

&lt;p&gt;Nothing is broken. The phone works. The socket works. They just no longer speak the same shape&lt;/p&gt;

&lt;p&gt;Software has the same problem every day.&lt;/p&gt;

&lt;p&gt;A team improves their API. They rename a field, change a format, remove something “nobody uses.” It works perfectly on their side.&lt;/p&gt;

&lt;p&gt;Then thousands of people who haven’t updated their app open it on Monday morning, and it stops working. Payments fail. Screens go blank. Support lines fill up.&lt;/p&gt;

&lt;p&gt;The new socket was better. It just didn’t fit the plugs already out in the world.&lt;/p&gt;

&lt;p&gt;Good engineers know that once an API is public, it becomes a promise. You can improve it, but you can’t quietly break the people already relying on it.&lt;/p&gt;

&lt;p&gt;So instead of forcing everyone to change overnight, we give them an adapter: the old version keeps working while the new one is introduced, and nobody is left standing in a hotel room with a dying phone.&lt;/p&gt;

&lt;p&gt;What’s the most painful breaking change you’ve had to recover from?&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7qc1rfca6fxqcu5pwb5b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7qc1rfca6fxqcu5pwb5b.png" alt="api versioning" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>backend</category>
      <category>architecture</category>
      <category>api</category>
    </item>
    <item>
      <title>Backpressure in System Design: How to Handle Traffic Spikes Without Crashing</title>
      <dc:creator>Odumosu Matthew</dc:creator>
      <pubDate>Thu, 29 Oct 2026 11:25:41 +0000</pubDate>
      <link>https://dev.to/iamcymentho/backpressure-in-system-design-how-to-handle-traffic-spikes-without-crashing-hfp</link>
      <guid>https://dev.to/iamcymentho/backpressure-in-system-design-how-to-handle-traffic-spikes-without-crashing-hfp</guid>
      <description>&lt;p&gt;𝐈𝐭’𝐬 𝐭𝐡𝐞 𝐟𝐢𝐫𝐬𝐭 𝐌𝐨𝐧𝐝𝐚𝐲 𝐨𝐟 𝐭𝐡𝐞 𝐡𝐨𝐥𝐢𝐝𝐚𝐲𝐬. 𝐄𝐯𝐞𝐫𝐲 𝐟𝐥𝐢𝐠𝐡𝐭 𝐥𝐞𝐚𝐯𝐞𝐬 𝐛𝐞𝐟𝐨𝐫𝐞 9𝐚𝐦.&lt;/p&gt;

&lt;p&gt;Thousands of passengers reach airport security at once.&lt;/p&gt;

&lt;p&gt;Imagine the airport let everyone through the entrance together. People crowd the scanners. Bags pile up. Staff can’t move. Nobody gets through, and everyone misses their flight.&lt;/p&gt;

&lt;p&gt;Real airports don’t do that. They control the flow at the door: a rope queue, a gate that opens in batches, a fast-track lane for anyone about to miss a flight. The scanners keep a steady pace, and the queue absorbs the rush.&lt;/p&gt;

&lt;p&gt;Software faces the same rush on payday, during a flash sale, or when something goes viral. A system that accepts everything eventually serves no one.&lt;/p&gt;

&lt;p&gt;Engineers call the fix 𝐛𝐚𝐜𝐤𝐩𝐫𝐞𝐬𝐬𝐮𝐫𝐞. I sketched how it works, from the API gateway down to the database.&lt;/p&gt;

&lt;p&gt;What’s the biggest traffic spike your system has had to survive?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5aq9va2szh6t0b1j1fpm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5aq9va2szh6t0b1j1fpm.png" alt="back-pressure" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>scalability</category>
      <category>systemdesign</category>
      <category>architecture</category>
      <category>performance</category>
    </item>
    <item>
      <title>Race Conditions in Production: When Two Requests Compete for One Resource</title>
      <dc:creator>Odumosu Matthew</dc:creator>
      <pubDate>Thu, 29 Oct 2026 11:10:46 +0000</pubDate>
      <link>https://dev.to/iamcymentho/race-condition-ahl</link>
      <guid>https://dev.to/iamcymentho/race-condition-ahl</guid>
      <description>&lt;p&gt;𝐒𝐨𝐟𝐢𝐚 𝐢𝐬 𝐢𝐧 𝐌𝐚𝐝𝐫𝐢𝐝. 𝐉𝐚𝐦𝐞𝐬 𝐢𝐬 𝐢𝐧 𝐓𝐨𝐫𝐨𝐧𝐭𝐨.&lt;/p&gt;

&lt;p&gt;Both find the same small hotel in Lisbon for the same weekend. Both see “Only 1 room left!”&lt;/p&gt;

&lt;p&gt;At exactly the same second, both tap “Book now.”&lt;/p&gt;

&lt;p&gt;Who gets the room?&lt;/p&gt;

&lt;p&gt;In a badly built system, both do. Two confirmations. One room. One very awkward morning at the front desk.&lt;/p&gt;

&lt;p&gt;Engineers call this a race condition: two operations each make a decision on information that’s about to become wrong.&lt;/p&gt;

&lt;p&gt;Exactly the same scenario plays out in  payments, just with money instead of rooms. Two withdrawals, one balance, same moment.&lt;/p&gt;

&lt;p&gt;Which of the five fixes have you used in production?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg1bkn35djcwfvmd8ecr1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg1bkn35djcwfvmd8ecr1.png" alt="race condition" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>softwaredevelopment</category>
      <category>software</category>
      <category>techtalks</category>
      <category>programming</category>
    </item>
    <item>
      <title>THE 24 - HOUR ACCOUNT RULE IN TECH</title>
      <dc:creator>Odumosu Matthew</dc:creator>
      <pubDate>Thu, 29 Oct 2026 10:52:19 +0000</pubDate>
      <link>https://dev.to/iamcymentho/the-24-hour-account-rule-in-tech-1f00</link>
      <guid>https://dev.to/iamcymentho/the-24-hour-account-rule-in-tech-1f00</guid>
      <description>&lt;p&gt;𝐓𝐨𝐥𝐮 𝐨𝐩𝐞𝐧𝐞𝐝 𝐚 𝐧𝐞𝐰 𝐚𝐜𝐜𝐨𝐮𝐧𝐭 𝐨𝐧 𝐌𝐨𝐧𝐝𝐚𝐲 𝐦𝐨𝐫𝐧𝐢𝐧𝐠.&lt;/p&gt;

&lt;p&gt;By afternoon, a client paid her for a big job. The alert came in. Joy.&lt;/p&gt;

&lt;p&gt;Then she tried to pay her supplier. “Limit exceeded.”&lt;/p&gt;

&lt;p&gt;The money was right there. So why couldn’t she move it?&lt;/p&gt;

&lt;p&gt;Because brand new accounts are a fraudster’s favourite getaway car: receive stolen money within hours, move it on before the victim even reports it.&lt;/p&gt;

&lt;p&gt;So for the first 24 hours, the rule is simple. Money can come in. Large amounts can’t leave yet.&lt;/p&gt;

&lt;p&gt;The customer still gets paid. The business doesn’t lose a customer. And stolen money has nowhere to run.&lt;/p&gt;

&lt;p&gt;Good engineering doesn’t always say no. Sometimes it just says “not yet.”&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq048h0e34yvdek3f6fwo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq048h0e34yvdek3f6fwo.png" alt="message queues / message buses " width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>software</category>
      <category>backend</category>
      <category>programming</category>
    </item>
    <item>
      <title>Why Records Are the Better Choice for Clean, Modern, and Principle-Driven .NET Development</title>
      <dc:creator>Odumosu Matthew</dc:creator>
      <pubDate>Sat, 18 Jul 2026 10:48:20 +0000</pubDate>
      <link>https://dev.to/iamcymentho/why-records-are-the-better-choice-for-clean-modern-and-principle-driven-net-development-2mna</link>
      <guid>https://dev.to/iamcymentho/why-records-are-the-better-choice-for-clean-modern-and-principle-driven-net-development-2mna</guid>
      <description>&lt;p&gt;If you've been writing C# for more than a few years, you've written thousands of DTOs. Request models, response models, event payloads, integration contracts — the connective tissue of every distributed system. And if you're like most of us, you've written nearly all of them as classes with &lt;code&gt;{ get; set; }&lt;/code&gt; properties, because that's what we've done since .NET Framework 2.0.&lt;/p&gt;

&lt;p&gt;Here's the uncomfortable truth: &lt;strong&gt;a mutable class is the wrong data structure for a DTO&lt;/strong&gt;, and it has been since C# 9 gave us a better one. Not "stylistically wrong." Wrong in ways that show up as production incidents — duplicate payments, corrupted audit trails, and race conditions in async pipelines.&lt;/p&gt;

&lt;p&gt;This article makes the case from first principles, with examples drawn from banking and fintech systems — payment processing, reconciliation, maker-checker workflows — because that's where the cost of getting this wrong is measured in money, not just bugs. Whether you're a junior engineer writing your first API contract or a solutions architect reviewing a payment platform design, the goal is the same: understand &lt;em&gt;why&lt;/em&gt; records exist, not just &lt;em&gt;how&lt;/em&gt; to type them.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚚 What Are DTOs in Practice?
&lt;/h2&gt;

&lt;p&gt;Imagine you're building a ride-sharing app like Uber or Bolt. When the frontend requests a list of nearby drivers, you return a &lt;code&gt;DriverDto&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;142&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Linda"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.9&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In C#, you might write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DriverDto&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;Rating&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works — but it's bloated, mutable, and doesn't reflect the true intent of a DTO: &lt;strong&gt;a simple, structured data carrier&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now zoom out from ride-sharing to where DTOs really earn their keep: system boundaries in enterprise software. In a payments platform, DTOs are everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;inbound webhook payload&lt;/strong&gt; from a card scheme or a partner bank notifying you of a settlement.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;API response&lt;/strong&gt; your mobile app receives when a customer checks their balance.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;Kafka event&lt;/strong&gt; published when a transfer moves from &lt;code&gt;PENDING&lt;/code&gt; to &lt;code&gt;SETTLED&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;reconciliation record&lt;/strong&gt; you compare against the processor's end-of-day file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these is a &lt;em&gt;contract&lt;/em&gt;. It represents a fact — "this transaction, with this reference, for this amount, at this time" — communicated across a boundary. And here is the key insight the whole article rests on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A DTO is a snapshot of data at a point in time. Snapshots don't change. So why are we modeling them with a type whose defining feature is that it changes?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A junior engineer can think of it this way: a DTO is like a &lt;strong&gt;printed bank statement&lt;/strong&gt;. Once it's printed, nobody should be able to grab a pen and change the numbers. If the numbers need to be different, you print a &lt;em&gt;new&lt;/em&gt; statement. Classes with &lt;code&gt;set;&lt;/code&gt; accessors hand everyone a pen. Records take the pen away.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔍 The Real Problems with Class-Based DTOs
&lt;/h2&gt;

&lt;p&gt;Let's be precise about what's wrong, because "bloated" alone wouldn't justify changing a decade of habit. There are four concrete failures.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Mutability is a liability at boundaries
&lt;/h3&gt;

&lt;p&gt;Consider a simplified inbound payment notification in a fintech platform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PaymentNotificationDto&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;TransactionRef&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;SenderAccount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This object will pass through validation, enrichment, fraud screening, ledger posting, and notification services — often across &lt;code&gt;async&lt;/code&gt; hops and sometimes across threads. Any one of those stages can &lt;em&gt;silently&lt;/em&gt; mutate it. A well-meaning teammate writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// "Normalizing" inside the fraud check — seems harmless&lt;/span&gt;
&lt;span class="n"&gt;notification&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;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&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="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the amount that reaches your ledger is not the amount you received from the partner bank. When reconciliation runs at end of day and finds a ₦0.005 discrepancy across two million transactions, you'll spend a very unpleasant week finding this line. The compiler could have prevented it. With a mutable class, it can't.&lt;/p&gt;

&lt;p&gt;In banking, this isn't hypothetical. &lt;strong&gt;The single most valuable property of financial data in flight is that it cannot be tampered with between the boundary and the ledger.&lt;/strong&gt; Mutability is an open invitation to tampering — usually accidental, occasionally worse.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Reference equality is the wrong equality
&lt;/h3&gt;

&lt;p&gt;Two DTOs carrying identical data are, for every practical purpose, &lt;em&gt;the same fact&lt;/em&gt;. But classes compare by reference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;PaymentNotificationDto&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;TransactionRef&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"TXN-8842"&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="m"&gt;50_000m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"NGN"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;PaymentNotificationDto&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;TransactionRef&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"TXN-8842"&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="m"&gt;50_000m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"NGN"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&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="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why does this matter? &lt;strong&gt;Idempotency&lt;/strong&gt; — the backbone of every payment system. Partner banks retry webhooks. Kafka delivers at-least-once. Mobile apps resubmit on timeout. Your system must recognize "I've seen this exact fact before" cheaply and correctly. With classes, you either hand-write &lt;code&gt;Equals&lt;/code&gt;/&lt;code&gt;GetHashCode&lt;/code&gt; (and forget to update them when a property is added — a classic source of subtle dedup bugs), pull in a library, or compare serialized JSON like a barbarian.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The boilerplate hides the contract
&lt;/h3&gt;

&lt;p&gt;A class-based DTO with proper equality, immutability via constructor injection, and a useful &lt;code&gt;ToString&lt;/code&gt; for structured logging is 40–60 lines. The &lt;em&gt;contract&lt;/em&gt; — the thing an engineer actually needs to read — is 5 lines. The other 90% is ceremony that must be maintained, reviewed, and kept in sync. In an enterprise codebase with 400 DTOs, that's tens of thousands of lines of pure noise, every line a place for a bug to hide.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Nothing communicates intent
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;public class PaymentNotificationDto&lt;/code&gt; tells a reader nothing about how the type is meant to be used. Is it safe to cache? Safe to share across threads? Is mutation expected? The type system is silent, so the answer lives in tribal knowledge and code review vigilance — the two most expensive and least reliable enforcement mechanisms in software.&lt;/p&gt;




&lt;h2&gt;
  
  
  ✨ Enter Records: The Type That Says What It Means
&lt;/h2&gt;

&lt;p&gt;The same DTO as a record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;PaymentNotificationDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;TransactionRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;SenderAccount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five lines. And the compiler generates, for free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Init-only properties&lt;/strong&gt; — the object is frozen after construction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Value-based &lt;code&gt;Equals&lt;/code&gt; and &lt;code&gt;GetHashCode&lt;/code&gt;&lt;/strong&gt; — two records with the same data are equal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A readable &lt;code&gt;ToString&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;PaymentNotificationDto { TransactionRef = TXN-8842, Amount = 50000, ... }&lt;/code&gt;, which is a quiet superpower for structured logging in Serilog.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deconstruction&lt;/strong&gt; — &lt;code&gt;var (txnRef, amount, currency, _, _) = dto;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;with&lt;/code&gt; expressions&lt;/strong&gt; — non-destructive "mutation" that produces a new copy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our idempotency check now just works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PaymentNotificationDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"TXN-8842"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;50_000m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"NGN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"0123456789"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PaymentNotificationDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"TXN-8842"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;50_000m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"NGN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"0123456789"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&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="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// true — same fact, recognized as such&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the tampering bug from earlier? It's now a &lt;strong&gt;compile-time error&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;notification&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;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&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="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// CS8852: Init-only property can only be assigned in an object initializer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pen has been taken away. If a stage in your pipeline genuinely needs a normalized amount, it must be explicit about creating a new fact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;normalized&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt; &lt;span class="k"&gt;with&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;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&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="m"&gt;2&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;Notice what happened to the &lt;em&gt;semantics&lt;/em&gt;: mutation-in-place (invisible, untraceable) became transformation (visible, reviewable, loggable). In a regulated environment where auditors ask "show me every place this value could have changed," that difference is not cosmetic.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏦 Records in the Wild: Enterprise Fintech Scenarios
&lt;/h2&gt;

&lt;p&gt;Abstract arguments are cheap. Let's walk through four scenarios that recur in nearly every banking or fintech platform and see how records change the engineering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 1: Idempotent webhook ingestion
&lt;/h3&gt;

&lt;p&gt;Your platform receives settlement notifications from multiple partner banks. Networks are unreliable; partners retry aggressively. A duplicate posting means a customer gets credited twice — a direct financial loss.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;SettlementNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ProviderRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;SessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;BeneficiaryAccount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;SettledAt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because records give you correct &lt;code&gt;GetHashCode&lt;/code&gt; for free, deduplication within a processing window becomes trivially correct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In-memory dedup for a processing window — HashSet just works&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;HashSet&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SettlementNotification&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_seenInWindow&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;HandleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SettlementNotification&lt;/span&gt; &lt;span class="n"&gt;notification&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="n"&gt;_seenInWindow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogWarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Duplicate settlement ignored: {Notification}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// structured log includes full payload via generated ToString&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PostAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&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;With a class, &lt;code&gt;HashSet.Add&lt;/code&gt; would happily accept ten copies of the same notification, because each is a distinct reference. You'd need hand-rolled equality — and every hand-rolled &lt;code&gt;GetHashCode&lt;/code&gt; is a future bug when someone adds &lt;code&gt;NarrationText&lt;/code&gt; to the DTO and forgets to update the hash.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(In production you'd also have a durable idempotency store keyed on &lt;code&gt;ProviderRef&lt;/code&gt; + &lt;code&gt;SessionId&lt;/code&gt; — value equality complements that; it doesn't replace it. More on this in the limitations section.)&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 2: Event-driven architecture on Kafka
&lt;/h3&gt;

&lt;p&gt;In an event-driven core banking system, events are &lt;strong&gt;facts about the past&lt;/strong&gt;: &lt;code&gt;TransferInitiated&lt;/code&gt;, &lt;code&gt;FraudCheckPassed&lt;/code&gt;, &lt;code&gt;TransferSettled&lt;/code&gt;. The first rule of event sourcing is that facts are immutable — you never rewrite history; you append new events.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;TransferInitiated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;TransferId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;SourceAccount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;DestinationAccount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;InitiatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IDomainEvent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a consumer replays this event during recovery or a new projection is built from the event log, the guarantee that &lt;em&gt;no handler anywhere could have mutated the event&lt;/em&gt; is what makes replay deterministic. With mutable class events, one handler that "fixes up" a field poisons every downstream consumer in the same process — and you'll only discover it when a rebuilt projection disagrees with the ledger.&lt;/p&gt;

&lt;p&gt;Records make the architectural rule ("events are immutable facts") a &lt;strong&gt;compiler-enforced invariant&lt;/strong&gt; instead of a wiki page nobody reads.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 3: Maker-checker workflows
&lt;/h3&gt;

&lt;p&gt;Regulated back-office operations — bulk account onboarding, limit changes, manual reversals — follow maker-checker: one officer initiates, another approves. The state transition must be explicit and auditable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ReversalRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;RequestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;TransactionRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;MakerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;CheckerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ApprovalStatus&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;CreatedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;DecidedAt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;with&lt;/code&gt; expression models the transition beautifully — the original request is preserved untouched, and approval produces a &lt;em&gt;new&lt;/em&gt; state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;ReversalRequest&lt;/span&gt; &lt;span class="nf"&gt;Approve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReversalRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;checkerId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;CheckerId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;checkerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ApprovalStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Approved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;DecidedAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For audit purposes you can log both objects — before and after — and the generated &lt;code&gt;ToString&lt;/code&gt; gives the auditor a complete, readable diff. Try producing that with a mutated class: the "before" state is gone; you overwrote it.&lt;/p&gt;

&lt;p&gt;This is the &lt;strong&gt;event-sourcing mindset in miniature&lt;/strong&gt;: state changes are new values, not edits. Records make that the path of least resistance.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 4: End-of-day reconciliation
&lt;/h3&gt;

&lt;p&gt;Reconciliation is fundamentally a set-comparison problem: &lt;em&gt;our&lt;/em&gt; view of the day's transactions versus &lt;em&gt;their&lt;/em&gt; file. Value equality turns this from a bespoke matching engine into LINQ:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ReconRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DateOnly&lt;/span&gt; &lt;span class="n"&gt;ValueDate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;internalRows&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetRowsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;providerRows&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ParseProviderFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;missingOnOurSide&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;providerRows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Except&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;internalRows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// they have it, we don't&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;missingOnTheirSide&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;internalRows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Except&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;providerRows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// we have it, they don't&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Except&lt;/code&gt;, &lt;code&gt;Intersect&lt;/code&gt;, &lt;code&gt;Distinct&lt;/code&gt;, &lt;code&gt;GroupBy&lt;/code&gt; — the entire LINQ set-algebra toolkit works &lt;em&gt;correctly by default&lt;/em&gt; on records because they carry proper value semantics. With classes you'd be writing &lt;code&gt;IEqualityComparer&amp;lt;T&amp;gt;&lt;/code&gt; implementations, one per comparison, each a maintenance liability.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧭 How Records Fulfill Software Engineering Principles
&lt;/h2&gt;

&lt;p&gt;This is where the article stops being about syntax and starts being about design. Records aren't just terser — they align data contracts with the principles we already claim to follow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Single Responsibility Principle (SRP)
&lt;/h3&gt;

&lt;p&gt;A DTO has one job: carry data across a boundary. A mutable class quietly takes on a second job — being a &lt;em&gt;workspace&lt;/em&gt; that pipeline stages scribble on. That second job is precisely what causes the tampering bugs above. Records structurally reject the second job. The type does one thing, and the compiler enforces it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Junior-friendly analogy:&lt;/strong&gt; a DTO should be an envelope, not a whiteboard. Records seal the envelope.&lt;/p&gt;

&lt;h3&gt;
  
  
  Open/Closed Principle (OCP)
&lt;/h3&gt;

&lt;p&gt;"Open for extension, closed for modification" is usually discussed for behavior, but it applies to data too. The &lt;code&gt;with&lt;/code&gt; expression is OCP for values: you &lt;em&gt;extend&lt;/em&gt; an existing fact into a new one without &lt;em&gt;modifying&lt;/em&gt; the original.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;enriched&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Narration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ResolveNarration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every stage of an enrichment pipeline receives an input it cannot break for other consumers and emits a new output. Pipelines composed this way are trivially parallelizable and testable, because no stage has side effects on shared state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Liskov Substitution Principle (LSP)
&lt;/h3&gt;

&lt;p&gt;Records support inheritance (&lt;code&gt;public record ChildDto : ParentDto&lt;/code&gt;) and — unlike hand-written class equality — the generated equality is &lt;em&gt;hierarchy-aware&lt;/em&gt;. A record's &lt;code&gt;Equals&lt;/code&gt; checks the runtime type via a synthesized &lt;code&gt;EqualityContract&lt;/code&gt;, so a &lt;code&gt;ParentDto&lt;/code&gt; and a &lt;code&gt;ChildDto&lt;/code&gt; with identical property values are correctly &lt;em&gt;not&lt;/em&gt; equal. Hand-rolled class equality gets this wrong constantly (the classic symmetric-equality bug in &lt;code&gt;Equals&lt;/code&gt; overrides). Records give you LSP-safe equality out of the box.&lt;/p&gt;

&lt;p&gt;That said — and this is a genuine senior-level guideline — &lt;strong&gt;prefer flat records and composition for DTOs&lt;/strong&gt;. Deep DTO inheritance hierarchies are usually a design smell regardless of the type mechanism. Records just fail more safely when you do it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Interface Segregation Principle (ISP)
&lt;/h3&gt;

&lt;p&gt;Positional records make fat contracts &lt;em&gt;visible&lt;/em&gt;. A 22-property class DTO looks like any other file. A 22-parameter positional record looks alarming in the constructor — and that discomfort is a feature. It nudges you toward segregating the contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;BeneficiaryDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;BankCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;TransferAmount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;TransferRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TransferAmount&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;BeneficiaryDetails&lt;/span&gt; &lt;span class="n"&gt;Beneficiary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nested records compose value equality automatically: two &lt;code&gt;TransferRequest&lt;/code&gt;s are equal if their nested records are equal, all the way down. You get well-factored, comparable aggregate contracts with zero extra code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dependency Inversion Principle (DIP)
&lt;/h3&gt;

&lt;p&gt;DIP is about depending on stable abstractions. In distributed systems, the &lt;em&gt;most&lt;/em&gt; stable abstraction between two services is an immutable contract. A record shared in a contracts package is a promise: "this shape, these semantics, no hidden mutation." Consumers can cache it, memoize on it, and key dictionaries by it, all safely — because the contract's stability is enforced, not merely documented.&lt;/p&gt;

&lt;h3&gt;
  
  
  YAGNI (You Aren't Gonna Need It)
&lt;/h3&gt;

&lt;p&gt;Every &lt;code&gt;set;&lt;/code&gt; accessor on a DTO is speculative capability: "someone &lt;em&gt;might&lt;/em&gt; need to mutate this someday." YAGNI says: don't build it until they do. Records are YAGNI applied to data — you ship exactly the capability a data carrier needs (construction, reading, comparison, copying) and nothing more. When a genuine mutation need appears, &lt;code&gt;with&lt;/code&gt; handles it explicitly, and the code review sees it.&lt;/p&gt;

&lt;h3&gt;
  
  
  DRY (Don't Repeat Yourself)
&lt;/h3&gt;

&lt;p&gt;The 40 lines of &lt;code&gt;Equals&lt;/code&gt;/&lt;code&gt;GetHashCode&lt;/code&gt;/&lt;code&gt;ToString&lt;/code&gt;/copy-constructor boilerplate per DTO is repetition at industrial scale — the same &lt;em&gt;pattern&lt;/em&gt; re-implemented hundreds of times, each an opportunity to diverge. Records move that pattern into the compiler, where it's written once and correct forever. This is DRY in its strongest form: not just avoiding copy-paste, but eliminating the category of code entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Principle of Least Astonishment
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;a == b&lt;/code&gt; returning &lt;code&gt;false&lt;/code&gt; for two identical payment notifications is astonishing to every engineer who hasn't been burned by it yet. Records make equality behave the way domain intuition expects for data: same values, same fact, equal. Fewer surprises, fewer 2 a.m. debugging sessions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fail Fast
&lt;/h3&gt;

&lt;p&gt;Positional records force construction through a single point — the primary constructor — which is exactly where validation belongs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;TransferAmount&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;TransferAmount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;currency&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="k"&gt;value&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="m"&gt;0&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="nf"&gt;ArgumentOutOfRangeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"Amount must be positive."&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="n"&gt;currency&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&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="nf"&gt;ArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ISO 4217 code required"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currency&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="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;Currency&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the object can't be mutated afterward, &lt;strong&gt;valid at construction means valid forever&lt;/strong&gt;. Compare that with a mutable class, where validity is a moving target you must re-verify at every use site.&lt;/p&gt;

&lt;h3&gt;
  
  
  DDD: Records &lt;em&gt;are&lt;/em&gt; Value Objects
&lt;/h3&gt;

&lt;p&gt;If you practice Domain-Driven Design, you've been hand-writing value objects — &lt;code&gt;Money&lt;/code&gt;, &lt;code&gt;AccountNumber&lt;/code&gt;, &lt;code&gt;Bvn&lt;/code&gt;, &lt;code&gt;IdempotencyKey&lt;/code&gt; — for years, with all the equality ceremony that entails. Records are the value-object pattern promoted to a language feature. Eric Evans's definition — "an object that describes some characteristic... with no conceptual identity, compared by value" — is a description of a C# record. This is the deepest reason records feel &lt;em&gt;right&lt;/em&gt; for DTOs: DTOs were always value objects; we just lacked the language support to say so.&lt;/p&gt;

&lt;h3&gt;
  
  
  CQS/CQRS alignment
&lt;/h3&gt;

&lt;p&gt;In a CQRS system (MediatR users, this is you), queries return read models and commands carry intent. Both are one-way messages — facts flowing in one direction. Immutable records match that semantics exactly: a &lt;code&gt;GetAccountBalanceQuery&lt;/code&gt; or its &lt;code&gt;AccountBalanceResult&lt;/code&gt; has no business being mutated after creation. Making commands, queries, and results records turns your MediatR pipeline into a chain of pure-ish transformations, which is dramatically easier to reason about, test, and parallelize.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚡ Performance: What Records Actually Buy You (and What They Don't)
&lt;/h2&gt;

&lt;p&gt;Senior engineers should be suspicious of performance claims, so let's be honest and specific.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where records genuinely help
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Correct, fast equality without reflection.&lt;/strong&gt; The compiler-generated &lt;code&gt;Equals&lt;/code&gt; and &lt;code&gt;GetHashCode&lt;/code&gt; are direct field comparisons — no reflection, no boxing of the comparands, no serialization tricks. Teams that fall back to comparing DTOs via &lt;code&gt;JsonSerializer.Serialize(a) == JsonSerializer.Serialize(b)&lt;/code&gt; (it happens more than anyone admits) are paying two full serializations plus a string comparison per check. Record equality is orders of magnitude cheaper and allocation-free for the comparison itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Dictionary and set performance by construction.&lt;/strong&gt; Because &lt;code&gt;GetHashCode&lt;/code&gt; is generated correctly, records are first-class citizens as keys in &lt;code&gt;Dictionary&amp;lt;TKey,TValue&amp;gt;&lt;/code&gt;, &lt;code&gt;HashSet&amp;lt;T&amp;gt;&lt;/code&gt;, &lt;code&gt;ConcurrentDictionary&lt;/code&gt;, and memoization caches. In a fraud-screening service that caches risk scores per &lt;code&gt;(Account, Channel, AmountBand)&lt;/code&gt; tuple, a record key gives you O(1) lookups with hash distribution you didn't have to think about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Lock-free sharing under concurrency.&lt;/strong&gt; This is the big one for throughput. An immutable object can be read by any number of threads with &lt;strong&gt;zero synchronization&lt;/strong&gt;. In a high-TPS inbound payments pipeline — parallel Kafka consumers, &lt;code&gt;Parallel.ForEachAsync&lt;/code&gt; enrichment, fan-out to multiple downstream handlers — mutable shared DTOs force you into locks, defensive copies, or prayer. Immutable records eliminate the entire category: no locks, no torn reads, no defensive &lt;code&gt;Clone()&lt;/code&gt; calls scattered through the code. The performance win isn't the object itself; it's all the synchronization machinery you get to delete.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Defensive copying disappears.&lt;/strong&gt; Mature codebases with mutable DTOs accrue defensive copies — "clone it before passing it to that service, just in case." Each copy is allocation and CPU spent to compensate for mutability. With records, passing a reference &lt;em&gt;is&lt;/em&gt; safe sharing. The copies you no longer make are a real, measurable saving in hot paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. &lt;code&gt;record struct&lt;/code&gt; for the truly hot path.&lt;/strong&gt; Since C# 10, &lt;code&gt;readonly record struct&lt;/code&gt; gives you value semantics with stack allocation — zero GC pressure for small, short-lived carriers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;struct&lt;/span&gt; &lt;span class="nf"&gt;FxRate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Pair&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;AsOf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a pricing engine evaluating thousands of FX quotes per second, this avoids heap allocation entirely for the quote objects, which shows up directly in Gen0 collection frequency. The guidance mirrors regular structs: keep them small (≈16 bytes as a rule of thumb, pragmatically a few fields), and prefer &lt;code&gt;readonly record struct&lt;/code&gt; to prevent the defensive-copy costs of non-readonly structs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where you must be honest about costs
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;with&lt;/code&gt; expressions allocate.&lt;/strong&gt; Every &lt;code&gt;with&lt;/code&gt; produces a full new instance. In a pipeline that transforms a record five times, that's five allocations where a mutable class would have zero. For typical API/webhook DTOs (short-lived, Gen0-collected) this is noise — Gen0 collection of small objects is extremely cheap, and correctness wins. But if profiling shows a genuinely hot loop churning large records through repeated &lt;code&gt;with&lt;/code&gt; copies, that's a legitimate case for a builder or a mutable working type &lt;em&gt;inside&lt;/em&gt; the tight scope, converting to a record at the boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Record equality is O(n) in fields.&lt;/strong&gt; Comparing two 30-property records field-by-field costs more than a reference check. If you're doing millions of comparisons and references would suffice (i.e., you &lt;em&gt;know&lt;/em&gt; it's the same instance), &lt;code&gt;ReferenceEquals&lt;/code&gt; still exists. This is rarely a real problem, but it's worth knowing the shape of the cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The performance argument, summarized for an architect:&lt;/strong&gt; records are not "faster objects." They are objects whose guarantees let you &lt;em&gt;delete slower code&lt;/em&gt; — locks, defensive copies, hand-rolled comparers, serialization-based equality — and whose struct variant removes allocation where it matters. The system gets faster because the design gets simpler.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚠️ Limitations and Sharp Edges (Read This Before You Migrate)
&lt;/h2&gt;

&lt;p&gt;Records are the right default for DTOs. They are not the right choice for everything, and they have gotchas that will bite teams who treat them as "classes but shorter."&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Do not use records for EF Core entities
&lt;/h3&gt;

&lt;p&gt;This is the most common misapplication. ORM entities are the opposite of DTOs: they have identity (primary key), a lifecycle, and &lt;em&gt;mutation is the mechanism&lt;/em&gt; — EF Core's change tracker works by detecting property modifications on tracked instances. Records fight this at every turn: value equality confuses identity-based logic and &lt;code&gt;DbSet&lt;/code&gt; semantics, &lt;code&gt;with&lt;/code&gt; copies break change tracking (the copy is a &lt;em&gt;different, untracked&lt;/em&gt; object), and lazy-loading proxies don't play well with record equality. Keep entities as classes. Map them to record DTOs at the query boundary — which is exactly the layering clean architecture prescribes anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Collection properties break value equality
&lt;/h3&gt;

&lt;p&gt;The gotcha that surprises everyone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;BatchDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;BatchId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TransactionRefs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;BatchDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"B-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"T1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"T2"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;BatchDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"B-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"T1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"T2"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&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="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// FALSE — List&amp;lt;T&amp;gt; compares by reference&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generated equality compares each member with its own &lt;code&gt;Equals&lt;/code&gt; — and &lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt; equality is reference equality. So a record containing a collection silently loses the value-equality guarantee you adopted records for. In a reconciliation or dedup context, this is a &lt;em&gt;correctness&lt;/em&gt; bug, not a style issue. Mitigations: use &lt;code&gt;ImmutableArray&amp;lt;T&amp;gt;&lt;/code&gt;/&lt;code&gt;ImmutableList&amp;lt;T&amp;gt;&lt;/code&gt; with a custom &lt;code&gt;Equals&lt;/code&gt; override for that member, model the data to avoid collections in equality-sensitive records, or explicitly override &lt;code&gt;Equals&lt;/code&gt;/&lt;code&gt;GetHashCode&lt;/code&gt; with sequence comparison. Whatever you choose, make it a documented team convention.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;code&gt;with&lt;/code&gt; is a shallow copy
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;with&lt;/code&gt; copies field values. If a field is a reference to a mutable object (that &lt;code&gt;List&amp;lt;string&amp;gt;&lt;/code&gt; again), the original and the copy &lt;strong&gt;share it&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;copy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;original&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;BatchId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"B-02"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="n"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TransactionRefs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"T3"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// also visible through original — same list!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The record's own scalar fields are safe; nested mutable state is not. The clean fix is to make records immutable &lt;em&gt;all the way down&lt;/em&gt; — nested records, immutable collections — so shallow copy and deep copy become indistinguishable. Half-immutable records are arguably more dangerous than honest mutable classes, because they &lt;em&gt;look&lt;/em&gt; safe.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Serializer and framework friction
&lt;/h3&gt;

&lt;p&gt;Modern &lt;code&gt;System.Text.Json&lt;/code&gt; and current Newtonsoft.Json handle records and constructor binding well, but the wider ecosystem varies: some mappers, legacy binders, and reflection-based tools still assume a parameterless constructor and settable properties. AutoMapper needs &lt;code&gt;ForCtorParam&lt;/code&gt; configuration for positional records. If a tool forces you to add &lt;code&gt;set;&lt;/code&gt; accessors back onto a record to make it work, you've kept the syntax and lost the point — reconsider the tool or use &lt;code&gt;init&lt;/code&gt; with object-initializer-style records.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Inheritance equality can surprise you
&lt;/h3&gt;

&lt;p&gt;Record inheritance is safer than hand-rolled class equality, but the &lt;code&gt;EqualityContract&lt;/code&gt; behavior — a base-typed variable holding a derived instance never equals a base instance with identical values — surprises engineers who expected structural comparison across the hierarchy. Combined with the general smell of deep DTO hierarchies, the pragmatic rule stands: &lt;strong&gt;flat records, composition over inheritance.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Wide records get unwieldy
&lt;/h3&gt;

&lt;p&gt;A positional record with 20+ parameters is miserable to construct and brittle to reorder (positional args have no names at most call sites). For wide contracts, use nominal records with &lt;code&gt;init&lt;/code&gt; properties and object initializers, or — better — apply ISP and decompose the contract, as shown earlier.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Migration is not free in a brownfield
&lt;/h3&gt;

&lt;p&gt;Introducing records into a 7-year-old codebase means auditing every place that mutates DTOs today. Some of those mutations are bugs you'll be glad to find; others are load-bearing hacks. Migrate boundary-first: new contracts, new events, and new endpoints as records; refactor legacy DTOs opportunistically when you're already touching their pipelines. A big-bang conversion of 400 DTOs is a two-week PR nobody can review.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧰 When a Class Is Still the Right Answer
&lt;/h2&gt;

&lt;p&gt;To be a trustworthy advocate for records, you have to know their boundary. Reach for a class when the type:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Has identity and a lifecycle&lt;/strong&gt; — ORM entities, aggregate roots, anything where "same object over time despite changing state" is the point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is fundamentally behavioral&lt;/strong&gt; — services, handlers, strategies. Records model &lt;em&gt;data&lt;/em&gt;; classes model &lt;em&gt;actors&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Requires managed mutable state&lt;/strong&gt; — builders, accumulators, caches, connection pools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Must integrate with mutation-assuming frameworks&lt;/strong&gt; you can't replace — some legacy binders, certain UI frameworks, older SOAP/WCF tooling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is a large object copied in a proven hot loop&lt;/strong&gt; — where &lt;code&gt;with&lt;/code&gt;-copy allocation genuinely dominates a profile (measure first; it almost never does).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mental model to give a junior engineer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Records are for facts. Classes are for actors.&lt;/strong&gt; A payment notification is a fact. The service that processes it is an actor. Facts don't change; actors do things.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  📋 A Practical Decision Guide
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type of thing&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API request/response contracts&lt;/td&gt;
&lt;td&gt;&lt;code&gt;record&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kafka / message-bus events&lt;/td&gt;
&lt;td&gt;&lt;code&gt;record&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MediatR commands, queries, results&lt;/td&gt;
&lt;td&gt;&lt;code&gt;record&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DDD value objects (&lt;code&gt;Money&lt;/code&gt;, &lt;code&gt;AccountNumber&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;record&lt;/code&gt; (with constructor validation)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small hot-path carriers (FX ticks, coordinates)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;readonly record struct&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EF Core entities / aggregate roots&lt;/td&gt;
&lt;td&gt;&lt;code&gt;class&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Services, handlers, middleware&lt;/td&gt;
&lt;td&gt;&lt;code&gt;class&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Builders, accumulators, mutable caches&lt;/td&gt;
&lt;td&gt;&lt;code&gt;class&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Configuration bound at startup (&lt;code&gt;IOptions&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;record&lt;/code&gt; with &lt;code&gt;init&lt;/code&gt; properties&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  🏁 Conclusion: This Was Never About Syntax
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;record&lt;/code&gt; keyword saves you 40 lines per DTO, and that alone would justify using it. But the real argument is architectural:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mutable class DTOs make correctness a matter of discipline. Records make it a matter of compilation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In consumer software, the cost of a mutated DTO is a weird bug. In banking and fintech, it's a duplicated settlement, a reconciliation break, a corrupted audit trail — failures with monetary and regulatory consequences. Every principle we invoked — SRP, OCP, YAGNI, DRY, fail-fast, least astonishment, DDD value semantics — converges on the same conclusion: data crossing a boundary should be an immutable, value-comparable fact, and C# now has a first-class type for exactly that.&lt;/p&gt;

&lt;p&gt;So the next time you type &lt;code&gt;public class SomethingDto { get; set; }&lt;/code&gt;, stop. Ask yourself: is this a fact or an actor? If it's a fact — and a DTO always is — reach for the record, take the pen away, and let the compiler enforce what your code reviews have been trying to enforce for years.&lt;/p&gt;

&lt;p&gt;Your reconciliation team will thank you. Eventually. Silently. By never having to call you at 2 a.m. about a ₦0.005 discrepancy.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you found this useful, drop a comment with the worst mutable-DTO bug you've debugged in production — the war stories are half the education.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LinkedIn Account&lt;/code&gt; : &lt;a href="https://www.linkedin.com/in/matthew-odumosu/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;br&gt;
&lt;code&gt;Twitter Account&lt;/code&gt;: &lt;a href="https://twitter.com/iamcymentho" rel="noopener noreferrer"&gt;Twitter&lt;/a&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>csharp</category>
      <category>software</category>
      <category>ai</category>
    </item>
    <item>
      <title>🛑 Why Records Are the Better Choice for Clean, Modern, and Principle-Driven .NET Development</title>
      <dc:creator>Odumosu Matthew</dc:creator>
      <pubDate>Sat, 18 Jul 2026 10:28:57 +0000</pubDate>
      <link>https://dev.to/iamcymentho/why-records-are-the-better-choice-for-clean-modern-and-principle-driven-net-development-1b18</link>
      <guid>https://dev.to/iamcymentho/why-records-are-the-better-choice-for-clean-modern-and-principle-driven-net-development-1b18</guid>
      <description>&lt;p&gt;If you've been writing C# for more than a few years, you've written thousands of DTOs. Request models, response models, event payloads, integration contracts, the connective tissue of every distributed system. And if you're like most of us, you've written nearly all of them as classes with &lt;code&gt;{ get; set; }&lt;/code&gt; properties, because that's what we've done since .NET Framework 2.0.&lt;/p&gt;

&lt;p&gt;Here's the uncomfortable truth: &lt;strong&gt;a mutable class is the wrong data structure for a DTO&lt;/strong&gt;, and it has been since C# 9 gave us a better one. Not "stylistically wrong." Wrong in ways that show up as production incidents,  duplicate payments, corrupted audit trails, and race conditions in async pipelines.&lt;/p&gt;

&lt;p&gt;This article makes the case from first principles, with examples drawn from banking and fintech systems,  payment processing, reconciliation, maker-checker workflows,  because that's where the cost of getting this wrong is measured in money, not just bugs. Whether you're a junior engineer writing your first API contract or a solutions architect reviewing a payment platform design, the goal is the same: understand &lt;em&gt;why&lt;/em&gt; records exist, not just &lt;em&gt;how&lt;/em&gt; to type them.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚚 What Are DTOs in Practice?
&lt;/h2&gt;

&lt;p&gt;Imagine you're building a ride-sharing app like Uber or Bolt. When the frontend requests a list of nearby drivers, you return a &lt;code&gt;DriverDto&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;142&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Linda"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.9&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In C#, you might write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DriverDto&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;Rating&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works,  but it's bloated, mutable, and doesn't reflect the true intent of a DTO: &lt;strong&gt;a simple, structured data carrier&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now zoom out from ride-sharing to where DTOs really earn their keep: system boundaries in enterprise software. In a payments platform, DTOs are everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;inbound webhook payload&lt;/strong&gt; from a card scheme or a partner bank notifying you of a settlement.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;API response&lt;/strong&gt; your mobile app receives when a customer checks their balance.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;Kafka event&lt;/strong&gt; published when a transfer moves from &lt;code&gt;PENDING&lt;/code&gt; to &lt;code&gt;SETTLED&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;reconciliation record&lt;/strong&gt; you compare against the processor's end-of-day file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these is a &lt;em&gt;contract&lt;/em&gt;. It represents a fact — "this transaction, with this reference, for this amount, at this time" — communicated across a boundary. And here is the key insight the whole article rests on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A DTO is a snapshot of data at a point in time. Snapshots don't change. So why are we modeling them with a type whose defining feature is that it changes?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A junior engineer can think of it this way: a DTO is like a &lt;strong&gt;printed bank statement&lt;/strong&gt;. Once it's printed, nobody should be able to grab a pen and change the numbers. If the numbers need to be different, you print a &lt;em&gt;new&lt;/em&gt; statement. Classes with &lt;code&gt;set;&lt;/code&gt; accessors hand everyone a pen. Records take the pen away.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔍 The Real Problems with Class-Based DTOs
&lt;/h2&gt;

&lt;p&gt;Let's be precise about what's wrong, because "bloated" alone wouldn't justify changing a decade of habit. There are four concrete failures.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Mutability is a liability at boundaries
&lt;/h3&gt;

&lt;p&gt;Consider a simplified inbound payment notification in a fintech platform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PaymentNotificationDto&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;TransactionRef&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;SenderAccount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This object will pass through validation, enrichment, fraud screening, ledger posting, and notification services,  often across &lt;code&gt;async&lt;/code&gt; hops and sometimes across threads. Any one of those stages can &lt;em&gt;silently&lt;/em&gt; mutate it. A well-meaning teammate writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// "Normalizing" inside the fraud check — seems harmless&lt;/span&gt;
&lt;span class="n"&gt;notification&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;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&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="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the amount that reaches your ledger is not the amount you received from the partner bank. When reconciliation runs at end of day and finds a ₦0.005 discrepancy across two million transactions, you'll spend a very unpleasant week finding this line. The compiler could have prevented it. With a mutable class, it can't.&lt;/p&gt;

&lt;p&gt;In banking, this isn't hypothetical. &lt;strong&gt;The single most valuable property of financial data in flight is that it cannot be tampered with between the boundary and the ledger.&lt;/strong&gt; Mutability is an open invitation to tampering,  usually accidental, occasionally worse.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Reference equality is the wrong equality
&lt;/h3&gt;

&lt;p&gt;Two DTOs carrying identical data are, for every practical purpose, &lt;em&gt;the same fact&lt;/em&gt;. But classes compare by reference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;PaymentNotificationDto&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;TransactionRef&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"TXN-8842"&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="m"&gt;50_000m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"NGN"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;PaymentNotificationDto&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;TransactionRef&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"TXN-8842"&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="m"&gt;50_000m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"NGN"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&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="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why does this matter? &lt;strong&gt;Idempotency&lt;/strong&gt; the backbone of every payment system. Partner banks retry webhooks. Kafka delivers at-least-once. Mobile apps resubmit on timeout. Your system must recognize "I've seen this exact fact before" cheaply and correctly. With classes, you either hand-write &lt;code&gt;Equals&lt;/code&gt;/&lt;code&gt;GetHashCode&lt;/code&gt; (and forget to update them when a property is added — a classic source of subtle dedup bugs), pull in a library, or compare serialized JSON like a barbarian.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The boilerplate hides the contract
&lt;/h3&gt;

&lt;p&gt;A class-based DTO with proper equality, immutability via constructor injection, and a useful &lt;code&gt;ToString&lt;/code&gt; for structured logging is 40–60 lines. The &lt;em&gt;contract&lt;/em&gt; — the thing an engineer actually needs to read,  is 5 lines. The other 90% is ceremony that must be maintained, reviewed, and kept in sync. In an enterprise codebase with 400 DTOs, that's tens of thousands of lines of pure noise, every line a place for a bug to hide.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Nothing communicates intent
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;public class PaymentNotificationDto&lt;/code&gt; tells a reader nothing about how the type is meant to be used. Is it safe to cache? Safe to share across threads? Is mutation expected? The type system is silent, so the answer lives in tribal knowledge and code review vigilance — the two most expensive and least reliable enforcement mechanisms in software.&lt;/p&gt;




&lt;h2&gt;
  
  
  ✨ Enter Records: The Type That Says What It Means
&lt;/h2&gt;

&lt;p&gt;The same DTO as a record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;PaymentNotificationDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;TransactionRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;SenderAccount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five lines. And the compiler generates, for free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Init-only properties&lt;/strong&gt; - the object is frozen after construction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Value-based &lt;code&gt;Equals&lt;/code&gt; and &lt;code&gt;GetHashCode&lt;/code&gt;&lt;/strong&gt; - two records with the same data are equal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A readable &lt;code&gt;ToString&lt;/code&gt;&lt;/strong&gt; - &lt;code&gt;PaymentNotificationDto { TransactionRef = TXN-8842, Amount = 50000, ... }&lt;/code&gt;, which is a quiet superpower for structured logging in Serilog.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deconstruction&lt;/strong&gt; - &lt;code&gt;var (txnRef, amount, currency, _, _) = dto;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;with&lt;/code&gt; expressions&lt;/strong&gt; - non-destructive "mutation" that produces a new copy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our idempotency check now just works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PaymentNotificationDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"TXN-8842"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;50_000m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"NGN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"0123456789"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PaymentNotificationDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"TXN-8842"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;50_000m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"NGN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"0123456789"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&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="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// true — same fact, recognized as such&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the tampering bug from earlier? It's now a &lt;strong&gt;compile-time error&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;notification&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;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&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="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// CS8852: Init-only property can only be assigned in an object initializer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pen has been taken away. If a stage in your pipeline genuinely needs a normalized amount, it must be explicit about creating a new fact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;normalized&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt; &lt;span class="k"&gt;with&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;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&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="m"&gt;2&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;Notice what happened to the &lt;em&gt;semantics&lt;/em&gt;: mutation-in-place (invisible, untraceable) became transformation (visible, reviewable, loggable). In a regulated environment where auditors ask "show me every place this value could have changed," that difference is not cosmetic.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏦 Records in the Wild: Enterprise Fintech Scenarios
&lt;/h2&gt;

&lt;p&gt;Abstract arguments are cheap. Let's walk through four scenarios that recur in nearly every banking or fintech platform and see how records change the engineering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 1: Idempotent webhook ingestion
&lt;/h3&gt;

&lt;p&gt;Your platform receives settlement notifications from multiple partner banks. Networks are unreliable; partners retry aggressively. A duplicate posting means a customer gets credited twice — a direct financial loss.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;SettlementNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ProviderRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;SessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;BeneficiaryAccount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;SettledAt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because records give you correct &lt;code&gt;GetHashCode&lt;/code&gt; for free, deduplication within a processing window becomes trivially correct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In-memory dedup for a processing window — HashSet just works&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;HashSet&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SettlementNotification&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_seenInWindow&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;HandleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SettlementNotification&lt;/span&gt; &lt;span class="n"&gt;notification&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="n"&gt;_seenInWindow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogWarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Duplicate settlement ignored: {Notification}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// structured log includes full payload via generated ToString&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PostAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&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;With a class, &lt;code&gt;HashSet.Add&lt;/code&gt; would happily accept ten copies of the same notification, because each is a distinct reference. You'd need hand-rolled equality — and every hand-rolled &lt;code&gt;GetHashCode&lt;/code&gt; is a future bug when someone adds &lt;code&gt;NarrationText&lt;/code&gt; to the DTO and forgets to update the hash.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(In production you'd also have a durable idempotency store keyed on &lt;code&gt;ProviderRef&lt;/code&gt; + &lt;code&gt;SessionId&lt;/code&gt; — value equality complements that; it doesn't replace it. More on this in the limitations section.)&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 2: Event-driven architecture on Kafka
&lt;/h3&gt;

&lt;p&gt;In an event-driven core banking system, events are &lt;strong&gt;facts about the past&lt;/strong&gt;: &lt;code&gt;TransferInitiated&lt;/code&gt;, &lt;code&gt;FraudCheckPassed&lt;/code&gt;, &lt;code&gt;TransferSettled&lt;/code&gt;. The first rule of event sourcing is that facts are immutable — you never rewrite history; you append new events.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;TransferInitiated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;TransferId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;SourceAccount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;DestinationAccount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;InitiatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IDomainEvent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a consumer replays this event during recovery or a new projection is built from the event log, the guarantee that &lt;em&gt;no handler anywhere could have mutated the event&lt;/em&gt; is what makes replay deterministic. With mutable class events, one handler that "fixes up" a field poisons every downstream consumer in the same process — and you'll only discover it when a rebuilt projection disagrees with the ledger.&lt;/p&gt;

&lt;p&gt;Records make the architectural rule ("events are immutable facts") a &lt;strong&gt;compiler-enforced invariant&lt;/strong&gt; instead of a wiki page nobody reads.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 3: Maker-checker workflows
&lt;/h3&gt;

&lt;p&gt;Regulated back-office operations — bulk account onboarding, limit changes, manual reversals ,  follow maker-checker: one officer initiates, another approves. The state transition must be explicit and auditable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ReversalRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;RequestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;TransactionRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;MakerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;CheckerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ApprovalStatus&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;CreatedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;DecidedAt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;with&lt;/code&gt; expression models the transition beautifully — the original request is preserved untouched, and approval produces a &lt;em&gt;new&lt;/em&gt; state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;ReversalRequest&lt;/span&gt; &lt;span class="nf"&gt;Approve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReversalRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;checkerId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;CheckerId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;checkerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ApprovalStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Approved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;DecidedAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For audit purposes you can log both objects — before and after — and the generated &lt;code&gt;ToString&lt;/code&gt; gives the auditor a complete, readable diff. Try producing that with a mutated class: the "before" state is gone; you overwrote it.&lt;/p&gt;

&lt;p&gt;This is the &lt;strong&gt;event-sourcing mindset in miniature&lt;/strong&gt;: state changes are new values, not edits. Records make that the path of least resistance.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 4: End-of-day reconciliation
&lt;/h3&gt;

&lt;p&gt;Reconciliation is fundamentally a set-comparison problem: &lt;em&gt;our&lt;/em&gt; view of the day's transactions versus &lt;em&gt;their&lt;/em&gt; file. Value equality turns this from a bespoke matching engine into LINQ:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ReconRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DateOnly&lt;/span&gt; &lt;span class="n"&gt;ValueDate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;internalRows&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetRowsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;providerRows&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ParseProviderFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;missingOnOurSide&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;providerRows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Except&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;internalRows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// they have it, we don't&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;missingOnTheirSide&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;internalRows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Except&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;providerRows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// we have it, they don't&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Except&lt;/code&gt;, &lt;code&gt;Intersect&lt;/code&gt;, &lt;code&gt;Distinct&lt;/code&gt;, &lt;code&gt;GroupBy&lt;/code&gt; — the entire LINQ set-algebra toolkit works &lt;em&gt;correctly by default&lt;/em&gt; on records because they carry proper value semantics. With classes you'd be writing &lt;code&gt;IEqualityComparer&amp;lt;T&amp;gt;&lt;/code&gt; implementations, one per comparison, each a maintenance liability.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧭 How Records Fulfill Software Engineering Principles
&lt;/h2&gt;

&lt;p&gt;This is where the article stops being about syntax and starts being about design. Records aren't just terser — they align data contracts with the principles we already claim to follow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Single Responsibility Principle (SRP)
&lt;/h3&gt;

&lt;p&gt;A DTO has one job: carry data across a boundary. A mutable class quietly takes on a second job — being a &lt;em&gt;workspace&lt;/em&gt; that pipeline stages scribble on. That second job is precisely what causes the tampering bugs above. Records structurally reject the second job. The type does one thing, and the compiler enforces it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Junior-friendly analogy:&lt;/strong&gt; a DTO should be an envelope, not a whiteboard. Records seal the envelope.&lt;/p&gt;

&lt;h3&gt;
  
  
  Open/Closed Principle (OCP)
&lt;/h3&gt;

&lt;p&gt;"Open for extension, closed for modification" is usually discussed for behavior, but it applies to data too. The &lt;code&gt;with&lt;/code&gt; expression is OCP for values: you &lt;em&gt;extend&lt;/em&gt; an existing fact into a new one without &lt;em&gt;modifying&lt;/em&gt; the original.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;enriched&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Narration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ResolveNarration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every stage of an enrichment pipeline receives an input it cannot break for other consumers and emits a new output. Pipelines composed this way are trivially parallelizable and testable, because no stage has side effects on shared state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Liskov Substitution Principle (LSP)
&lt;/h3&gt;

&lt;p&gt;Records support inheritance (&lt;code&gt;public record ChildDto : ParentDto&lt;/code&gt;) and — unlike hand-written class equality - the generated equality is &lt;em&gt;hierarchy-aware&lt;/em&gt;. A record's &lt;code&gt;Equals&lt;/code&gt; checks the runtime type via a synthesized &lt;code&gt;EqualityContract&lt;/code&gt;, so a &lt;code&gt;ParentDto&lt;/code&gt; and a &lt;code&gt;ChildDto&lt;/code&gt; with identical property values are correctly &lt;em&gt;not&lt;/em&gt; equal. Hand-rolled class equality gets this wrong constantly (the classic symmetric-equality bug in &lt;code&gt;Equals&lt;/code&gt; overrides). Records give you LSP-safe equality out of the box.&lt;/p&gt;

&lt;p&gt;That said — and this is a genuine senior-level guideline — &lt;strong&gt;prefer flat records and composition for DTOs&lt;/strong&gt;. Deep DTO inheritance hierarchies are usually a design smell regardless of the type mechanism. Records just fail more safely when you do it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Interface Segregation Principle (ISP)
&lt;/h3&gt;

&lt;p&gt;Positional records make fat contracts &lt;em&gt;visible&lt;/em&gt;. A 22-property class DTO looks like any other file. A 22-parameter positional record looks alarming in the constructor — and that discomfort is a feature. It nudges you toward segregating the contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;BeneficiaryDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;BankCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;TransferAmount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;TransferRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TransferAmount&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;BeneficiaryDetails&lt;/span&gt; &lt;span class="n"&gt;Beneficiary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nested records compose value equality automatically: two &lt;code&gt;TransferRequest&lt;/code&gt;s are equal if their nested records are equal, all the way down. You get well-factored, comparable aggregate contracts with zero extra code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dependency Inversion Principle (DIP)
&lt;/h3&gt;

&lt;p&gt;DIP is about depending on stable abstractions. In distributed systems, the &lt;em&gt;most&lt;/em&gt; stable abstraction between two services is an immutable contract. A record shared in a contracts package is a promise: "this shape, these semantics, no hidden mutation." Consumers can cache it, memoize on it, and key dictionaries by it, all safely — because the contract's stability is enforced, not merely documented.&lt;/p&gt;

&lt;h3&gt;
  
  
  YAGNI (You Aren't Gonna Need It)
&lt;/h3&gt;

&lt;p&gt;Every &lt;code&gt;set;&lt;/code&gt; accessor on a DTO is speculative capability: "someone &lt;em&gt;might&lt;/em&gt; need to mutate this someday." YAGNI says: don't build it until they do. Records are YAGNI applied to data — you ship exactly the capability a data carrier needs (construction, reading, comparison, copying) and nothing more. When a genuine mutation need appears, &lt;code&gt;with&lt;/code&gt; handles it explicitly, and the code review sees it.&lt;/p&gt;

&lt;h3&gt;
  
  
  DRY (Don't Repeat Yourself)
&lt;/h3&gt;

&lt;p&gt;The 40 lines of &lt;code&gt;Equals&lt;/code&gt;/&lt;code&gt;GetHashCode&lt;/code&gt;/&lt;code&gt;ToString&lt;/code&gt;/copy-constructor boilerplate per DTO is repetition at industrial scale — the same &lt;em&gt;pattern&lt;/em&gt; re-implemented hundreds of times, each an opportunity to diverge. Records move that pattern into the compiler, where it's written once and correct forever. This is DRY in its strongest form: not just avoiding copy-paste, but eliminating the category of code entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Principle of Least Astonishment
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;a == b&lt;/code&gt; returning &lt;code&gt;false&lt;/code&gt; for two identical payment notifications is astonishing to every engineer who hasn't been burned by it yet. Records make equality behave the way domain intuition expects for data: same values, same fact, equal. Fewer surprises, fewer 2 a.m. debugging sessions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fail Fast
&lt;/h3&gt;

&lt;p&gt;Positional records force construction through a single point — the primary constructor — which is exactly where validation belongs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;TransferAmount&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;TransferAmount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;currency&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="k"&gt;value&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="m"&gt;0&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="nf"&gt;ArgumentOutOfRangeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"Amount must be positive."&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="n"&gt;currency&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&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="nf"&gt;ArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ISO 4217 code required"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currency&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="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;Currency&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the object can't be mutated afterward, &lt;strong&gt;valid at construction means valid forever&lt;/strong&gt;. Compare that with a mutable class, where validity is a moving target you must re-verify at every use site.&lt;/p&gt;

&lt;h3&gt;
  
  
  DDD: Records &lt;em&gt;are&lt;/em&gt; Value Objects
&lt;/h3&gt;

&lt;p&gt;If you practice Domain-Driven Design, you've been hand-writing value objects — &lt;code&gt;Money&lt;/code&gt;, &lt;code&gt;AccountNumber&lt;/code&gt;, &lt;code&gt;Bvn&lt;/code&gt;, &lt;code&gt;IdempotencyKey&lt;/code&gt; — for years, with all the equality ceremony that entails. Records are the value-object pattern promoted to a language feature. Eric Evans's definition — "an object that describes some characteristic... with no conceptual identity, compared by value" — is a description of a C# record. This is the deepest reason records feel &lt;em&gt;right&lt;/em&gt; for DTOs: DTOs were always value objects; we just lacked the language support to say so.&lt;/p&gt;

&lt;h3&gt;
  
  
  CQS/CQRS alignment
&lt;/h3&gt;

&lt;p&gt;In a CQRS system (MediatR users, this is you), queries return read models and commands carry intent. Both are one-way messages — facts flowing in one direction. Immutable records match that semantics exactly: a &lt;code&gt;GetAccountBalanceQuery&lt;/code&gt; or its &lt;code&gt;AccountBalanceResult&lt;/code&gt; has no business being mutated after creation. Making commands, queries, and results records turns your MediatR pipeline into a chain of pure-ish transformations, which is dramatically easier to reason about, test, and parallelize.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚡ Performance: What Records Actually Buy You (and What They Don't)
&lt;/h2&gt;

&lt;p&gt;Senior engineers should be suspicious of performance claims, so let's be honest and specific.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where records genuinely help
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Correct, fast equality without reflection.&lt;/strong&gt; The compiler-generated &lt;code&gt;Equals&lt;/code&gt; and &lt;code&gt;GetHashCode&lt;/code&gt; are direct field comparisons - no reflection, no boxing of the comparands, no serialization tricks. Teams that fall back to comparing DTOs via &lt;code&gt;JsonSerializer.Serialize(a) == JsonSerializer.Serialize(b)&lt;/code&gt; (it happens more than anyone admits) are paying two full serializations plus a string comparison per check. Record equality is orders of magnitude cheaper and allocation-free for the comparison itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Dictionary and set performance by construction.&lt;/strong&gt; Because &lt;code&gt;GetHashCode&lt;/code&gt; is generated correctly, records are first-class citizens as keys in &lt;code&gt;Dictionary&amp;lt;TKey,TValue&amp;gt;&lt;/code&gt;, &lt;code&gt;HashSet&amp;lt;T&amp;gt;&lt;/code&gt;, &lt;code&gt;ConcurrentDictionary&lt;/code&gt;, and memoization caches. In a fraud-screening service that caches risk scores per &lt;code&gt;(Account, Channel, AmountBand)&lt;/code&gt; tuple, a record key gives you O(1) lookups with hash distribution you didn't have to think about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Lock-free sharing under concurrency.&lt;/strong&gt; This is the big one for throughput. An immutable object can be read by any number of threads with &lt;strong&gt;zero synchronization&lt;/strong&gt;. In a high-TPS inbound payments pipeline — parallel Kafka consumers, &lt;code&gt;Parallel.ForEachAsync&lt;/code&gt; enrichment, fan-out to multiple downstream handlers — mutable shared DTOs force you into locks, defensive copies, or prayer. Immutable records eliminate the entire category: no locks, no torn reads, no defensive &lt;code&gt;Clone()&lt;/code&gt; calls scattered through the code. The performance win isn't the object itself; it's all the synchronization machinery you get to delete.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Defensive copying disappears.&lt;/strong&gt; Mature codebases with mutable DTOs accrue defensive copies — "clone it before passing it to that service, just in case." Each copy is allocation and CPU spent to compensate for mutability. With records, passing a reference &lt;em&gt;is&lt;/em&gt; safe sharing. The copies you no longer make are a real, measurable saving in hot paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. &lt;code&gt;record struct&lt;/code&gt; for the truly hot path.&lt;/strong&gt; Since C# 10, &lt;code&gt;readonly record struct&lt;/code&gt; gives you value semantics with stack allocation — zero GC pressure for small, short-lived carriers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;struct&lt;/span&gt; &lt;span class="nf"&gt;FxRate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Pair&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;AsOf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a pricing engine evaluating thousands of FX quotes per second, this avoids heap allocation entirely for the quote objects, which shows up directly in Gen0 collection frequency. The guidance mirrors regular structs: keep them small (≈16 bytes as a rule of thumb, pragmatically a few fields), and prefer &lt;code&gt;readonly record struct&lt;/code&gt; to prevent the defensive-copy costs of non-readonly structs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where you must be honest about costs
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;with&lt;/code&gt; expressions allocate.&lt;/strong&gt; Every &lt;code&gt;with&lt;/code&gt; produces a full new instance. In a pipeline that transforms a record five times, that's five allocations where a mutable class would have zero. For typical API/webhook DTOs (short-lived, Gen0-collected) this is noise — Gen0 collection of small objects is extremely cheap, and correctness wins. But if profiling shows a genuinely hot loop churning large records through repeated &lt;code&gt;with&lt;/code&gt; copies, that's a legitimate case for a builder or a mutable working type &lt;em&gt;inside&lt;/em&gt; the tight scope, converting to a record at the boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Record equality is O(n) in fields.&lt;/strong&gt; Comparing two 30-property records field-by-field costs more than a reference check. If you're doing millions of comparisons and references would suffice (i.e., you &lt;em&gt;know&lt;/em&gt; it's the same instance), &lt;code&gt;ReferenceEquals&lt;/code&gt; still exists. This is rarely a real problem, but it's worth knowing the shape of the cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The performance argument, summarized for an architect:&lt;/strong&gt; records are not "faster objects." They are objects whose guarantees let you &lt;em&gt;delete slower code&lt;/em&gt; — locks, defensive copies, hand-rolled comparers, serialization-based equality — and whose struct variant removes allocation where it matters. The system gets faster because the design gets simpler.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚠️ Limitations and Sharp Edges (Read This Before You Migrate)
&lt;/h2&gt;

&lt;p&gt;Records are the right default for DTOs. They are not the right choice for everything, and they have gotchas that will bite teams who treat them as "classes but shorter."&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Do not use records for EF Core entities
&lt;/h3&gt;

&lt;p&gt;This is the most common misapplication. ORM entities are the opposite of DTOs: they have identity (primary key), a lifecycle, and &lt;em&gt;mutation is the mechanism&lt;/em&gt; — EF Core's change tracker works by detecting property modifications on tracked instances. Records fight this at every turn: value equality confuses identity-based logic and &lt;code&gt;DbSet&lt;/code&gt; semantics, &lt;code&gt;with&lt;/code&gt; copies break change tracking (the copy is a &lt;em&gt;different, untracked&lt;/em&gt; object), and lazy-loading proxies don't play well with record equality. Keep entities as classes. Map them to record DTOs at the query boundary — which is exactly the layering clean architecture prescribes anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Collection properties break value equality
&lt;/h3&gt;

&lt;p&gt;The gotcha that surprises everyone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;BatchDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;BatchId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TransactionRefs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;BatchDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"B-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"T1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"T2"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;BatchDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"B-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"T1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"T2"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&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="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// FALSE — List&amp;lt;T&amp;gt; compares by reference&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generated equality compares each member with its own &lt;code&gt;Equals&lt;/code&gt; — and &lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt; equality is reference equality. So a record containing a collection silently loses the value-equality guarantee you adopted records for. In a reconciliation or dedup context, this is a &lt;em&gt;correctness&lt;/em&gt; bug, not a style issue. Mitigations: use &lt;code&gt;ImmutableArray&amp;lt;T&amp;gt;&lt;/code&gt;/&lt;code&gt;ImmutableList&amp;lt;T&amp;gt;&lt;/code&gt; with a custom &lt;code&gt;Equals&lt;/code&gt; override for that member, model the data to avoid collections in equality-sensitive records, or explicitly override &lt;code&gt;Equals&lt;/code&gt;/&lt;code&gt;GetHashCode&lt;/code&gt; with sequence comparison. Whatever you choose, make it a documented team convention.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;code&gt;with&lt;/code&gt; is a shallow copy
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;with&lt;/code&gt; copies field values. If a field is a reference to a mutable object (that &lt;code&gt;List&amp;lt;string&amp;gt;&lt;/code&gt; again), the original and the copy &lt;strong&gt;share it&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;copy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;original&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;BatchId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"B-02"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="n"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TransactionRefs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"T3"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// also visible through original — same list!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The record's own scalar fields are safe; nested mutable state is not. The clean fix is to make records immutable &lt;em&gt;all the way down&lt;/em&gt; — nested records, immutable collections — so shallow copy and deep copy become indistinguishable. Half-immutable records are arguably more dangerous than honest mutable classes, because they &lt;em&gt;look&lt;/em&gt; safe.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Serializer and framework friction
&lt;/h3&gt;

&lt;p&gt;Modern &lt;code&gt;System.Text.Json&lt;/code&gt; and current Newtonsoft.Json handle records and constructor binding well, but the wider ecosystem varies: some mappers, legacy binders, and reflection-based tools still assume a parameterless constructor and settable properties. AutoMapper needs &lt;code&gt;ForCtorParam&lt;/code&gt; configuration for positional records. If a tool forces you to add &lt;code&gt;set;&lt;/code&gt; accessors back onto a record to make it work, you've kept the syntax and lost the point — reconsider the tool or use &lt;code&gt;init&lt;/code&gt; with object-initializer-style records.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Inheritance equality can surprise you
&lt;/h3&gt;

&lt;p&gt;Record inheritance is safer than hand-rolled class equality, but the &lt;code&gt;EqualityContract&lt;/code&gt; behavior — a base-typed variable holding a derived instance never equals a base instance with identical values — surprises engineers who expected structural comparison across the hierarchy. Combined with the general smell of deep DTO hierarchies, the pragmatic rule stands: &lt;strong&gt;flat records, composition over inheritance.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Wide records get unwieldy
&lt;/h3&gt;

&lt;p&gt;A positional record with 20+ parameters is miserable to construct and brittle to reorder (positional args have no names at most call sites). For wide contracts, use nominal records with &lt;code&gt;init&lt;/code&gt; properties and object initializers, or — better — apply ISP and decompose the contract, as shown earlier.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Migration is not free in a brownfield
&lt;/h3&gt;

&lt;p&gt;Introducing records into a 7-year-old codebase means auditing every place that mutates DTOs today. Some of those mutations are bugs you'll be glad to find; others are load-bearing hacks. Migrate boundary-first: new contracts, new events, and new endpoints as records; refactor legacy DTOs opportunistically when you're already touching their pipelines. A big-bang conversion of 400 DTOs is a two-week PR nobody can review.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧰 When a Class Is Still the Right Answer
&lt;/h2&gt;

&lt;p&gt;To be a trustworthy advocate for records, you have to know their boundary. Reach for a class when the type:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Has identity and a lifecycle&lt;/strong&gt; — ORM entities, aggregate roots, anything where "same object over time despite changing state" is the point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is fundamentally behavioral&lt;/strong&gt; — services, handlers, strategies. Records model &lt;em&gt;data&lt;/em&gt;; classes model &lt;em&gt;actors&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Requires managed mutable state&lt;/strong&gt; — builders, accumulators, caches, connection pools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Must integrate with mutation-assuming frameworks&lt;/strong&gt; you can't replace — some legacy binders, certain UI frameworks, older SOAP/WCF tooling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is a large object copied in a proven hot loop&lt;/strong&gt; — where &lt;code&gt;with&lt;/code&gt;-copy allocation genuinely dominates a profile (measure first; it almost never does).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mental model to give a junior engineer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Records are for facts. Classes are for actors.&lt;/strong&gt; A payment notification is a fact. The service that processes it is an actor. Facts don't change; actors do things.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  📋 A Practical Decision Guide
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type of thing&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API request/response contracts&lt;/td&gt;
&lt;td&gt;&lt;code&gt;record&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kafka / message-bus events&lt;/td&gt;
&lt;td&gt;&lt;code&gt;record&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MediatR commands, queries, results&lt;/td&gt;
&lt;td&gt;&lt;code&gt;record&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DDD value objects (&lt;code&gt;Money&lt;/code&gt;, &lt;code&gt;AccountNumber&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;record&lt;/code&gt; (with constructor validation)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small hot-path carriers (FX ticks, coordinates)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;readonly record struct&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EF Core entities / aggregate roots&lt;/td&gt;
&lt;td&gt;&lt;code&gt;class&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Services, handlers, middleware&lt;/td&gt;
&lt;td&gt;&lt;code&gt;class&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Builders, accumulators, mutable caches&lt;/td&gt;
&lt;td&gt;&lt;code&gt;class&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Configuration bound at startup (&lt;code&gt;IOptions&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;record&lt;/code&gt; with &lt;code&gt;init&lt;/code&gt; properties&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  🏁 Conclusion: This Was Never About Syntax
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;record&lt;/code&gt; keyword saves you 40 lines per DTO, and that alone would justify using it. But the real argument is architectural:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mutable class DTOs make correctness a matter of discipline. Records make it a matter of compilation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In consumer software, the cost of a mutated DTO is a weird bug. In banking and fintech, it's a duplicated settlement, a reconciliation break, a corrupted audit trail — failures with monetary and regulatory consequences. Every principle we invoked — SRP, OCP, YAGNI, DRY, fail-fast, least astonishment, DDD value semantics — converges on the same conclusion: data crossing a boundary should be an immutable, value-comparable fact, and C# now has a first-class type for exactly that.&lt;/p&gt;

&lt;p&gt;So the next time you type &lt;code&gt;public class SomethingDto { get; set; }&lt;/code&gt;, stop. Ask yourself: is this a fact or an actor? If it's a fact — and a DTO always is — reach for the record, take the pen away, and let the compiler enforce what your code reviews have been trying to enforce for years.&lt;/p&gt;

&lt;p&gt;Your reconciliation team will thank you. Eventually. Silently. By never having to call you at 2 a.m. about a ₦0.005 discrepancy.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you found this useful, drop a comment with the worst mutable-DTO bug you've debugged in production - the war stories are half the education.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LinkedIn Account&lt;/code&gt; : &lt;a href="https://www.linkedin.com/in/matthew-odumosu/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;br&gt;
&lt;code&gt;Twitter Account&lt;/code&gt;: &lt;a href="https://twitter.com/iamcymentho" rel="noopener noreferrer"&gt;Twitter&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Credit&lt;/strong&gt;: Graphics sourced from &lt;a href="https://dev.to/iamcymentho/building-a-scalable-furniture-e-commerce-web-api-using-net-clean-architecture-and-mongodb-33o7/edit"&gt;Dev.To&lt;/a&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>softwaredevelopment</category>
      <category>learning</category>
      <category>csharp</category>
    </item>
    <item>
      <title>The Missing Row: Auto-Provisioning Derived Records Without the Race Condition</title>
      <dc:creator>Odumosu Matthew</dc:creator>
      <pubDate>Sat, 18 Jul 2026 10:00:37 +0000</pubDate>
      <link>https://dev.to/iamcymentho/the-missing-row-auto-provisioning-derived-records-without-the-race-condition-3ecb</link>
      <guid>https://dev.to/iamcymentho/the-missing-row-auto-provisioning-derived-records-without-the-race-condition-3ecb</guid>
      <description>&lt;p&gt;&lt;em&gt;Why some records should be created by your system, not your users,  and how to do it safely in .NET.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A support ticket lands on your desk: "The Teams page is empty. I added a member, but no team shows up."&lt;/p&gt;

&lt;p&gt;You check the API. It's behaving exactly as written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"totalCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is broken. And that's the problem. The system is faithfully returning nothing, because the row that the page reads from was never created. Somewhere in your design,&lt;br&gt;&lt;br&gt;
  you assumed a human would create it first.&lt;/p&gt;

&lt;p&gt;This article is about a small, recurring design decision that quietly causes empty dashboards, confused users, and "is this a bug?" tickets: &lt;strong&gt;who is responsible for creating &lt;br&gt;
  derived records&lt;/strong&gt; the user, or the system? and how to let the system do it without introducing duplicate rows or race conditions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Let's use a fictional product: a collaboration tool called &lt;strong&gt;Loop&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In Loop, the important entities are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;Organization&lt;/strong&gt; (a paying customer).&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Member&lt;/strong&gt; (a person invited into an organization under a plan).&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Team&lt;/strong&gt; a grouping that members belong to, keyed by &lt;code&gt;(OrganizationId, PlanCode)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The admin dashboard lists &lt;strong&gt;Teams&lt;/strong&gt;. Each team card shows a member count.&lt;/p&gt;

&lt;p&gt;Here's the catch in the original design: creating a &lt;strong&gt;Member&lt;/strong&gt; wrote a member row. Creating a &lt;strong&gt;Team&lt;/strong&gt; was a &lt;em&gt;separate, manual&lt;/em&gt; step an admin was expected to do first. If an&lt;br&gt;&lt;br&gt;
  admin invited members without first creating the matching team, the dashboard showed nothing even though the members clearly existed.&lt;/p&gt;

&lt;p&gt;From the user's point of view, they did everything right. From the system's point of view, a required row simply didn't exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Team record isn't independent information. It is fully &lt;em&gt;derivable&lt;/em&gt; from the first member invited under a plan. When one entity's existence is implied by another, forcing a&lt;br&gt;
  human to create it manually is a design smell. It leads to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Empty states that look like outages.&lt;/strong&gt; Users can't tell "no data" from "misconfigured."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support load.&lt;/strong&gt; Every skipped step becomes a ticket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent data drift.&lt;/strong&gt; Members exist with no grouping to roll them up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is to let the system provision the derived record at the moment it first becomes necessary the first write,  instead of relying on a prerequisite step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The naive solution (and its two traps)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The obvious move: when a member is created, create the team if it isn't there yet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;  &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;AddMemberAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NewMember&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Member&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_members&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="c1"&gt;// Create the team if this is the first member on this plan.&lt;/span&gt;
      &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_teams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AnyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrganizationId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrganizationId&lt;/span&gt;
            &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PlanCode&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PlanCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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="n"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;team&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Team&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrganizationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PlanCode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_teams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_unitOfWork&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&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;This works in a demo. In production it has two traps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap 1: the check-then-insert race&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AnyAsync(...)&lt;/code&gt; and &lt;code&gt;AddAsync(...)&lt;/code&gt; are not atomic. If two members are invited to the same new team at the same moment, both requests can observe &lt;code&gt;exists == false&lt;/code&gt;, and both&lt;br&gt;&lt;br&gt;
  insert a team. Now the dashboard shows &lt;strong&gt;two identical team cards&lt;/strong&gt;, and every downstream count is split across them.&lt;/p&gt;

&lt;p&gt;An application-level &lt;code&gt;AnyAsync&lt;/code&gt; check can never close this window on its own. The only reliable guard is a &lt;strong&gt;unique constraint&lt;/strong&gt;, so the database,  the one component that sees&lt;br&gt;
  all writes,  rejects the second insert.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;  &lt;span class="c1"&gt;// EF Core model configuration&lt;/span&gt;
  &lt;span class="n"&gt;modelBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Team&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HasIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrganizationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PlanCode&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsUnique&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the constraint in place, turn the "check" into a "try, and treat a uniqueness violation as success",  because a violation means someone else already created exactly the&lt;br&gt;&lt;br&gt;
  row you wanted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;EnsureTeamExistsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;planCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_teams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AnyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrganizationId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;organizationId&lt;/span&gt;
            &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PlanCode&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;planCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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="n"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="n"&gt;_teams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Team&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;planCode&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

      &lt;span class="k"&gt;try&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_unitOfWork&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DbUpdateException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;IsUniqueViolation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="c1"&gt;// A concurrent request created the same team first.&lt;/span&gt;
          &lt;span class="c1"&gt;// That is the desired end state, so this is a no-op, not an error.&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;The application-level &lt;code&gt;AnyAsync&lt;/code&gt; still earns its keep: it avoids a failed insert on the common path. The constraint is what makes the code &lt;em&gt;correct&lt;/em&gt; under concurrency. You&lt;br&gt;&lt;br&gt;
  want both,  the check for efficiency, the constraint for truth.&lt;/p&gt;

&lt;p&gt;###Trap 2: the normalization mismatch&lt;/p&gt;

&lt;p&gt;The team and the member are joined on &lt;code&gt;PlanCode&lt;/code&gt;. If one side stores &lt;code&gt;"pro"&lt;/code&gt; and the other stores &lt;code&gt;"pro "&lt;/code&gt; (trailing space) or &lt;code&gt;"PRO"&lt;/code&gt;, the join silently returns nothing.&lt;br&gt;&lt;br&gt;
  You'll create a team &lt;em&gt;and&lt;/em&gt; members, and the dashboard &lt;em&gt;still&lt;/em&gt; shows a count of zero a bug that looks identical to the original one.&lt;/p&gt;

&lt;p&gt;Normalize the key in exactly one place, and make both sides use it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;  &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;Team&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;planCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;OrganizationId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="c1"&gt;// Trim once, here, so the stored key matches how members store it.&lt;/span&gt;
      &lt;span class="n"&gt;PlanCode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;planCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&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;Whatever rule you choose, trim, lowercase, collapse,  apply it on every path that reads or writes the key. A join is only as reliable as the normalization behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The retroactive gap&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's the part teams forget. This fix runs &lt;em&gt;at write time&lt;/em&gt;. It fixes every member invited &lt;strong&gt;after&lt;/strong&gt; you ship it.&lt;/p&gt;

&lt;p&gt;It does nothing for data that already exists. Organizations that invited members &lt;em&gt;before&lt;/em&gt; the change still have no team row. They will look broken until either:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;someone invites one more member (the new code then provisions the missing team), or&lt;/li&gt;
&lt;li&gt;you run a &lt;strong&gt;backfill&lt;/strong&gt; for the existing data.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Don't skip the backfill and hope the write path heals everything. Write a one-time job that creates the missing rows from what's already there:&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="c1"&gt;-- Create the missing team for every distinct (org, plan) that has members&lt;/span&gt;
  &lt;span class="c1"&gt;-- but no team yet. Idempotent: safe to run more than once.&lt;/span&gt;
  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;teams&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;organization_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at_utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;TRIM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan_code&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;members&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
  &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;teams&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;TRIM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;TRIM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan_code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A forward fix plus a backfill is one complete change, not two optional ones. Ship them together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best practices&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Let the system create what it can derive.&lt;/strong&gt; If record B's existence is implied by record A, don't make a human create B first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guard uniqueness at the database, not just in code.&lt;/strong&gt; Application checks are an optimization; constraints are the guarantee.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make "already exists" a success, not an exception.&lt;/strong&gt; Idempotent provisioning should converge on the same state no matter how many callers race.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalize join keys once and everywhere.&lt;/strong&gt; Most "the data is there but the count is zero" bugs are a normalization mismatch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pair every write-time fix with a backfill.&lt;/strong&gt; The forward path won't repair history.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Common mistakes&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Relying on &lt;code&gt;AnyAsync&lt;/code&gt; / &lt;code&gt;SELECT ... IF NOT EXISTS&lt;/code&gt; alone under concurrency.&lt;/li&gt;
&lt;li&gt;Adding the unique index but not handling the violation — turning a race into a 500 for the second caller.&lt;/li&gt;
&lt;li&gt;Backfilling but forgetting to normalize in the backfill query, so it re-inserts near-duplicates.&lt;/li&gt;
&lt;li&gt;Provisioning the derived row in a &lt;em&gt;separate&lt;/em&gt; transaction from the triggering write, so a partial failure leaves one without the other.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Lessons learned&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An empty response is not always "no data." Sometimes it's "the row you're reading from was never anyone's job to create." The most durable fix is to move that responsibility&lt;br&gt;&lt;br&gt;
  from the user to the system, and then make the system's version safe under concurrency and honest about history.&lt;/p&gt;

&lt;p&gt;The interesting engineering isn't the get-or-create. It's everything around it: the constraint that makes it correct, the normalization that makes the join real, and the&lt;br&gt;&lt;br&gt;
  backfill that makes it true for data that already exists.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LinkedIn Account&lt;/code&gt; : &lt;a href="https://www.linkedin.com/in/matthew-odumosu/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;br&gt;
&lt;code&gt;Twitter Account&lt;/code&gt;: &lt;a href="https://twitter.com/iamcymentho" rel="noopener noreferrer"&gt;Twitter&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Credit&lt;/strong&gt;: Graphics sourced from &lt;a href="https://medium.com/backend-engineers-club/race-conditions-in-plain-english-fbc32990b436" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;&lt;/p&gt;

</description>
      <category>software</category>
      <category>ai</category>
      <category>csharp</category>
      <category>development</category>
    </item>
    <item>
      <title>Mastering the Senior Java/Spring Boot Engineer Interview</title>
      <dc:creator>Odumosu Matthew</dc:creator>
      <pubDate>Thu, 19 Mar 2026 12:14:25 +0000</pubDate>
      <link>https://dev.to/iamcymentho/mastering-the-senior-javaspring-boot-engineer-interview-3c1g</link>
      <guid>https://dev.to/iamcymentho/mastering-the-senior-javaspring-boot-engineer-interview-3c1g</guid>
      <description>&lt;p&gt;I've been on both sides of the interview table for over a decade. I've bombed interviews at companies I really wanted to join. I've also hired engineers who looked mediocre on paper but turned out to be the best technical decisions I ever made.&lt;/p&gt;

&lt;p&gt;Here's what I've learned: &lt;strong&gt;Senior interviews aren't knowledge tests. They're thinking tests.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Anyone can memorize that &lt;code&gt;HashMap&lt;/code&gt; has O(1) lookup. What interviewers actually want to know is whether you'll make good decisions when the documentation runs out and Stack Overflow doesn't have your exact problem.&lt;/p&gt;

&lt;p&gt;This guide is different. I'm not going to list 50 questions with textbook answers. Instead, I'll show you how senior engineers &lt;em&gt;think&lt;/em&gt; through problems, because that's what actually gets you hired.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Uncomfortable Truth About Senior Interviews
&lt;/h2&gt;

&lt;p&gt;Let me be blunt: if you have 10+ years of experience and you're still getting rejected, it's probably not your technical knowledge. It's one of these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;You're answering questions like a junior&lt;/strong&gt; - giving definitions instead of demonstrating judgment&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're not showing trade-off thinking&lt;/strong&gt; - every solution has costs, and you're not acknowledging them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're not telling stories&lt;/strong&gt; - your experience is your differentiator, but you're not using it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're solving the wrong problem&lt;/strong&gt; - you're answering what was asked, not what was meant&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let's fix all of these.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: Core Java - But Not The Way You Think
&lt;/h2&gt;

&lt;p&gt;Every Java interview covers the basics. The difference is &lt;em&gt;depth&lt;/em&gt;. Here's what separates a senior answer from a mid-level one.&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;equals()&lt;/code&gt; and &lt;code&gt;hashCode()&lt;/code&gt; Question
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What they ask:&lt;/strong&gt; "What happens if you override &lt;code&gt;equals()&lt;/code&gt; but not &lt;code&gt;hashCode()&lt;/code&gt;?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mid-level answer:&lt;/strong&gt; "It breaks hash-based collections because equal objects might have different hash codes."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Senior answer:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"It violates the &lt;code&gt;hashCode&lt;/code&gt; contract, which creates subtle bugs that are incredibly hard to track down in production.&lt;/p&gt;

&lt;p&gt;Here's why this is dangerous: the bug won't show up in your unit tests. It'll show up three months later when someone adds your object to a &lt;code&gt;HashSet&lt;/code&gt; in a completely different part of the codebase.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TradingAccount&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;getClass&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClass&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="nc"&gt;TradingAccount&lt;/span&gt; &lt;span class="n"&gt;that&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TradingAccount&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Objects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;that&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;accountId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Intentionally missing hashCode — this is a bug&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now imagine this in a trading system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TradingAccount&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;processedAccounts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HashSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
&lt;span class="nc"&gt;TradingAccount&lt;/span&gt; &lt;span class="n"&gt;account1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;loadFromDatabase&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12345&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;processedAccounts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Later, different code path loads the same account&lt;/span&gt;
&lt;span class="nc"&gt;TradingAccount&lt;/span&gt; &lt;span class="n"&gt;account2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;loadFromDatabase&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12345&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;processedAccounts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account2&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// This executes! We process the same account twice.&lt;/span&gt;
    &lt;span class="c1"&gt;// In a trading system, this could mean duplicate orders.&lt;/span&gt;
    &lt;span class="n"&gt;processAccount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account2&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;contains()&lt;/code&gt; check fails because &lt;code&gt;HashSet&lt;/code&gt; first checks the bucket using &lt;code&gt;hashCode()&lt;/code&gt;. Since we didn't override it, &lt;code&gt;account1&lt;/code&gt; and &lt;code&gt;account2&lt;/code&gt; have different hash codes (based on memory address), so they land in different buckets. The &lt;code&gt;equals()&lt;/code&gt; method is never even called.&lt;/p&gt;

&lt;p&gt;I've seen this exact bug cause duplicate transactions in a payment system. Took two weeks to find because it only happened under specific load conditions where the same entity was loaded through different code paths.&lt;/p&gt;

&lt;p&gt;The fix isn't just adding &lt;code&gt;hashCode()&lt;/code&gt; - it's establishing team conventions. We now require that any class overriding &lt;code&gt;equals()&lt;/code&gt; must also have a test that verifies the &lt;code&gt;hashCode&lt;/code&gt; contract."&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;See the difference?&lt;/strong&gt; The senior answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Explains the &lt;em&gt;mechanism&lt;/em&gt; (hash buckets)&lt;/li&gt;
&lt;li&gt;Describes real &lt;em&gt;consequences&lt;/em&gt; (duplicate transactions)&lt;/li&gt;
&lt;li&gt;Shares a &lt;em&gt;war story&lt;/em&gt; (the two-week debugging session)&lt;/li&gt;
&lt;li&gt;Proposes &lt;em&gt;prevention&lt;/em&gt; (team conventions and tests)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is what interviewers want. They're not testing if you know Java. They're testing if you've been burned by Java and learned from it.&lt;/p&gt;




&lt;h3&gt;
  
  
  Memory Model and Concurrency
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What they ask:&lt;/strong&gt; "Explain the Java Memory Model and when you'd use &lt;code&gt;volatile&lt;/code&gt;."&lt;/p&gt;

&lt;p&gt;This question is a trap. Most candidates launch into a textbook explanation of happens-before relationships and CPU caches. That's fine, but it's not impressive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Senior approach:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"The Java Memory Model defines how threads interact through memory, but honestly, the practical implications matter more than the theory.&lt;/p&gt;

&lt;p&gt;Here's when I've actually needed &lt;code&gt;volatile&lt;/code&gt; in production:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Status flags for graceful shutdown&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderProcessor&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Runnable&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;volatile&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;running&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;shutdown&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;running&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Visible to the processing thread immediately&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;running&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;poll&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MILLISECONDS&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;// Cleanup resources&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;volatile&lt;/code&gt;, the processing thread might cache &lt;code&gt;running = true&lt;/code&gt; and never see the shutdown signal. I've seen services that couldn't shut down cleanly because of this - they'd hang during deployments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Double-checked locking (though I avoid it now)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExpensiveResourceHolder&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;volatile&lt;/span&gt; &lt;span class="nc"&gt;ExpensiveResource&lt;/span&gt; &lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ExpensiveResource&lt;/span&gt; &lt;span class="nf"&gt;getInstance&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;synchronized&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;instance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ExpensiveResource&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
                &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern is technically correct with &lt;code&gt;volatile&lt;/code&gt;, but I've stopped using it. It's too easy to get wrong, and the performance benefit over simple synchronization is negligible in most applications. I now use either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Lazy&lt;/code&gt; holders for singletons&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ConcurrentHashMap.computeIfAbsent()&lt;/code&gt; for cached values&lt;/li&gt;
&lt;li&gt;Or just accept the synchronization cost&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. Publishing immutable objects&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConfigurationHolder&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;volatile&lt;/span&gt; &lt;span class="nc"&gt;ImmutableConfiguration&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;updateConfiguration&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ImmutableConfiguration&lt;/span&gt; &lt;span class="n"&gt;newConfig&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Single atomic write, immediately visible to all readers&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;newConfig&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ImmutableConfiguration&lt;/span&gt; &lt;span class="nf"&gt;getConfiguration&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// No synchronization needed for reads&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is probably my most common use case — when you have a reference that's written rarely but read constantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'd avoid:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Using &lt;code&gt;volatile&lt;/code&gt; for compound operations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;volatile&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;++;&lt;/span&gt;  &lt;span class="c1"&gt;// NOT THREAD-SAFE! Read-modify-write is not atomic&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a classic mistake. &lt;code&gt;volatile&lt;/code&gt; guarantees visibility, not atomicity. You need &lt;code&gt;AtomicInteger&lt;/code&gt; or synchronization here.&lt;/p&gt;

&lt;p&gt;The broader point is: most concurrency problems are better solved by avoiding shared mutable state entirely. Immutable objects, message passing, and higher-level constructs like &lt;code&gt;CompletableFuture&lt;/code&gt; are almost always better choices than low-level primitives like &lt;code&gt;volatile&lt;/code&gt;."&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: Spring Boot — Beyond the Annotations
&lt;/h2&gt;

&lt;p&gt;Every Java developer knows &lt;code&gt;@Autowired&lt;/code&gt;. Senior developers know &lt;em&gt;when not to use it&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dependency Injection Philosophy
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What they ask:&lt;/strong&gt; "Explain dependency injection in Spring."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What they mean:&lt;/strong&gt; "Do you understand &lt;em&gt;why&lt;/em&gt; we use DI, or do you just follow patterns blindly?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Senior answer:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"Dependency injection is fundamentally about &lt;em&gt;inverting control of object creation&lt;/em&gt;. Instead of a class creating its dependencies, they're provided from outside. This seems simple, but the implications are profound.&lt;/p&gt;

&lt;p&gt;Here's what DI actually buys us:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Testability without magic&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Without DI — hard to test&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PaymentProcessor&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;PaymentGateway&lt;/span&gt; &lt;span class="n"&gt;gateway&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StripeGateway&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt; &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Payment&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// How do you test this without hitting Stripe?&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// With DI — trivially testable&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PaymentProcessor&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;PaymentGateway&lt;/span&gt; &lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;PaymentProcessor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentGateway&lt;/span&gt; &lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;gateway&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt; &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Payment&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Test&lt;/span&gt;
&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;shouldHandleDeclinedCard&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;PaymentGateway&lt;/span&gt; &lt;span class="n"&gt;mockGateway&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentGateway&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mockGateway&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="o"&gt;())).&lt;/span&gt;&lt;span class="na"&gt;thenReturn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;declined&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Insufficient funds"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="nc"&gt;PaymentProcessor&lt;/span&gt; &lt;span class="n"&gt;processor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PaymentProcessor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mockGateway&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;PaymentResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;processor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;process&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Payment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isDeclined&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;isTrue&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Configuration flexibility&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Configuration&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PaymentConfig&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="nd"&gt;@Profile&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"production"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;PaymentGateway&lt;/span&gt; &lt;span class="nf"&gt;productionGateway&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;StripeGateway&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stripeApiKey&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="nd"&gt;@Profile&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sandbox"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;PaymentGateway&lt;/span&gt; &lt;span class="nf"&gt;sandboxGateway&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;StripeSandboxGateway&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="nd"&gt;@Profile&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"test"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;PaymentGateway&lt;/span&gt; &lt;span class="nf"&gt;testGateway&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InMemoryPaymentGateway&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// No external calls&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same code, different behavior based on environment. No if-statements scattered through your business logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Lifecycle management&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Spring manages object lifecycles, which matters more than people realize:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DatabaseConnectionPool&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;HikariDataSource&lt;/span&gt; &lt;span class="n"&gt;dataSource&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@PostConstruct&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Warm up connections&lt;/span&gt;
        &lt;span class="n"&gt;dataSource&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getConnection&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Connection pool warmed up"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@PreDestroy&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;shutdown&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Graceful shutdown — finish in-flight queries&lt;/span&gt;
        &lt;span class="n"&gt;dataSource&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Connection pool shut down gracefully"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What I've learned to avoid:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Field injection:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Don't do this&lt;/span&gt;
&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Autowired&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;PaymentService&lt;/span&gt; &lt;span class="n"&gt;paymentService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Hidden dependency&lt;/span&gt;
    &lt;span class="nd"&gt;@Autowired&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;InventoryService&lt;/span&gt; &lt;span class="n"&gt;inventoryService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Another hidden dependency&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dependencies are invisible in the constructor&lt;/li&gt;
&lt;li&gt;Can't create instances without Spring (harder to test)&lt;/li&gt;
&lt;li&gt;No compile-time safety if dependency is missing&lt;/li&gt;
&lt;li&gt;Encourages too many dependencies (code smell for SRP violation)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Constructor injection is always better:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;PaymentService&lt;/span&gt; &lt;span class="n"&gt;paymentService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;InventoryService&lt;/span&gt; &lt;span class="n"&gt;inventoryService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// If this constructor has 8 parameters, you know something's wrong&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;OrderService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentService&lt;/span&gt; &lt;span class="n"&gt;paymentService&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;InventoryService&lt;/span&gt; &lt;span class="n"&gt;inventoryService&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;paymentService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;paymentService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inventoryService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inventoryService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With constructor injection, a class with too many dependencies becomes obvious — the constructor is unreadable. That's a feature, not a bug. It's a signal that the class has too many responsibilities."&lt;/p&gt;




&lt;h3&gt;
  
  
  The N+1 Problem — A Production Story
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What they ask:&lt;/strong&gt; "How do you solve N+1 queries in JPA?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What they mean:&lt;/strong&gt; "Have you actually debugged performance issues in production?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Senior answer:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"The N+1 problem has cost me more production incidents than any other JPA issue. Let me tell you about one.&lt;/p&gt;

&lt;p&gt;We had an order history page that worked fine in development — maybe 50ms response time. In production, with real data, it took 12 seconds. Users were furious.&lt;/p&gt;

&lt;p&gt;The code looked innocent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/orders"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderDto&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getOrders&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@RequestParam&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;customerId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByCustomerId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customerId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;toDto&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;collect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;toList&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;OrderDto&lt;/span&gt; &lt;span class="nf"&gt;toDto&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderDto&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTotal&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getItems&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;  &lt;span class="c1"&gt;// BOOM — lazy load&lt;/span&gt;
        &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCustomer&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;// BOOM — another lazy load&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One customer had 500 orders. That's:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1 query to get orders&lt;/li&gt;
&lt;li&gt;500 queries to get items (one per order)&lt;/li&gt;
&lt;li&gt;500 queries to get customer (yes, the same customer 500 times — JPA doesn't know)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;1,001 queries for one page load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution 1: JOIN FETCH&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT o FROM Order o "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
       &lt;span class="s"&gt;"JOIN FETCH o.items "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
       &lt;span class="s"&gt;"JOIN FETCH o.customer "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
       &lt;span class="s"&gt;"WHERE o.customer.id = :customerId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByCustomerIdWithDetails&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"customerId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;customerId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This generates a single query with JOINs. But there's a catch — if you have multiple collections, you get a cartesian product:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// DON'T DO THIS&lt;/span&gt;
&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT o FROM Order o "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
       &lt;span class="s"&gt;"JOIN FETCH o.items "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
       &lt;span class="s"&gt;"JOIN FETCH o.payments "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;  &lt;span class="c1"&gt;// Two collections = cartesian product&lt;/span&gt;
       &lt;span class="s"&gt;"WHERE o.customer.id = :customerId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If an order has 10 items and 3 payments, you get 30 rows per order. Hibernate deduplicates, but you've transferred way more data than needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution 2: @EntityGraph&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@EntityGraph&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attributePaths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"items"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"customer"&lt;/span&gt;&lt;span class="o"&gt;})&lt;/span&gt;
&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByCustomerId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;customerId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cleaner syntax, same result. I prefer this for simple cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution 3: Batch fetching&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@OneToMany&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mappedBy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"order"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@BatchSize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// Load 25 orders' items in one query&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This changes N+1 into N/25+1. For 500 orders, that's 21 queries instead of 501. Not perfect, but much better, and it doesn't require changing your repository methods.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution 4: DTO projections (my preferred approach for read-heavy endpoints)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OrderSummary&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="nf"&gt;getTotal&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;Integer&lt;/span&gt; &lt;span class="nf"&gt;getItemCount&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getCustomerName&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT o.id as id, o.total as total, SIZE(o.items) as itemCount, c.name as customerName "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
       &lt;span class="s"&gt;"FROM Order o JOIN o.customer c "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
       &lt;span class="s"&gt;"WHERE c.id = :customerId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderSummary&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findOrderSummaries&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"customerId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;customerId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the nuclear option. One query, exactly the data you need, no entity mapping overhead. For read-heavy endpoints where you're just displaying data, this is usually the best choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How I prevent N+1 now:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enable SQL logging in development:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jpa&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;show-sql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;hibernate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;format_sql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use a query counter in tests:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;shouldLoadOrdersInSingleQuery&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;QueryCounter&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;QueryCounter&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByCustomerIdWithDetails&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customerId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getQueryCount&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;isLessThanOrEqualTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Monitor query count in production&lt;/strong&gt; — we alert if any endpoint exceeds 10 queries."&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Part 3: System Design Thinking
&lt;/h2&gt;

&lt;p&gt;Senior interviews always include system design. Here's how to approach them.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Payment Service Question
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What they ask:&lt;/strong&gt; "Design a payment service for an e-commerce platform."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How seniors approach it:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"Before I start designing, I need to understand the constraints. Let me ask a few questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What's the expected transaction volume? Tens per second or thousands?&lt;/li&gt;
&lt;li&gt;Do we need to support multiple payment providers or just one?&lt;/li&gt;
&lt;li&gt;What's the consistency requirement? Can we ever lose a transaction?&lt;/li&gt;
&lt;li&gt;What's the latency budget for a payment request?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;[Interviewer provides context: 100 TPS, multiple providers, zero data loss, 500ms budget]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Okay, let me think through this systematically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The core challenge with payments is reliability&lt;/strong&gt;. Users will retry if something looks stuck. Payment providers might timeout but still process the charge. Network partitions happen. We need to handle all of this without charging someone twice or losing their order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Architecture:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   Client    │────▶│  Payment API    │────▶│  Payment Worker │
└─────────────┘     └─────────────────┘     └─────────────────┘
                            │                        │
                            ▼                        ▼
                    ┌─────────────┐          ┌─────────────┐
                    │  Database   │          │   Stripe    │
                    │  (source    │          │   Adyen     │
                    │  of truth)  │          │   etc.      │
                    └─────────────┘          └─────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key design decisions:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Idempotency is non-negotiable&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every payment request must include a client-generated idempotency key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@PostMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/payments"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;PaymentResponse&lt;/span&gt; &lt;span class="nf"&gt;createPayment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nd"&gt;@RequestHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Idempotency-Key"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nd"&gt;@RequestBody&lt;/span&gt; &lt;span class="nc"&gt;PaymentRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="c1"&gt;// Check if we've seen this request before&lt;/span&gt;
    &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Payment&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;paymentRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByIdempotencyKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isPresent&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;toResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;  &lt;span class="c1"&gt;// Return cached result&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Process new payment&lt;/span&gt;
    &lt;span class="nc"&gt;Payment&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;processPayment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;toResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents duplicate charges when clients retry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Two-phase state machine&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Payments go through explicit states:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;PaymentState&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// Created, not yet sent to provider&lt;/span&gt;
    &lt;span class="no"&gt;PROCESSING&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// Sent to provider, awaiting response&lt;/span&gt;
    &lt;span class="no"&gt;COMPLETED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// Successfully charged&lt;/span&gt;
    &lt;span class="no"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// Provider declined&lt;/span&gt;
    &lt;span class="no"&gt;REFUNDED&lt;/span&gt;      &lt;span class="c1"&gt;// Money returned&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The critical insight: we persist state &lt;em&gt;before&lt;/em&gt; calling the payment provider, and update it &lt;em&gt;after&lt;/em&gt; we get a response. This way, if we crash mid-processing, we can recover:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Payment&lt;/span&gt; &lt;span class="nf"&gt;initiatePayment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Step 1: Create payment record in PENDING state&lt;/span&gt;
    &lt;span class="nc"&gt;Payment&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAmount&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;paymentRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 2: Transition to PROCESSING before calling provider&lt;/span&gt;
    &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PROCESSING&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;paymentRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Separate method, possibly async&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;executePayment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Payment&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;ProviderResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;paymentProvider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;COMPLETED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setProviderTransactionId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTransactionId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentDeclinedException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setFailureReason&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;paymentRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Recovery process for stuck payments&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A background job finds payments stuck in PROCESSING:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Scheduled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixedDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;recoverStuckPayments&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;minus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ChronoUnit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MINUTES&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Payment&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;stuck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;paymentRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByStateAndCreatedBefore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;PaymentState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PROCESSING&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Payment&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;stuck&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Check with provider if payment actually went through&lt;/span&gt;
        &lt;span class="nc"&gt;ProviderStatus&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;paymentProvider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;checkStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getIdempotencyKey&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;COMPLETED&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;COMPLETED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;FAILED&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;NOT_FOUND&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="c1"&gt;// Provider never received it — safe to retry or fail&lt;/span&gt;
                &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setFailureReason&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Provider timeout — please retry"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;paymentRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Provider abstraction for multi-provider support&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PaymentProvider&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ProviderResponse&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentDetails&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;ProviderStatus&lt;/span&gt; &lt;span class="nf"&gt;checkStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;RefundResponse&lt;/span&gt; &lt;span class="nf"&gt;refund&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;transactionId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="nd"&gt;@Primary&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RoutingPaymentProvider&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;PaymentProvider&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PaymentProvider&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;providers&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ProviderResponse&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentDetails&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;PaymentProvider&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;selectProvider&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;PaymentProvider&lt;/span&gt; &lt;span class="nf"&gt;selectProvider&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentDetails&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Route based on card type, amount, merchant category, etc.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAmount&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;compareTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"10000"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;providers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"stripe"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// Lower fees for large transactions&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;providers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"adyen"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// Better for small transactions&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What I'd monitor:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Payment success rate (alert if drops below 95%)&lt;/li&gt;
&lt;li&gt;Provider latency (p99 should be under 2 seconds)&lt;/li&gt;
&lt;li&gt;Stuck payments count (alert if any)&lt;/li&gt;
&lt;li&gt;Idempotency key collision rate (should be near zero)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs I'm making:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Complexity for reliability — this is more complex than a simple REST call to Stripe&lt;/li&gt;
&lt;li&gt;Eventual consistency — the client might get 'processing' and need to poll&lt;/li&gt;
&lt;li&gt;Storage overhead — we're keeping full payment history"&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Part 4: Behavioral Questions That Actually Matter
&lt;/h2&gt;

&lt;p&gt;Senior roles require leadership and judgment. Here's how to demonstrate both.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Tell me about a technical decision you regret"
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Bad answer:&lt;/strong&gt; "I can't think of any" (implies you don't reflect on your work)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Good answer:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"Two years ago, I pushed hard for us to adopt microservices. We were a team of six, and our monolith was getting messy. I'd read all the blog posts about how microservices would solve our problems.&lt;/p&gt;

&lt;p&gt;We spent four months splitting the monolith into eight services. And honestly? It made everything worse.&lt;/p&gt;

&lt;p&gt;Deployments went from 10 minutes to 2 hours because we had to coordinate across services. Debugging a request meant checking logs in five different places. We didn't have the observability infrastructure to support it. And the 'messy monolith' problems? They were still there, just distributed across services now.&lt;/p&gt;

&lt;p&gt;What I learned: &lt;strong&gt;microservices are an organizational scaling solution, not a technical one&lt;/strong&gt;. They make sense when you have multiple teams that need to deploy independently. For a team of six working on one product, a well-structured modular monolith is almost always better.&lt;/p&gt;

&lt;p&gt;We eventually merged three of those services back. It was painful to admit the mistake, but the team's velocity doubled after we simplified."&lt;/p&gt;




&lt;h3&gt;
  
  
  "How do you handle disagreements with other engineers?"
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Senior approach:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"I had a significant disagreement last year about our caching strategy. A colleague wanted to cache aggressively at the application layer using Redis. I thought we should rely more on database query optimization and HTTP caching.&lt;/p&gt;

&lt;p&gt;Here's how I approached it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, I made sure I understood their position&lt;/strong&gt;. Not just what they wanted, but &lt;em&gt;why&lt;/em&gt;. Turned out they'd been burned by a slow database at a previous company, so caching was their instinctive solution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then I focused the discussion on data, not opinions&lt;/strong&gt;. I proposed we measure our actual bottlenecks. We profiled a few endpoints and found that 70% of our latency was network calls to external services, not database queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We found a compromise based on evidence&lt;/strong&gt;. We implemented Redis caching for external API responses (where it genuinely helped) but invested in query optimization for our own database. Both of us got part of what we wanted, and the solution was better than either original proposal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The relationship actually improved&lt;/strong&gt;. Because I'd taken their concerns seriously and proposed measuring instead of arguing, they trusted my judgment more afterward. We've collaborated well since then.&lt;/p&gt;

&lt;p&gt;What I've learned is that technical disagreements are almost never purely technical. There's usually context — past experiences, assumptions, priorities — that shapes someone's position. Understanding that context is often more productive than debating the technical merits."&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Advice
&lt;/h2&gt;

&lt;p&gt;After conducting hundreds of senior interviews, here's what I look for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Depth over breadth&lt;/strong&gt; — I'd rather you know one thing deeply than ten things superficially&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opinions with reasons&lt;/strong&gt; — "I prefer X because Y" is always better than "I use whatever works"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Humility about mistakes&lt;/strong&gt; — The best engineers have strong opinions loosely held&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clear communication&lt;/strong&gt; — If you can't explain it simply, you don't understand it well enough&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Curiosity&lt;/strong&gt; — Do you ask good questions, or just answer what's asked?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The best interview advice I ever got: &lt;strong&gt;treat the interview as a conversation between potential colleagues, not an exam&lt;/strong&gt;. Ask questions. Challenge assumptions. Share your real opinions.&lt;/p&gt;

&lt;p&gt;Good luck.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I've also written guides for &lt;a href="https://dev.to/iamcymentho/mastering-the-senior-c-engineer-interview-3dc2"&gt;C# interviews&lt;/a&gt; if you work across both ecosystems.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's the hardest interview question you've faced? Drop it in the comments — I'll add my take.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>software</category>
      <category>interview</category>
      <category>career</category>
    </item>
    <item>
      <title>Why I Built a Centralised Platform Admin in a Microservices Architecture -And What I Learned</title>
      <dc:creator>Odumosu Matthew</dc:creator>
      <pubDate>Thu, 19 Mar 2026 07:31:34 +0000</pubDate>
      <link>https://dev.to/iamcymentho/why-i-built-a-centralised-platform-admin-in-a-microservices-architecture-and-what-i-learned-7nh</link>
      <guid>https://dev.to/iamcymentho/why-i-built-a-centralised-platform-admin-in-a-microservices-architecture-and-what-i-learned-7nh</guid>
      <description>&lt;h2&gt;
  
  
  It Started With a Slack Message
&lt;/h2&gt;

&lt;p&gt;Our CTO posted in #engineering at 9:47 AM on a Tuesday:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Can someone tell me how many active users we have across all products right now?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Silence. For forty-five minutes.&lt;/p&gt;

&lt;p&gt;Not because nobody cared - because nobody &lt;em&gt;knew&lt;/em&gt;. The auth service had login counts. The user service had registration numbers. The billing service knew who was paying. But no single place could answer: &lt;strong&gt;"How many active users do we have?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That Slack message cost us a morning of three engineers querying different databases and reconciling numbers in a Google Sheet. That's when we knew we had a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Microservices Hangover
&lt;/h2&gt;

&lt;p&gt;Here's the thing nobody warns you about in the "Why Microservices" blog posts: &lt;strong&gt;decentralisation is fantastic for business logic and terrible for everything else.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We had twelve services. Each one managed its own roles. Its own permissions. Its own metrics. Its own version of the truth. And when anyone outside engineering asked a straightforward question - &lt;em&gt;"Who has admin access?" "What's our uptime?" "When did this permission change?"&lt;/em&gt; - the answer was always the same: "Give us a few hours."&lt;/p&gt;

&lt;p&gt;We had three specific nightmares:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Permission Nightmare.&lt;/strong&gt; Service A thought a user was an admin. Service B disagreed. Both were technically correct according to their own databases. A customer escalation later, we discovered the role had been revoked in one service but not the other. Fun times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Dashboard Nightmare.&lt;/strong&gt; Leadership wanted a single executive dashboard. We had twelve Grafana dashboards, each telling a different story. Our "platform uptime" was a vibes-based estimate that someone calculated manually every Monday morning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Audit Nightmare.&lt;/strong&gt; A compliance review asked: &lt;em&gt;"Show us every admin role change in the last 90 days, who approved it, and why."&lt;/em&gt; We spent a week grepping logs across services and reconstructing a timeline in a spreadsheet. The auditor was not impressed.&lt;/p&gt;

&lt;p&gt;So we did what any reasonable team would do: we built another microservice. But this one had a different job.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Platform Admin: One Service to Rule the Cross-Cutting Concerns
&lt;/h2&gt;

&lt;p&gt;The mandate was simple: &lt;strong&gt;own the things that get worse when distributed.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authorization&lt;/strong&gt; - One source of truth for roles and permissions. Every service asks us: "Can this user do this thing?" We answer in under 10ms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability&lt;/strong&gt; - One place where metrics from every service converge into a single dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit&lt;/strong&gt; - One immutable, append-only log of every permission change, role assignment, and admin action across the entire platform.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not a God service. Not a monolith in disguise. A focused service with a clear boundary: &lt;strong&gt;cross-cutting platform governance.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's what we learned building it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bet #1: Let Services Push Metrics (Don't Pull)
&lt;/h2&gt;

&lt;p&gt;Our first instinct was to have the Platform Admin poll every service. Hit their health endpoints, scrape their stats, aggregate.&lt;/p&gt;

&lt;p&gt;Then someone on the team asked the obvious question: &lt;em&gt;"What happens when the auth service is down and we're trying to pull its uptime metric?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Right. The monitoring system can't depend on the availability of the things it's monitoring. That's not architecture — that's a comedy sketch.&lt;/p&gt;

&lt;p&gt;So we flipped it. Every service pushes its own metrics to us via POST endpoints. Auth service reports its call counts. Payment service reports its API volumes. User service reports active users. We receive, validate, store, and serve.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Auth Service ──→ POST /ingest/subsystem-metrics ──→ Platform Admin DB
Payment Service ──→ POST /ingest/api-calls ──→ Platform Admin DB
User Service ──→ POST /ingest/active-users ──→ Platform Admin DB

Frontend ←── GET /dashboard/executive-summary ←── Platform Admin DB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The beauty of this: &lt;strong&gt;we don't need to know about new services in advance.&lt;/strong&gt; A team spins up a new microservice, starts posting to our ingestion endpoint, and their metrics appear on the dashboard. Zero configuration on our side.&lt;/p&gt;

&lt;p&gt;And when a service goes silent? Its metrics expire via TTL. The absence of data &lt;em&gt;is&lt;/em&gt; the signal. No heartbeat checks needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bet #2: One Table for All Metrics (Yes, Really)
&lt;/h2&gt;

&lt;p&gt;We needed to serve seven different metric types on the dashboard. The obvious move was seven database tables with seven migrations and seven sets of CRUD operations. Then someone on the team muttered: "And next quarter, when product wants three more metric types...?"&lt;/p&gt;

&lt;p&gt;Instead, we went with a single table. One row per metric. A composite string key, a JSONB value column, and a TTL timestamp. That's it.&lt;/p&gt;

&lt;p&gt;Want to add a new metric type? Create a DTO, pick a key prefix, and start writing. No migration. No PR to update the schema. No waiting for the DBA.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"But you can't query individual fields inside the JSON!"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Correct. And we don't need to. Dashboard data is always read as a complete object and handed to the frontend. We're not running analytical queries against it. We're serialising, storing, and deserialising. The table is a smart cache with persistence, not a relational data model.&lt;/p&gt;

&lt;p&gt;Months later, we've added four more metric types. Zero migrations. The table doesn't care.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bet #3: Validate Tenants at the Gate, Not in Business Logic
&lt;/h2&gt;

&lt;p&gt;Multi-tenant platforms have a sneaky bug pattern: someone passes a tenant ID that doesn't exist, and the system happily returns empty results instead of saying &lt;em&gt;"that's not a real tenant."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We caught this one during testing when someone called our endpoint with &lt;code&gt;/dashboard/metrics/banana/30&lt;/code&gt; and got a 200 OK with zeros. Technically correct — there's no data for the "banana" tenant. Practically useless - it should have been a 400 error.&lt;/p&gt;

&lt;p&gt;So we built tenant validation into the request pipeline. Every request with a tenant identifier gets checked against the database before it reaches any business logic. The special keyword &lt;code&gt;all&lt;/code&gt; (meaning "aggregate across all tenants") short-circuits and never touches the database.&lt;/p&gt;

&lt;p&gt;Since tenant codes change roughly never, we cache the valid set in a &lt;code&gt;HashSet&lt;/code&gt; for 10 minutes. The hot path is an O(1) lookup. The cold path hits the database once every 10 minutes. Thirteen endpoints, one validation rule, enforced consistently through FluentValidation in the MediatR pipeline. No endpoint can skip it, even by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bet #4: CQRS, Because Reads and Writes Live Different Lives
&lt;/h2&gt;

&lt;p&gt;Here's the access pattern for a platform admin:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Writes&lt;/strong&gt;: Rare, high-stakes. An admin changes a role, ingests metrics, creates an audit entry. Maybe a few hundred writes per hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reads&lt;/strong&gt;: Constant, latency-sensitive. Permission checks from other services, dashboard queries from the frontend, audit log lookups. Thousands per minute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treating these the same way is like designing a library and a nightclub with the same floor plan. They have fundamentally different requirements.&lt;/p&gt;

&lt;p&gt;CQRS let us optimise each side independently. The write side does thorough validation, persists carefully, and emits domain events. The read side is aggressively cached and optimised for speed. A permission check from another service hits the cache and returns in single-digit milliseconds. The write path doesn't even notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bet #5: Side Effects Belong in Event Handlers, Not Command Handlers
&lt;/h2&gt;

&lt;p&gt;When an admin assigns a role, here's everything that needs to happen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Validate the request&lt;/li&gt;
&lt;li&gt;Persist the role assignment&lt;/li&gt;
&lt;li&gt;Invalidate the permission cache for that user&lt;/li&gt;
&lt;li&gt;Send an email notification to the affected user&lt;/li&gt;
&lt;li&gt;Notify all admins for visibility&lt;/li&gt;
&lt;li&gt;Write an immutable audit log entry&lt;/li&gt;
&lt;li&gt;Refresh platform statistics cache&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Our first version had all seven steps in the command handler. It was 200+ lines, touched four different infrastructure services, and was a nightmare to test. Mocking the email service to test the cache invalidation? Really?&lt;/p&gt;

&lt;p&gt;We refactored to domain events. The command handler does steps 1 and 2 - validate and persist. Then it publishes an event: &lt;em&gt;"A role was assigned."&lt;/em&gt; Separate, independent handlers pick up the event and handle their own concern. Cache handler invalidates keys. Notification handler fires emails (fire-and-forget, never blocking the response). Audit handler writes the log.&lt;/p&gt;

&lt;p&gt;Now each handler is 20 lines, testable in isolation, and completely unaware of the others. Adding a new side effect - say, posting to a Slack channel,  means adding a new event handler. No existing code changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bet #6: Don't Make Reads Pay for Expired Data
&lt;/h2&gt;

&lt;p&gt;Metrics have a TTL. The lazy approach: add &lt;code&gt;WHERE ExpiresAt &amp;gt; NOW()&lt;/code&gt; to every query. It works... until you have 50,000 expired rows cluttering the table and your index scans are doing unnecessary work.&lt;/p&gt;

&lt;p&gt;We built a background service that wakes up every 30 minutes, deletes expired metrics in batches, and goes back to sleep. Batch size is capped so a single cleanup cycle can't lock the table. If it hits the limit, it logs a warning so ops can tune the configuration.&lt;/p&gt;

&lt;p&gt;The metrics table stays lean. Queries stay fast. And instead of every read request paying a tiny tax to filter garbage, a background job handles it once in bulk. It's not clever — it's just good housekeeping.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bet #7: Design the Audit Log for the Auditor, Not the Developer
&lt;/h2&gt;

&lt;p&gt;Our first audit log was a &lt;code&gt;message&lt;/code&gt; text column with entries like: &lt;em&gt;"User &lt;a href="mailto:admin@company.com"&gt;admin@company.com&lt;/a&gt; assigned role Admin to user &lt;a href="mailto:john@company.com"&gt;john@company.com&lt;/a&gt;"&lt;/em&gt;. Human-readable. Unsearchable. Useless for compliance.&lt;/p&gt;

&lt;p&gt;The redesigned audit log captures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Who&lt;/strong&gt; initiated the action (with IP address)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who&lt;/strong&gt; was the target&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What&lt;/strong&gt; changed (previous state → new state, as structured JSON)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When&lt;/strong&gt; it happened&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why&lt;/strong&gt; (a mandatory reason field)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Whether it succeeded or failed&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's append-only. No updates. No deletes. Not even soft deletes. Once a record exists, it exists forever. That's not a technical constraint — it's a compliance requirement. When an auditor asks &lt;em&gt;"has anyone tampered with these logs?"&lt;/em&gt; the answer needs to be architecturally impossible, not "we promise we didn't."&lt;/p&gt;

&lt;p&gt;We also added CSV export, because auditors don't want to learn your API. They want to open a file in Excel, apply some filters, and go home. Meeting your users where they are applies to internal users too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Caching Philosophy That Changed Everything
&lt;/h2&gt;

&lt;p&gt;Here's what I wish someone had told me earlier: &lt;strong&gt;caching isn't a performance optimisation. It's an architectural decision.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When fifteen microservices are calling your permission endpoint on every request, the difference between 200ms and 5ms is the difference between a platform that feels snappy and one that feels sluggish. And the bottleneck isn't your code — it's the database round trip.&lt;/p&gt;

&lt;p&gt;Our caching strategy is tiered by how often data actually changes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data&lt;/th&gt;
&lt;th&gt;Cache TTL&lt;/th&gt;
&lt;th&gt;Invalidation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;User permissions&lt;/td&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;td&gt;Immediate on role change (domain event)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reference data (role list)&lt;/td&gt;
&lt;td&gt;30 min&lt;/td&gt;
&lt;td&gt;On any role modification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard metrics&lt;/td&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;td&gt;On ingestion writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tenant codes&lt;/td&gt;
&lt;td&gt;10 min&lt;/td&gt;
&lt;td&gt;Almost never changes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The critical insight: &lt;strong&gt;TTLs are safety nets, not invalidation strategies.&lt;/strong&gt; When we know data changed (because we just processed the command), we invalidate immediately via domain events. The TTL only exists for the edge case where an event gets lost or a cache entry somehow becomes stale.&lt;/p&gt;

&lt;p&gt;This gives us sub-10ms permission checks in production. Other services call us on every request and barely notice the network hop. That's not optimisation - that's making centralised auth viable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest Mistakes We Made
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;We bolted on audit logging after building RBAC.&lt;/strong&gt; Retrofitting event publishing into existing command handlers was a week of tedious work. If we'd designed the audit schema first, every handler would have been built with events from day one. Lesson: compliance requirements should shape your architecture, not be stapled on later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We let each team define their own ingestion payload.&lt;/strong&gt; One team used &lt;code&gt;count&lt;/code&gt;, another used &lt;code&gt;total&lt;/code&gt;, a third used &lt;code&gt;value&lt;/code&gt;. Same concept, three different field names. A shared contract or schema registry would have saved us a normalisation sprint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We underinvested in cache invalidation testing.&lt;/strong&gt; Our unit tests mocked the cache and passed beautifully. The bugs lived in timing — a role change followed by an immediate permission check, where the cache hadn't been invalidated yet. These only surfaced under concurrent load testing. Test your cache invalidation under race conditions, not just in isolation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Looks Like Today
&lt;/h2&gt;

&lt;p&gt;Thirteen endpoints. Six ingestion (microservices pushing data in), seven dashboard (frontend pulling data out). Every single one has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tenant validation against the database&lt;/li&gt;
&lt;li&gt;Input validation in the request pipeline&lt;/li&gt;
&lt;li&gt;JWT authentication&lt;/li&gt;
&lt;li&gt;In-memory caching with event-driven invalidation&lt;/li&gt;
&lt;li&gt;Structured logging&lt;/li&gt;
&lt;li&gt;Automated TTL cleanup&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our CTO's question - &lt;em&gt;"How many active users do we have?"&lt;/em&gt; — now takes about 3 seconds to answer. Open the dashboard. It's right there.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR for the Architects
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Centralise cross-cutting concerns, decentralise business logic.&lt;/strong&gt; Auth, audit, and observability get worse when spread across services. Give them a home.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Push over pull for metrics.&lt;/strong&gt; The monitoring system shouldn't depend on the availability of what it's monitoring.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Schema flexibility beats relational purity for metrics.&lt;/strong&gt; A key-value store with JSONB means zero migrations when product wants new dashboard widgets.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Event-driven cache invalidation, not just TTLs.&lt;/strong&gt; You know when data changes. Act on it immediately.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design for the auditor.&lt;/strong&gt; Immutable logs, CSV exports, structured change records. It feels like overhead until the compliance review. Then it's the best thing you built.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Caching is architecture.&lt;/strong&gt; When your service is in the critical path of every other service, response time isn't a nice-to-have — it's existential.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Building platform infrastructure isn't the kind of work that gets you on stage at a conference. Nobody's tweeting about your audit log schema. But the next time an auditor asks &lt;em&gt;"who had admin access on March 3rd?"&lt;/em&gt; and you answer in three seconds flat — or when the CEO opens one dashboard instead of pinging five engineering leads — you'll know it was worth it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with .NET 9, Clean Architecture, CQRS, Entity Framework Core, and PostgreSQL.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>softwaredevelopment</category>
      <category>programming</category>
      <category>ai</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
