<?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: KitKeen</title>
    <description>The latest articles on DEV Community by KitKeen (@kitkeen_55).</description>
    <link>https://dev.to/kitkeen_55</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3848864%2F51829748-d666-494f-b67b-f6fd26fe4a3f.png</url>
      <title>DEV Community: KitKeen</title>
      <link>https://dev.to/kitkeen_55</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kitkeen_55"/>
    <language>en</language>
    <item>
      <title>I shipped a NuGet package, then rewrote it completely. Here's why.</title>
      <dc:creator>KitKeen</dc:creator>
      <pubDate>Sat, 18 Apr 2026 14:26:21 +0000</pubDate>
      <link>https://dev.to/kitkeen_55/i-shipped-a-nuget-package-then-rewrote-it-completely-heres-why-2ngk</link>
      <guid>https://dev.to/kitkeen_55/i-shipped-a-nuget-package-then-rewrote-it-completely-heres-why-2ngk</guid>
      <description>&lt;p&gt;If you work in fintech and process card transactions, you've seen MCC codes. Merchant Category Code- a four-digit number that tells you what kind of business charged the card. &lt;code&gt;5411&lt;/code&gt; is a grocery store. &lt;code&gt;5812&lt;/code&gt; is a restaurant. &lt;code&gt;7995&lt;/code&gt; is a casino.&lt;/p&gt;

&lt;p&gt;Every product that touches transactions eventually needs to turn these codes into something a human can understand. Cashback rules, spend controls, analytics dashboards, compliance reports- they all need the same thing: given a code, what category is this?&lt;/p&gt;

&lt;p&gt;I've built this mapping three times in production. Three separate microservices at the same company, each with its own copy. When one got updated, the others didn't. When the business asked "why is 7995 categorized as Entertainment in the app but Gambling in the compliance report?"- nobody had a good answer.&lt;/p&gt;

&lt;p&gt;So I extracted it into a NuGet package. Then I looked at what I had actually built, benchmarked it, and rewrote it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What v1 looked like
&lt;/h2&gt;

&lt;p&gt;The first version used a &lt;code&gt;FrozenDictionary&amp;lt;string, MccCategory&amp;gt;&lt;/code&gt;. Seemed reasonable- build once, read forever, immutable.&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;// v1&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;FrozenDictionary&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;,&lt;/span&gt; &lt;span class="n"&gt;MccCategory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Codes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;BuildCodes&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;static&lt;/span&gt; &lt;span class="n"&gt;MccCategory&lt;/span&gt; &lt;span class="nf"&gt;Categorize&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;mccCode&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;Codes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetValueOrDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mccCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MccCategory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Other&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It worked. But there were three problems I didn't like.&lt;/p&gt;

&lt;p&gt;First: every call allocates or assumes the caller already has a string. In a high-throughput service processing thousands of transactions per second, that adds up.&lt;/p&gt;

&lt;p&gt;Second: &lt;code&gt;FrozenDictionary&lt;/code&gt; hashes strings. But MCC codes are 4-digit numbers. There's a faster structure for this- one that doesn't need hashing at all.&lt;/p&gt;

&lt;p&gt;Third: there was no way for a team with non-standard mappings to override anything without forking the library.&lt;/p&gt;




&lt;h2&gt;
  
  
  What v2 actually does
&lt;/h2&gt;

&lt;p&gt;MCC codes are integers in the range &lt;code&gt;[0, 9999]&lt;/code&gt;. That's a fixed-size space. So instead of a dictionary, v2 uses a plain array indexed directly by the numeric code value:&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;internal&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;TableSize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;;&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;MccCategory&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;_codes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// indexed by MCC integer&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;_occupied&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// tracks which slots are in the taxonomy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lookup is a direct array read:&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;MccCategory&lt;/span&gt; &lt;span class="nf"&gt;Categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;mccCode&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="kt"&gt;uint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;mccCode&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;TableSize&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;_occupied&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mccCode&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;MccCategory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Uncategorized&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;_codes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mccCode&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No hash function. No bucket traversal. One bounds check, one bool check, one array read. That's it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three overloads, one of which is allocation-free
&lt;/h2&gt;

&lt;p&gt;The real-world caller is often processing a raw string from a payment event, but might have an integer from a database column or a span from a buffer. v2 handles all three:&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;MccLookup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5411&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// int- fastest path&lt;/span&gt;
&lt;span class="n"&gt;MccLookup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"5411"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// string- still zero-alloc after parse&lt;/span&gt;
&lt;span class="n"&gt;MccLookup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;             &lt;span class="c1"&gt;// ReadOnlySpan&amp;lt;char&amp;gt;- allocation-free&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ReadOnlySpan&amp;lt;char&amp;gt;&lt;/code&gt; overload uses a custom parser instead of &lt;code&gt;int.TryParse&lt;/code&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;TryParseMcc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReadOnlySpan&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;char&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&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="m"&gt;0&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;s&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;0&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;s&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;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&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;result&lt;/span&gt; &lt;span class="p"&gt;=&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;for&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;i&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;s&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="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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&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;c&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="sc"&gt;'0'&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="sc"&gt;'9'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sc"&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;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;true&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;Why not &lt;code&gt;int.TryParse&lt;/code&gt;? On &lt;code&gt;netstandard2.0&lt;/code&gt;, the &lt;code&gt;ReadOnlySpan&amp;lt;char&amp;gt;&lt;/code&gt; overload of &lt;code&gt;int.TryParse&lt;/code&gt; doesn't exist. This custom parser works identically across all target frameworks, is measurably faster on the hot path, and rejects non-digit characters explicitly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Extensibility via IMccLookup and WithCustomCodes
&lt;/h2&gt;

&lt;p&gt;The built-in taxonomy covers ISO 18245. But teams have non-standard codes- internal test MCCs, network-specific extensions, or just a different opinion about which category &lt;code&gt;5962&lt;/code&gt; belongs to.&lt;/p&gt;

&lt;p&gt;v2 exposes &lt;code&gt;IMccLookup&lt;/code&gt; and lets you override without touching the default:&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;IMccLookup&lt;/span&gt; &lt;span class="n"&gt;custom&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MccLookup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithCustomCodes&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;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MccCategory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;9999&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MccCategory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Finance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// add an unknown code&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;7995&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MccCategory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Leisure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// override the built-in mapping&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// The built-in default is not modified- it's immutable&lt;/span&gt;
&lt;span class="n"&gt;custom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;9999&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// Finance&lt;/span&gt;
&lt;span class="n"&gt;MccLookup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;9999&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Uncategorized- unchanged&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;WithCustomCodes&lt;/code&gt; clones the underlying arrays and applies overrides. The original instance is untouched. Thread-safe by design- no locks, no shared mutable state.&lt;/p&gt;




&lt;h2&gt;
  
  
  The pre-built category index
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GetCodes(MccCategory.Airlines)&lt;/code&gt; needs to return all ~351 airline codes. In v1 this meant scanning the entire dictionary. In v2, a secondary index is built at construction time:&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;readonly&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MccCategory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;]&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_codesByCategory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;GetCodes&lt;/code&gt; and &lt;code&gt;GetCodeValues&lt;/code&gt; run in O(N) over the codes in that category- not O(10000) over the whole table.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the package gives you
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Most common: categorize by string&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cat&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MccLookup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"5411"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// Supermarkets&lt;/span&gt;

&lt;span class="c1"&gt;// By integer (no string involved)&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cat&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MccLookup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5411&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// Supermarkets&lt;/span&gt;

&lt;span class="c1"&gt;// Distinguish known from unknown&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;MccLookup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetCategory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"5812"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;c&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;c&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                      &lt;span class="c1"&gt;// FoodAndDining&lt;/span&gt;

&lt;span class="c1"&gt;// Reverse lookup&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;codes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MccLookup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetCodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MccCategory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Airlines&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// "3000".."3350" + 2 more&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ints&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MccLookup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetCodeValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MccCategory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Airlines&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Custom overrides&lt;/span&gt;
&lt;span class="n"&gt;IMccLookup&lt;/span&gt; &lt;span class="n"&gt;lookup&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MccLookup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithCustomCodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;overrides&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No dependencies. No DI registration. Targets .NET 6.0+ and .NET Standard 2.0.&lt;/p&gt;




&lt;h2&gt;
  
  
  Numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;27 categories, ~900 MCC codes (ISO 18245 + network-specific)&lt;/li&gt;
&lt;li&gt;Array-based O(1) lookup, zero allocations on hot path&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ReadOnlySpan&amp;lt;char&amp;gt;&lt;/code&gt; overload for buffer-friendly callers&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WithCustomCodes&lt;/code&gt; for teams with non-standard mappings&lt;/li&gt;
&lt;li&gt;.NET 6.0, .NET Standard 2.0, MIT license&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The package is on &lt;a href="https://www.nuget.org/packages/MccTaxonomy" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt; and the source is on &lt;a href="https://github.com/pashakuraev/MccTaxonomy" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;The v1 was fine. The v2 is what I'd actually want to use in a service that handles thousands of transactions per second. Sometimes you need to ship something to understand what it should have been.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>opensource</category>
      <category>fintech</category>
      <category>performance</category>
    </item>
    <item>
      <title>Reliability Patterns for Asynchronous APIs in Fintech: A Migration Guide</title>
      <dc:creator>KitKeen</dc:creator>
      <pubDate>Sun, 12 Apr 2026 09:37:05 +0000</pubDate>
      <link>https://dev.to/kitkeen_55/reliability-patterns-for-asynchronous-apis-in-fintech-a-migration-guide-2k21</link>
      <guid>https://dev.to/kitkeen_55/reliability-patterns-for-asynchronous-apis-in-fintech-a-migration-guide-2k21</guid>
      <description>&lt;p&gt;&lt;strong&gt;Disclaimer&lt;/strong&gt;: The views and opinions expressed in this article are strictly my own and do not reflect the official policy or position of my employer. The architecture, system designs, and workflows discussed have been abstracted, generalized, and simplified for educational purposes. All metrics, timelines, and numbers are approximate or illustrative and do not represent actual company data. No confidential, proprietary, or sensitive business information is disclosed in this post.&lt;/p&gt;




&lt;p&gt;Our core banking provider, NexusBank, sent us a notice: their synchronous account-opening API was being deprecated. The deadline was strict- if we didn't migrate to their new asynchronous webhook-based flow within two weeks, new customer KYC in Belgium and Netherlands would stop completely.&lt;/p&gt;

&lt;p&gt;If you work in fintech, you know that "account opening" is the critical backend pipeline that assigns a real IBAN to a user. Moving this flow from synchronous to asynchronous is not just about replacing one endpoint request with another. It is a fundamental shift in the reliability paradigm of your system. &lt;/p&gt;

&lt;p&gt;When you lose synchronous immediate feedback, you can no longer treat interactions as a single atomic request-response transaction. This article serves as an engineering playbook, detailing the architectural patterns and checklists needed to safely migrate a critical distributed pipeline to an asynchronous model.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The Architecture Shift: From Blocking to State Machines
&lt;/h2&gt;

&lt;p&gt;In the old synchronous model, our account-opening pipeline was a linear sequence that was easy to reason about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1] Create User Profile →
[2] Request Provider Account (Blocks) → 
[3] Issue Virtual Card
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each step ran in sequence. Step 2 blocked the execution thread until the bank generated and returned the new IBAN. &lt;/p&gt;

&lt;p&gt;With the asynchronous provider, this atomic pipeline breaks. The account creation API now returns instantly with just a tracking_id. The actual IBAN arrives via a webhook at an unknown point in the future. Since we can't reliably predict when that happens, blocking a thread is no longer viable.&lt;/p&gt;

&lt;p&gt;To handle this without rewriting our entire workflow engine, we split the flow and shifted to a state machine pattern by introducing a targeted "Wait State":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Phase 1: Initiation
[1] Create User Profile → 
[2] Request Provider Account (Async) → *(Pipeline Pauses)*

Phase 2: Completion
*(Webhook Arrives)* → 
[3] Match Tracking ID &amp;amp; Save IBAN → 
[4] Issue Virtual Card
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the crucial implementation detail: during Phase 1, we immediately create the account record in our database with an InProgress status and an empty IBAN/BIC. &lt;/p&gt;

&lt;p&gt;This is critical: the system &lt;em&gt;must&lt;/em&gt; have a persistent local database object to act as an anchor. You never want to hold memory or threads ransom waiting for a callback. When the webhook eventually arrives, it finds this pending record, updates the status, fills in the IBAN, and gracefully triggers the pipeline to resume.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Handling Transport Unreliability and Fallbacks
&lt;/h2&gt;

&lt;p&gt;Asynchronous integrations are common in fintech, but the primary engineering challenge is delivery guarantees. Webhooks can be delayed, delivered out of order, or simply lost. &lt;/p&gt;

&lt;p&gt;We already knew NexusBank webhooks occasionally experienced production delays. You cannot rely purely on the provider calling you back. You must design a deterministic fallback path.&lt;/p&gt;

&lt;p&gt;Our fallback policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Webhook arrives
  ├─ COMPLETED / REJECTED
  │    └─ process terminal status, resume orchestration
  │
  └─ INITIATED / IN_PROGRESS (provider still working)
       └─ schedule retry in 30 min
            └─ poll provider status API directly
                 ├─ terminal status → process
                 └─ still pending → retry
                      └─ 12 hours elapsed?
                           ├─ no  → schedule another retry
                           └─ yes → emit escalation event
                                     → create ops investigation ticket
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By introducing a polling fallback mechanism that terminates after 12 hours, we bounded our waiting time. If a webhook is permanently lost, the system automatically creates a Jira ticket (&lt;strong&gt;AccountOpeningWebhookNotReceived&lt;/strong&gt;) with a deep link for manual investigation. No orphaned records, no silent failures.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Deduplication: Cache for Burst Protection
&lt;/h2&gt;

&lt;p&gt;Providers often guarantee "at-least-once" delivery, making duplicate webhooks inevitable. A duplicate webhook can cause severe logical errors if processed twice.&lt;/p&gt;

&lt;p&gt;To handle this, we implemented a cache-based deduplication layer with a specific key structure:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;account_opening_request_id_{aorId}_{status}&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Why include &lt;code&gt;status&lt;/code&gt; in the key? Because a single request ID legitimately transitions through multiple states over time (INITIATED -&amp;gt; IN_PROGRESS -&amp;gt; SUCCEEDED). Processing the "same request" with a "new status" is a valid state transition.&lt;/p&gt;

&lt;p&gt;Why Cache instead of the Database? The purpose of this specific lock is to absorb &lt;em&gt;immediate retry bursts&lt;/em&gt; (e.g., the provider sending the same webhook three times in two seconds), not long-term state idempotency. The cache key expires after 15 minutes. Long-term idempotency is handled inherently by our database's orchestration state (a SUCCEEDED account cannot be transitioned to SUCCEEDED twice). The cache simply acts as a fast, lightweight shield against transient network spam.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Testing Distributed Workflows with a Sandbox
&lt;/h2&gt;

&lt;p&gt;Testing async integrations by mocking external APIs locally often fails to catch edge cases in the webhook processing layer. &lt;/p&gt;

&lt;p&gt;Instead of relying on fragile mocks, we built a dedicated Sandbox Endpoint (POST /company/account-opening/set-status-sandbox) in our non-production environments. You send it a request ID and a target status. It publishes a simulated internal webhook message that flows through the &lt;em&gt;exact same production processing pipeline&lt;/em&gt; as a real external webhook.&lt;/p&gt;

&lt;p&gt;This simulated message hits the deduplication locks, triggers state transitions, and advances the orchestration pipeline. This allowed us to write repeatable, end-to-end integration tests without depending on third-party staging environments.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Safe Rollouts via Feature Flags
&lt;/h2&gt;

&lt;p&gt;Migrating a core flow with a hard deadline leaves no room for "big bang" release failures. I wrapped the dispatching logic behind dynamic feature flags scoped by market:&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;isAsyncEnabled&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;featureFlags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsEnabledAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Features&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AsyncAccountOpening&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;marketCode&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;isAsyncEnabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;syncOpenAccountService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&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;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncOpenAccountService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach allowed us to deploy the new microservices before the deadline alongside the legacy code. We rolled out to Netherlands (companies, then freelancers) and then Belgium. At each step, if we detected malformed webhook payloads or provider instability, the feature flag allowed us to instantly reroute traffic back to the legacy sync flow- zero service interruptions and no emergency rollback deployments required.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaways Checklist for Backend Teams
&lt;/h2&gt;

&lt;p&gt;Moving to webhook-driven architectures requires an intentional design shift:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;State Representation: Does your system explicitly track an "In Progress" database state that acts as an anchor for incoming callbacks?&lt;/li&gt;
&lt;li&gt;Burst Protection: Are you deduplicating rapid webhook retries (e.g., using a short-lived cache lock with a composite key of ID + Status)?&lt;/li&gt;
&lt;li&gt;Bounded Waiting: Do you have a fallback mechanism (like polling) to catch missing webhooks and prevent indefinite pipeline hanging?&lt;/li&gt;
&lt;li&gt;Automated Escalation: What happens when the bounds are exceeded? (e.g., automatically generating an Ops Jira ticket).&lt;/li&gt;
&lt;li&gt;End-to-End Testing: Can you inject simulated webhooks deep into your pipeline to test the actual handling logic?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Incidents in webhook architectures are rarely caused by business logic bugs; they are caused by false assumptions about transport reliability. Design for failure from day one.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>architecture</category>
      <category>webhooks</category>
      <category>fintech</category>
    </item>
    <item>
      <title>We had a bug in production for 16 days. It made more money than most features I've shipped.</title>
      <dc:creator>KitKeen</dc:creator>
      <pubDate>Fri, 03 Apr 2026 12:08:05 +0000</pubDate>
      <link>https://dev.to/kitkeen_55/we-had-a-bug-in-production-for-16-days-it-made-more-money-than-most-features-ive-shipped-2jci</link>
      <guid>https://dev.to/kitkeen_55/we-had-a-bug-in-production-for-16-days-it-made-more-money-than-most-features-ive-shipped-2jci</guid>
      <description>&lt;p&gt;Disclaimer: To keep my lawyers happy and respect my NDA, I’ve "randomized" the actual dollar amounts in this story. While the €350k+ figures are placeholders, the 73% growth and the "accidental" logic behind it are 100% real.&lt;/p&gt;




&lt;p&gt;+70% revenue. Not from a new feature. Not from a redesign. Not from a growth hack some PM spent three quarters planning.&lt;/p&gt;

&lt;p&gt;From a bug. A config error that sat in production for sixteen days, silently funnelling every new user in one of our European markets into the most expensive plan.&lt;/p&gt;

&lt;p&gt;When someone finally noticed, the team wanted a hotfix and a post-mortem. I wanted to see what's in the database.&lt;/p&gt;




&lt;h2&gt;
  
  
  5% vs 43%
&lt;/h2&gt;

&lt;p&gt;Here's what I found.&lt;/p&gt;

&lt;p&gt;Before the bug, 5% of new users in that market picked the premium plan. That was the normal baseline. Everyone else scrolled down to the cheapest option and hit Continue.&lt;/p&gt;

&lt;p&gt;During the bug- when premium was the default- &lt;strong&gt;43% kept it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Forty-three percent.&lt;/p&gt;

&lt;p&gt;The onboarding screen wasn't hiding anything. The price was right there. "Change plan" was one click away. Nobody was forced into anything. Almost half the users just looked at the premium plan and thought: yeah, this works for me.&lt;/p&gt;

&lt;p&gt;I didn't believe it at first. Classic survivorship bias, right? They selected it, but did they actually pay?&lt;/p&gt;

&lt;p&gt;They did.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;About 40%&lt;/strong&gt; opened and activated their accounts&lt;/li&gt;
&lt;li&gt;Of those who stayed on the premium default, &lt;strong&gt;nearly half&lt;/strong&gt; made real payments within the first month.&lt;/li&gt;
&lt;li&gt;Only &lt;strong&gt;16%&lt;/strong&gt; downgraded later&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The funnel shape was identical to the control group. Same activation rate, same payment rate. The only thing that changed was how many people entered the premium funnel. And that number was &lt;strong&gt;nearly 9x higher&lt;/strong&gt; because of a bug.&lt;/p&gt;




&lt;h2&gt;
  
  
  The number no one expected
&lt;/h2&gt;

&lt;p&gt;I pulled the revenue numbers for both cohorts over the same period.&lt;/p&gt;

&lt;p&gt;Normal users: &lt;strong&gt;~€340,000/month.&lt;/strong&gt;&lt;br&gt;
Bug users: &lt;strong&gt;~€590,000/month.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Over 70% uplift.&lt;/strong&gt; Same product. The only difference was which plan showed up first.&lt;/p&gt;

&lt;p&gt;Let that sink in. A config error- an actual production defect- generated more incremental revenue than entire features that took months to build.&lt;/p&gt;


&lt;h2&gt;
  
  
  I didn't file a post-mortem. I filed a proposal.
&lt;/h2&gt;

&lt;p&gt;This is where most stories end. Bug found, bug fixed, regression test added, everyone moves on. That's what a responsible engineer does.&lt;/p&gt;

&lt;p&gt;I did the irresponsible thing. I went to the product team and said: don't fix this. Let me turn it into a controlled experiment.&lt;/p&gt;

&lt;p&gt;The bug had accidentally created a perfect A/B test- no controls, no tracking, no consent framework, but a screaming signal. If we could reproduce it properly- with feature flags, per-country segmentation, and real cohort tracking- we'd know whether this was noise or an actual insight about how users make decisions.&lt;/p&gt;

&lt;p&gt;They said yes.&lt;/p&gt;


&lt;h2&gt;
  
  
  The implementation
&lt;/h2&gt;

&lt;p&gt;The whole thing was embarrassingly simple.&lt;/p&gt;

&lt;p&gt;A feature-toggled tariff resolver that runs at registration time. Three conditions checked in sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveTariff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;not&lt;/span&gt; &lt;span class="nx"&gt;experiment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;defaultPlan&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;not&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;experiment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;targetSegments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;defaultPlan&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;experiment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;    &lt;span class="c1"&gt;// premium&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any condition fails- default plan. No impact on anyone outside the experiment. No latency. A few in-memory lookups against cached config.&lt;/p&gt;

&lt;p&gt;Each country had its own toggle. Kill one market without touching another. Add a new country without a deploy- just a config change.&lt;/p&gt;

&lt;p&gt;That's it. That's the whole feature. A senior engineer could review this in ten minutes. A junior could build it in a day.&lt;/p&gt;

&lt;p&gt;The hard part was never the code. The hard part was not fixing the bug.&lt;/p&gt;




&lt;h2&gt;
  
  
  It worked. Again.
&lt;/h2&gt;

&lt;p&gt;The original market went first- we already had the accidental baseline. The experiment ran for a full billing cycle: real payments, not just plan selections.&lt;/p&gt;

&lt;p&gt;The 43% selection rate from the bug period reproduced almost exactly under controlled conditions. Revenue uplift held.&lt;/p&gt;

&lt;p&gt;We expanded to a second European market. Same results.&lt;/p&gt;

&lt;p&gt;The product team's conclusion:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The experiment was rather successful. We make the premium plan the recommended default during onboarding. We start the A/B experiment in the next market to check whether we'll get the same effect."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The "experiment" became the default. The feature flag stayed- as a kill switch, not an experiment.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this actually taught me
&lt;/h2&gt;

&lt;p&gt;I've shipped features with months of work behind them that moved metrics by low single digits. This one was a three-condition &lt;code&gt;if&lt;/code&gt; statement that moved revenue by more than 70%.&lt;/p&gt;

&lt;p&gt;There's a lesson here that most backend engineers never learn, because we're trained to think our value is in the complexity of what we build. Distributed systems, event sourcing, saga patterns, microservice choreography- that's the hard stuff, and the hard stuff is what matters. Right?&lt;/p&gt;

&lt;p&gt;Wrong. The most impactful thing I did that year was stare at a SQL query for twenty minutes. The code I wrote afterward was trivial. A junior could do it. What a junior couldn't do- and what most seniors don't do- is pause before the fix and ask: &lt;em&gt;what is the bug actually telling us?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every production incident has a signal in it. Most of the time the signal is: something is broken, fix it. But occasionally- rarely- the signal is: your assumptions about user behaviour are wrong, and the bug just proved it.&lt;/p&gt;

&lt;p&gt;No product manager would have proposed "show every user the most expensive plan by default." It sounds predatory. It sounds like a dark pattern. Except the data shows the opposite- users weren't tricked. They made informed decisions. They just needed better defaults.&lt;/p&gt;




&lt;h2&gt;
  
  
  The uncomfortable takeaway
&lt;/h2&gt;

&lt;p&gt;If you're a backend engineer and you've never looked at the business impact of your code- you're flying blind.&lt;/p&gt;

&lt;p&gt;Not because revenue is your job. It's not. But because understanding what your code &lt;em&gt;does to the business&lt;/em&gt; changes the kind of decisions you make. It's the difference between "I fixed the bug" and "I found a pricing insight worth millions a year."&lt;/p&gt;

&lt;p&gt;I could have closed the ticket in an hour. Instead, I spent a day in the data, wrote a proposal, and changed the default pricing strategy across multiple markets.&lt;/p&gt;

&lt;p&gt;The code was the easiest part. The observation was the whole thing.&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>webdev</category>
      <category>programming</category>
      <category>career</category>
    </item>
    <item>
      <title>Building a KYC questionnaire that knows what the regulator will ask before they ask it</title>
      <dc:creator>KitKeen</dc:creator>
      <pubDate>Sun, 29 Mar 2026 10:06:39 +0000</pubDate>
      <link>https://dev.to/kitkeen_55/building-a-kyc-questionnaire-that-knows-what-the-regulator-will-ask-before-they-ask-it-3bd7</link>
      <guid>https://dev.to/kitkeen_55/building-a-kyc-questionnaire-that-knows-what-the-regulator-will-ask-before-they-ask-it-3bd7</guid>
      <description>&lt;p&gt;If you've never worked in fintech: KYC stands for Know Your Customer. It's the legal requirement for financial institutions to verify who their customers are before providing services. Anti-money laundering, fraud prevention, sanctions screening - all of it starts with KYC. When a business opens an account, the bank needs to understand what that business does, where its money comes from, and whether it poses any compliance risk.&lt;/p&gt;

&lt;p&gt;Before this feature shipped, a new business customer could complete onboarding, submit their application - and then wait for the banking partner's compliance team to start asking questions.&lt;/p&gt;

&lt;p&gt;Our banking partner performs compliance reviews on all new business accounts. As part of that review, their compliance team would send questions about business activity, industry type, international transactions, licensing, certifications. The customer would answer. Sometimes the partner would come back with another round. Multiple rounds of back-and-forth, each round adding days to the timeline.&lt;/p&gt;

&lt;p&gt;The critical detail: these questions weren't random. The compliance team asked them based on the customer's industry. A restaurant got asked about cash handling. A financial intermediary got asked about cross-border transaction volumes. A construction company got asked about subcontractors. An IT consultancy got asked about data processing agreements. The questions were predictable. We just weren't collecting the answers before the partner asked for them.&lt;/p&gt;

&lt;p&gt;The solution was to intercept the question-answer cycle before it started. Collect the compliance questions during onboarding - before the review even begins - so when the application lands at the banking partner, the answers are already there.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem with "right questions"
&lt;/h2&gt;

&lt;p&gt;Not every business needs to answer the same questions. A software consultancy and a money services company have very different compliance profiles. Asking the same 30 questions to everyone wastes the customer's time and produces noise in the compliance review.&lt;/p&gt;

&lt;p&gt;The questionnaire had to be adaptive. The question set for each customer is determined by their industry classification - in Europe, that's the NACE code. Think of it as a standardised label for what a company does: restaurants, software development, financial services, construction, logistics, etc. Every registered business has one.&lt;/p&gt;

&lt;p&gt;Each industry classification maps to a specific set of question groups. A money transfer business gets questions about transaction monitoring and correspondent banking. A restaurant gets questions about cash revenue ratios. An e-commerce company gets questions about chargebacks and payment providers. One industry might require a dozen groups, another might need half that. The question set is assembled at generation time based on this mapping, not hardcoded per customer.&lt;/p&gt;




&lt;h2&gt;
  
  
  The question hierarchy
&lt;/h2&gt;

&lt;p&gt;Questions aren't flat. Many have sub-questions that only make sense given a specific answer.&lt;/p&gt;

&lt;p&gt;"Does your company conduct business internationally?" has a follow-up question about destination countries - but only if the answer is yes. Asking about destination countries when the answer is no creates noise and confuses customers.&lt;/p&gt;

&lt;p&gt;We model this with decimal question numbers. Question 1 is a parent. Questions 1.1 and 1.2 are its children. The hierarchy is encoded in the decimal, not in a separate parent-child field.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Question 1: Does your company conduct business internationally? (Bool)
  Question 1.1: If yes, which countries? (Text) - Condition: true
  Question 1.2: If no, why not? (Text) - Condition: false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At submission time, the validator prunes the tree. If the customer answers "no" to question 1, question 1.1 is removed from the contract before storage. The customer never sees questions that don't apply to them, and the stored answers form a clean, consistent set.&lt;/p&gt;

&lt;p&gt;Some questions are container nodes - they exist to define the hierarchy but are never shown to the user. Only leaf questions generate actual form inputs. A boolean flag on each question distinguishes containers from visible inputs.&lt;/p&gt;




&lt;h2&gt;
  
  
  One group per step
&lt;/h2&gt;

&lt;p&gt;The questionnaire can span multiple question groups depending on industry. Rather than dumping everything on one screen, each question group is a separate onboarding step.&lt;/p&gt;

&lt;p&gt;A customer sees their progress at the top - "Step 3 of 7" or "Step 5 of 12" depending on the industry. Each step is independently validated and submitted. Answers are stored per-step. The customer can stop mid-questionnaire and resume later without losing progress. Completed steps are marked hidden - the answers are preserved for the compliance audit trail, but the customer can't edit them after submission.&lt;/p&gt;

&lt;p&gt;The routing logic tracks which compliance steps are still pending. When the customer completes a step, it finds the next incomplete one and routes there. When all compliance steps are done, onboarding picks up from where it left off.&lt;/p&gt;




&lt;h2&gt;
  
  
  Preventing duplicates
&lt;/h2&gt;

&lt;p&gt;Question generation runs once per customer. But onboarding flows have concurrent paths - multiple events can trigger at the same time, and we had to ensure we didn't generate duplicate question sets.&lt;/p&gt;

&lt;p&gt;Two layers of protection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────┐
│              Generate questionnaire request              │
└──────────────────────┬──────────────────────────────────┘
                       │
                       ▼
              ┌─────────────────┐     YES
              │  Cache lock set? ├────────────► Drop request
              └────────┬────────┘
                  NO   │
                       ▼
             Set cache lock (short TTL)
                       │
                       ▼
          ┌────────────────────────┐     YES
          │  Completion flag set?  ├────────────► Drop request
          └────────────┬───────────┘
                  NO   │
                       ▼
             Generate questions
                       │
                       ▼
           Set completion flag (persistent)
                       │
                       ▼
                    Done ✓
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First, a distributed cache lock with a short TTL using set-if-not-exists semantics. The first request to generate the questionnaire for a given onboarding ID wins. Concurrent requests within the TTL window are dropped.&lt;/p&gt;

&lt;p&gt;Second, a persistent completion flag written to the onboarding metadata after generation completes. Even if the cache expires, the flag prevents re-generation.&lt;/p&gt;




&lt;h2&gt;
  
  
  The rollout
&lt;/h2&gt;

&lt;p&gt;We didn't enable this for all customers at once. The feature was introduced during an ongoing product with existing customers, and asking existing customers to answer compliance questions mid-flight would have been disruptive.&lt;/p&gt;

&lt;p&gt;Two feature toggles controlled the rollout. The first controls eligibility by customer creation date - customers created before the launch date are excluded. Hard boundary, no percentage, no A/B. The second controls traffic within eligible customers - started at 0%, increased gradually as we validated the system in production. The two toggles are independent: scope (who is eligible) and volume (what percentage see it) are controlled separately.&lt;/p&gt;




&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Thousands of customers completed the questionnaire during the rollout period.&lt;/p&gt;

&lt;p&gt;The banking partner's compliance review started with all required information already in the application. No rounds of questions. No waiting for customer responses. The back-and-forth that previously stretched across days of email exchanges was replaced by a single session during onboarding - at the point where the customer was already engaged and focused on getting their account open.&lt;/p&gt;

&lt;p&gt;The technical work - industry-based question mapping, conditional logic, multi-step navigation, distributed deduplication - existed to serve one outcome: anticipating what the regulator would ask, and collecting those answers before the review even began.&lt;/p&gt;




&lt;h2&gt;
  
  
  What changed in the code
&lt;/h2&gt;

&lt;p&gt;Before this feature, question generation was a static config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: same questions for every customer&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;questions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LoadDefaultComplianceQuestions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;SendToPartner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;application&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// Partner replies: "We also need to know about X, Y, Z"&lt;/span&gt;
&lt;span class="c1"&gt;// Customer gets emailed. Waits. Answers. Waits again.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After: industry-adaptive generation&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;industry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GetIndustryClassification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;questionGroups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MapIndustryToQuestionGroups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;industry&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;tree&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BuildConditionalTree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;questionGroups&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Customer answers during onboarding&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;answers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CollectAnswersDuringOnboarding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;pruned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PruneByConditions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;SendToPartner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;application&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pruned&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// Partner receives complete data. No follow-up needed.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference in flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEFORE:
Customer → Submit app → Partner reviews → Questions round 1
→ Customer replies (days) → More questions → Customer replies (days)
→ Approved
Total: days to weeks of back-and-forth

AFTER:
Customer → Answer compliance questions during onboarding → Submit app
→ Partner reviews (answers already attached) → Approved
Total: single onboarding session
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No new infrastructure. No external dependencies. Just a question mapping layer, a conditional tree, and a deduplication guard - inserted into the existing onboarding pipeline.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>fintech</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
