<?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: NorfolkD</title>
    <description>The latest articles on DEV Community by NorfolkD (@norfolkd).</description>
    <link>https://dev.to/norfolkd</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%2F579227%2F382e9002-f48c-4b04-9340-18a6685a3de3.png</url>
      <title>DEV Community: NorfolkD</title>
      <link>https://dev.to/norfolkd</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/norfolkd"/>
    <language>en</language>
    <item>
      <title>Fintech Frontend Engineering: What You Need to Know Before Building in Financial Services</title>
      <dc:creator>NorfolkD</dc:creator>
      <pubDate>Sat, 27 Jun 2026 20:10:21 +0000</pubDate>
      <link>https://dev.to/norfolkd/fintech-frontend-engineering-what-you-need-to-know-before-building-in-financial-services-26b0</link>
      <guid>https://dev.to/norfolkd/fintech-frontend-engineering-what-you-need-to-know-before-building-in-financial-services-26b0</guid>
      <description>&lt;p&gt;Most frontend engineers entering fintech underestimate how much the domain changes what &lt;em&gt;good&lt;/em&gt; looks like. It's not just stricter security requirements or more complex forms. The regulatory, compliance, and payments context fundamentally reshapes how you architect UI, how you reason about state, how you handle errors, and what "done" means.&lt;/p&gt;

&lt;p&gt;This post is a reference for frontend engineers — particularly those operating at staff level, where you're expected to contribute credibly beyond UI concerns — moving into or deeper into fintech. It covers the conceptual terrain: compliance constraints, payments flows, and the architectural patterns that emerge from them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Domain Fluency Matters at the Frontend Layer
&lt;/h2&gt;

&lt;p&gt;At staff level, you're expected to make architectural decisions that won't rot under you. In fintech, those decisions carry regulatory surface area. A poorly designed state machine for a payments flow isn't just a UX bug — it can result in double charges, incomplete settlements, or audit failures.&lt;/p&gt;

&lt;p&gt;Frontend engineers who treat fintech as "the same job with stricter validation" tend to build systems that handle happy paths correctly but crack under the domain's actual failure modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Idempotency violations from optimistic UI updates&lt;/li&gt;
&lt;li&gt;Race conditions between transaction state and UI state&lt;/li&gt;
&lt;li&gt;Accessibility failures that violate regulatory obligations (WCAG is a legal requirement in financial services in several jurisdictions)&lt;/li&gt;
&lt;li&gt;Audit trail gaps caused by insufficient event logging at the client layer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Domain fluency means you can anticipate these failure modes &lt;em&gt;before&lt;/em&gt; they're filed as incidents.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Compliance Layer Is an Architectural Constraint
&lt;/h2&gt;

&lt;p&gt;In most product domains, compliance is a checklist you run through late in a feature cycle. In fintech, it's a first-class architectural input.&lt;/p&gt;

&lt;h3&gt;
  
  
  KYC and KYB Flows
&lt;/h3&gt;

&lt;p&gt;Know Your Customer (KYC) and Know Your Business (KYB) verification flows are often the first complex frontend engineering problem you'll encounter. They look like multi-step forms, but they behave like state machines with external dependencies.&lt;/p&gt;

&lt;p&gt;A KYC flow typically involves:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Document collection (government ID, proof of address)&lt;/li&gt;
&lt;li&gt;Submission to a third-party verification provider (e.g., Persona, Onfido, Jumio)&lt;/li&gt;
&lt;li&gt;Asynchronous verification (taking minutes to days)&lt;/li&gt;
&lt;li&gt;Status polling or webhook-triggered state transitions&lt;/li&gt;
&lt;li&gt;Remediation loops for rejected submissions&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The frontend architecture needs to handle the async nature explicitly. A naive implementation that treats verification as a synchronous form submission will fail badly. You need a durable state model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;KYCStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;not_started&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;documents_pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;submitted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;submittedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;under_review&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;approved&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;approvedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;retryAllowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;manual_review&lt;/span&gt;&lt;span class="dl"&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 discriminated union pattern forces every rendering path to handle every state explicitly. When the backend team adds a new status, TypeScript surfaces every unhandled case in the UI. In regulated flows, "we missed a status" is not an acceptable postmortem entry.&lt;/p&gt;

&lt;h3&gt;
  
  
  Audit Trails and Immutability
&lt;/h3&gt;

&lt;p&gt;Regulatory frameworks — PCI-DSS, SOX, GDPR, FCA rules — often require that financial actions be attributable, timestamped, and non-repudiable. This has direct implications for how you design frontend event logging.&lt;/p&gt;

&lt;p&gt;Client-side events that touch payment initiation, consent recording, or document submission should be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Emitted as structured, typed events&lt;/li&gt;
&lt;li&gt;Correlated with a server-side trace ID&lt;/li&gt;
&lt;li&gt;Stored immutably (no update or delete semantics at the log layer)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not analytics. It's an audit trail. Design it accordingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Payments Flows: The State Machine Problem
&lt;/h2&gt;

&lt;p&gt;A payments flow is a distributed state machine with multiple participants: the user, your frontend, your backend, and a payments processor (Stripe, Adyen, Braintree). The frontend's job is to accurately reflect state transitions that it does not fully control.&lt;/p&gt;

&lt;h3&gt;
  
  
  Idempotency and Optimistic UI
&lt;/h3&gt;

&lt;p&gt;Optimistic UI — updating local state before server confirmation — is a standard pattern for improving perceived performance. In payments, it's dangerous without careful design.&lt;/p&gt;

&lt;p&gt;Consider a "Pay Now" button. If a user taps it and the network is slow, they may tap again. Without idempotency keys on the backend &lt;em&gt;and&lt;/em&gt; request deduplication on the frontend, you risk two charges.&lt;/p&gt;

&lt;p&gt;The correct pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;usePaymentSubmit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;submitting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;submit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;paymentDetails&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PaymentDetails&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;submitting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;'&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="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;submitting&lt;/span&gt;&lt;span class="dl"&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="nf"&gt;initiatePayment&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;paymentDetails&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="c1"&gt;// Do NOT reset idempotencyKey — allow retry with the same key&lt;/span&gt;
    &lt;span class="p"&gt;}&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="nx"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idempotency key persists across retries intentionally. If the first request succeeded but the response was lost in transit, a retry with the same key returns the original result rather than creating a second transaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Terminal vs. Non-Terminal States
&lt;/h3&gt;

&lt;p&gt;Transaction states divide into terminal (no further transitions possible) and non-terminal (may still change). &lt;code&gt;SETTLED&lt;/code&gt; and &lt;code&gt;REFUNDED&lt;/code&gt; are terminal. &lt;code&gt;PENDING&lt;/code&gt; and &lt;code&gt;PROCESSING&lt;/code&gt; are not.&lt;/p&gt;

&lt;p&gt;UI that treats a non-terminal state as final is incorrect. A payment showing &lt;code&gt;PROCESSING&lt;/code&gt; needs a polling mechanism or a WebSocket subscription to eventually converge to &lt;code&gt;SETTLED&lt;/code&gt; or &lt;code&gt;FAILED&lt;/code&gt;. Users who close the browser and return need to see the current state, not a cached intermediate state.&lt;/p&gt;

&lt;p&gt;Your state management layer needs to distinguish between &lt;em&gt;local UI state&lt;/em&gt; and &lt;em&gt;remote transaction state&lt;/em&gt;, and it needs a reconciliation mechanism:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// TanStack Query handles staleness and refetching cleanly&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;transaction&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transaction&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;transactionId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetchTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transactionId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;refetchInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Stop polling once we reach a terminal state&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;terminalStates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;settled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refunded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cancelled&lt;/span&gt;&lt;span class="dl"&gt;'&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;data&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;terminalStates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2000&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;
  
  
  Regulatory UI Obligations
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Accessibility Is Not Optional
&lt;/h3&gt;

&lt;p&gt;In the UK, the Equality Act 2010 requires that financial services be accessible. In the EU, the European Accessibility Act came into full force in June 2025. In the US, the DOJ has increasingly cited WCAG 2.1 AA as the standard for ADA compliance in financial services litigation.&lt;/p&gt;

&lt;p&gt;WCAG compliance in fintech is a legal obligation, not a nice-to-have. A payment form with poor focus management, missing ARIA labels on error states, or keyboard traps isn't just a bad experience — it's a liability.&lt;/p&gt;

&lt;p&gt;For complex flows (multi-step forms, modal confirmation dialogs, dynamic error states), you need to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Manage focus programmatically on step transitions&lt;/li&gt;
&lt;li&gt;Announce dynamic content changes via &lt;code&gt;aria-live&lt;/code&gt; regions&lt;/li&gt;
&lt;li&gt;Associate error messages with their inputs via &lt;code&gt;aria-describedby&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Test with actual screen readers, not just automated scanners&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Consent and Disclosure UI
&lt;/h3&gt;

&lt;p&gt;Many financial actions require explicit, recorded consent: terms of service acceptance, fee disclosures, risk warnings. The UI patterns here are legally significant.&lt;/p&gt;

&lt;p&gt;A pre-checked checkbox, an auto-dismissing modal, or a disclosure hidden below the fold can each invalidate consent in regulatory terms. These components need compliance review, not just product review.&lt;/p&gt;

&lt;p&gt;When building consent components, treat the consent event as a domain event with a typed payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ConsentEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;consent_recorded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;consentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;terms_of_service&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fee_disclosure&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;risk_warning&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;documentVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ISO 8601&lt;/span&gt;
  &lt;span class="nl"&gt;ipAddress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// captured server-side&lt;/span&gt;
  &lt;span class="nl"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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 frontend initiates this event; the backend records it immutably.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Patterns That Emerge from Fintech Constraints
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Strict Data Flow and Validation
&lt;/h3&gt;

&lt;p&gt;Financial data has tight schemas. An amount field that accepts &lt;code&gt;string&lt;/code&gt; when it should be &lt;code&gt;number&lt;/code&gt; in minor currency units, or an ambiguously formatted date, can cause downstream failures in payment processing. Use Zod (or an equivalent schema library) to validate API responses at the boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TransactionSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;amountMinorUnits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;positive&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;length&lt;/span&gt;&lt;span class="p"&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;// ISO 4217&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;processing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;settled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refunded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Transaction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;TransactionSchema&lt;/span&gt;&lt;span class="o"&gt;&amp;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 validation belongs at the API client layer, not scattered across components. When a backend team changes a field type, you catch it at the boundary — not in a production error report.&lt;/p&gt;

&lt;h3&gt;
  
  
  Feature Flagging and Progressive Rollouts
&lt;/h3&gt;

&lt;p&gt;Financial features can't be safely rolled back mid-transaction. A user who has started a payment flow under version A of your app cannot safely complete it under version B if the flow structure has changed. Feature flags and cohort-based rollouts are therefore critical infrastructure, not optional.&lt;/p&gt;

&lt;p&gt;Design new payment flows as entirely separate routes or experiences, keeping the old flow live until the rollout is complete and all in-flight transactions have resolved.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sensitive Data Handling on the Client
&lt;/h3&gt;

&lt;p&gt;Card numbers, bank account details, and government ID numbers should never pass through your application's JavaScript. Use iframe-based tokenization (Stripe Elements, Adyen Web Components) so sensitive data travels directly from the browser to the processor. Your app receives a token, not the raw data.&lt;/p&gt;

&lt;p&gt;This isn't just a security best practice — it substantially reduces your PCI-DSS compliance scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Means for a Frontend Engineer in Practice
&lt;/h2&gt;

&lt;p&gt;Building in fintech means accepting that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every flow has a failure mode that matters more than the happy path.&lt;/strong&gt; Design for &lt;code&gt;settled&lt;/code&gt;, &lt;code&gt;failed&lt;/code&gt;, and &lt;code&gt;pending&lt;/code&gt;, not just success.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State machines are load-bearing structures.&lt;/strong&gt; Informal state management — boolean flags, nested conditionals — will break in a domain with this many states and transitions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance constraints are design constraints.&lt;/strong&gt; Engage with them early, not at the end of a sprint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The TypeScript type system is a compliance tool.&lt;/strong&gt; Exhaustive unions and strict validation at API boundaries catch domain errors before they become incidents.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Frontend engineers who understand the domain can challenge product decisions, identify compliance risks in designs, and write code that reflects the actual semantics of financial operations. That's the difference between owning a UI layer and being a full contributor to a fintech product.&lt;/p&gt;

&lt;p&gt;The investment in domain knowledge pays back quickly — and in fintech, the cost of &lt;em&gt;not&lt;/em&gt; making it is measured in real money.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>frontend</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Prefer duplication over the wrong abstraction</title>
      <dc:creator>NorfolkD</dc:creator>
      <pubDate>Tue, 23 Jun 2026 03:18:00 +0000</pubDate>
      <link>https://dev.to/norfolkd/prefer-duplication-over-the-wrong-abstraction-1kf1</link>
      <guid>https://dev.to/norfolkd/prefer-duplication-over-the-wrong-abstraction-1kf1</guid>
      <description>&lt;p&gt;Sandi Metz wrote &lt;a href="https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction" rel="noopener noreferrer"&gt;The Wrong Abstraction&lt;/a&gt; in 2016. It keeps resurfacing — in high-scoring HN threads, architecture Slack channels, and code review comments — whenever a team is staring at a function that started as a clean DRY consolidation and has since acquired seven parameters, three feature flags, and a comment that says &lt;code&gt;# don't touch this&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The core argument is simple: duplication is far cheaper than the wrong abstraction. Once you've built the wrong abstraction, you're not starting from a clean slate — you're inheriting someone else's sunk cost, and every new requirement gets jammed into a shape that doesn't quite fit.&lt;/p&gt;

&lt;p&gt;I've spent a lot of time at the intersection where this matters most: design systems, editor foundations, and cross-team API contracts. These are exactly the contexts where the abstraction temptation is strongest — and where getting it wrong is most expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the wrong abstraction is so easy to create
&lt;/h2&gt;

&lt;p&gt;Abstractions don't usually start wrong. They start reasonable.&lt;/p&gt;

&lt;p&gt;You have two components that render a card. They share 80% of their structure. You extract a &lt;code&gt;Card&lt;/code&gt; component, parameterize the differences, and ship it. Six months later, &lt;code&gt;Card&lt;/code&gt; has a &lt;code&gt;variant&lt;/code&gt; prop with eight values, a &lt;code&gt;withBorder&lt;/code&gt; flag, a &lt;code&gt;compact&lt;/code&gt; prop, a &lt;code&gt;headerSlot&lt;/code&gt;, and a &lt;code&gt;suppressDefaultPadding&lt;/code&gt; escape hatch added by someone who needed the component to do something the abstraction never anticipated.&lt;/p&gt;

&lt;p&gt;The problem isn't that the original extraction was wrong. The problem is what happened next: every new use case was treated as a reason to extend the existing abstraction rather than a signal to question whether the abstraction still fit.&lt;/p&gt;

&lt;p&gt;Metz identifies the specific failure mode: &lt;em&gt;the abstraction is not the thing itself, it's a theory about the thing&lt;/em&gt;. When the theory is wrong, new data doesn't correct it — it gets explained away by adding parameters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost of inline duplication is lower than it looks
&lt;/h2&gt;

&lt;p&gt;When two components share markup, the instinct is to extract immediately. But consider what you actually know at extraction time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You know what the two existing callers need.&lt;/li&gt;
&lt;li&gt;You don't know what the third caller will need.&lt;/li&gt;
&lt;li&gt;You don't know which parts of the shared structure are incidentally similar versus structurally equivalent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Incidental similarity is the trap. Two things can look identical for completely different reasons. A &lt;code&gt;StatusBadge&lt;/code&gt; and a &lt;code&gt;CategoryTag&lt;/code&gt; might render the same pill shape today. That doesn't mean they'll evolve together. Extracting them into a shared &lt;code&gt;Pill&lt;/code&gt; component couples their evolution even if their domains have nothing in common.&lt;/p&gt;

&lt;p&gt;Duplication, in this case, is not laziness — it's preserving optionality. Each component can change shape independently when its domain requirements diverge, which they will.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Two components that look identical today&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;StatusBadge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OrderStatus&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`badge badge--&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;STATUS_LABELS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CategoryTag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ProductCategory&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`badge badge--&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;CATEGORY_LABELS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Six months later, StatusBadge needs a tooltip and an ARIA live region.&lt;/span&gt;
&lt;span class="c1"&gt;// CategoryTag needs a remove button and a count indicator.&lt;/span&gt;
&lt;span class="c1"&gt;// The shared abstraction would now be a liability.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you'd extracted a &lt;code&gt;Pill&lt;/code&gt; component on day one, you'd now be untangling it. Instead, two independent components diverge cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  When shared primitives are genuinely correct
&lt;/h2&gt;

&lt;p&gt;None of this means "never abstract." The argument is about &lt;em&gt;wrong&lt;/em&gt; abstractions, not abstractions generally. There's a category of shared code that genuinely belongs together: primitives that are structurally equivalent by design, not incidentally similar by coincidence.&lt;/p&gt;

&lt;p&gt;In a design system, a &lt;code&gt;Button&lt;/code&gt; component is a real abstraction. It exists because button behavior, accessibility semantics, focus management, and visual consistency are supposed to be uniform across every surface. The shared implementation isn't an accident — it's the point. When the design system updates the focus ring to meet WCAG 2.2 criteria, you want every button in the product to update from one change.&lt;/p&gt;

&lt;p&gt;The test for a real abstraction is whether its callers are &lt;em&gt;supposed&lt;/em&gt; to be coupled. If coupling them is a feature — consistent behavior, enforced constraints, centralized correctness — the abstraction earns its place. If coupling them is just an artifact of current visual similarity, it's a liability.&lt;/p&gt;

&lt;p&gt;A useful heuristic for architecture reviews:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Does this abstraction encode a rule, or does it encode a coincidence?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A &lt;code&gt;Button&lt;/code&gt; encodes a rule: interactive elements with this semantic role should behave this way. A &lt;code&gt;Pill&lt;/code&gt; extracted from &lt;code&gt;StatusBadge&lt;/code&gt; and &lt;code&gt;CategoryTag&lt;/code&gt; encodes a coincidence: these two things look the same right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parameter accumulation signal
&lt;/h2&gt;

&lt;p&gt;You rarely see a wrong abstraction clearly at creation time. The signal appears later, in how the abstraction responds to pressure.&lt;/p&gt;

&lt;p&gt;Right abstractions absorb new requirements gracefully. You add a new button variant: &lt;code&gt;&amp;lt;Button variant="ghost"&amp;gt;&lt;/code&gt;. The existing API handles it cleanly. The abstraction was modeling something real, and the new case fits the model.&lt;/p&gt;

&lt;p&gt;Wrong abstractions resist new requirements. You need the card component to suppress its default padding in one specific context. There's no clean way to express that, so you add &lt;code&gt;suppressDefaultPadding={true}&lt;/code&gt;. The prop name itself is a confession — it's not modeling a concept, it's punching an escape hatch through the existing model.&lt;/p&gt;

&lt;p&gt;Parameter accumulation — especially boolean flags or parameters that only matter to one caller — is the clearest signal that an abstraction has outlived its theory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This function signature is a warning sign&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;formatUserName&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;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;includeTitle&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;shortForm&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;uppercaseLastName&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// added for the export feature&lt;/span&gt;
    &lt;span class="nl"&gt;omitMiddleName&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// added for the badge component&lt;/span&gt;
    &lt;span class="nl"&gt;legalFormat&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// added for contract generation&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This function is doing five jobs. It's no longer a formatting function — it's a dispatch table that routes to five different formatting strategies based on flags. The right move is to inline the relevant logic at each call site, or to create five clearly-named functions that each do one thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The recovery path: inline before you re-extract
&lt;/h2&gt;

&lt;p&gt;Metz's prescription is specific: when you recognize a wrong abstraction, don't refactor it — &lt;em&gt;inline it&lt;/em&gt;. Copy the abstraction's code back to each call site, restore the parameters to their concrete values, delete the dead branches, and see what you actually have.&lt;/p&gt;

&lt;p&gt;This is uncomfortable. It feels like moving backward. But what you get after inlining is the truth: the actual logic each caller needs, without the mediation of a theory that no longer fits.&lt;/p&gt;

&lt;p&gt;From that position, you can see whether a new abstraction is warranted. Sometimes the call sites look very different after inlining — which tells you the old abstraction was hiding real divergence. Sometimes they look nearly identical — which means a better abstraction is now obvious, because you're working from real requirements rather than accumulated historical ones.&lt;/p&gt;

&lt;p&gt;I've done this with editor extension configurations, design token resolution logic, and API response normalization layers. Every time, the inline step was the uncomfortable-but-necessary precondition for getting the abstraction right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cross-team dimension
&lt;/h2&gt;

&lt;p&gt;At staff level, wrong abstractions have a social dimension that makes them harder to fix. When an abstraction is owned by one team and consumed by three others, inlining it requires coordination. The owning team has to be willing to let go of something they built. The consuming teams have to accept temporary duplication. Everyone involved has to resist the pull toward "let's just add another parameter."&lt;/p&gt;

&lt;p&gt;This is where architecture reviews and RFCs earn their keep. The time to debate whether an abstraction is modeling the right thing is &lt;em&gt;before&lt;/em&gt; three teams build on top of it — not after. A good RFC process forces the question: is this abstraction encoding a rule that should govern all callers, or is it encoding one team's current needs in a shape that will constrain everyone else later?&lt;/p&gt;

&lt;p&gt;The answer isn't always to avoid the shared abstraction. Sometimes the shared primitive is genuinely the right call. But asking the question explicitly, with concrete examples of what future callers might need, catches a lot of wrong abstractions before they get adopted.&lt;/p&gt;

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

&lt;p&gt;Metz's essay persists because it names something that's easy to see in retrospect and hard to see in the moment. The wrong abstraction feels like good engineering when you create it. It reduces line count, eliminates repetition, and looks clean. The cost shows up later, incrementally, as each new requirement gets shoe-horned in.&lt;/p&gt;

&lt;p&gt;The discipline it requires is specific: resist the extraction until you understand what the shared structure actually represents. Duplicate freely when the similarity is incidental. Extract confidently when the abstraction encodes a real rule. And when you inherit something that's accumulated enough parameters to be unreadable, have the resolve to inline it before you try to fix it.&lt;/p&gt;

&lt;p&gt;Duplication is recoverable. The wrong abstraction is a debt that compounds.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>typescript</category>
      <category>frontend</category>
      <category>designsystem</category>
    </item>
    <item>
      <title>When an AI Agent Joins Your Yjs Room, Three Assumptions Break</title>
      <dc:creator>NorfolkD</dc:creator>
      <pubDate>Sun, 21 Jun 2026 03:18:14 +0000</pubDate>
      <link>https://dev.to/norfolkd/when-an-ai-agent-joins-your-yjs-room-three-assumptions-break-50h8</link>
      <guid>https://dev.to/norfolkd/when-an-ai-agent-joins-your-yjs-room-three-assumptions-break-50h8</guid>
      <description>&lt;p&gt;Wiring an LLM as a first-class Yjs peer is architecturally sound — but it invalidates three silent assumptions your collaboration stack already makes about peer symmetry: throughput, undo ownership, and presence cadence.&lt;/p&gt;




&lt;p&gt;You've tuned a Yjs provider under real collaborative load. You know the feeling before you can name it — one heavy client starts lagging the room, presence updates stutter, and you end up adding a debounce somewhere and calling it done.&lt;/p&gt;

&lt;p&gt;Now imagine that client generates text at 3,000 words per minute, never goes offline, and has its own awareness cursor.&lt;/p&gt;

&lt;p&gt;That's not a sidebar feature. That's a new class of peer, and your collaboration architecture wasn't designed for it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Demo Is Real — But It Skips the Hard Parts
&lt;/h2&gt;

&lt;p&gt;In April 2026, a working demo wired an LLM as a genuine server-side Yjs document peer — same transport as the human editors, same CRDT, its own awareness state. The implementation uses &lt;code&gt;y-prosemirror&lt;/code&gt; and the standard awareness protocol directly. If you've shipped TipTap collaboration, you already have every dependency it needs.&lt;/p&gt;

&lt;p&gt;The architecture is correct. Making the agent a server-side peer — rather than a client-side bolt-on posting diffs over a REST endpoint — gives you one convergence model instead of two, real presence semantics for the agent, and a clean separation between the LLM streaming layer and the document state layer.&lt;/p&gt;

&lt;p&gt;But the demo establishes the peer model. It doesn't stress-test what happens to your existing assumptions once that peer is running.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Silent Assumption Every CRDT Implementation Makes
&lt;/h2&gt;

&lt;p&gt;Here it is — the assumption baked into the Yjs awareness protocol, the undo manager, and your backpressure strategy, the one nobody wrote down because it was always true until now:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;All peers produce operations at roughly human speed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not identical speed. Human typists vary. But they land in the same order of magnitude. The entire design space — how often you broadcast awareness, how you scope undo history, whether you need per-peer rate limiting at the application layer — rests on that implicit contract.&lt;/p&gt;

&lt;p&gt;An AI agent at 1,000–4,000 words per minute is 25–100× outside that range. It doesn't just stress your transport. It invalidates the mental model.&lt;/p&gt;

&lt;p&gt;Here's what actually breaks.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Backpressure: The Chokepoint You Don't Have
&lt;/h2&gt;

&lt;p&gt;A central OT server can throttle any client trivially — it's the authority, it controls the queue. A CRDT peer model has no natural chokepoint. That's the tradeoff you accepted when you chose Yjs, and it's usually fine because human peers self-limit.&lt;/p&gt;

&lt;p&gt;An agent peer doesn't self-limit. Left unrestricted, its &lt;code&gt;doc.transact()&lt;/code&gt; calls will flood the sync cycle and starve human-paced operations of their share of the convergence window. This is write starvation — the same class of problem as database concurrency — and it manifests as cursor lag and dropped presence updates for everyone else in the room.&lt;/p&gt;

&lt;p&gt;The fix doesn't belong at the transport layer. It belongs between the LLM's streaming output and the Yjs document write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Token bucket between LLM stream and Yjs write&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agentBucket&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;TokenBucket&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// max queued ops&lt;/span&gt;
  &lt;span class="na"&gt;refillRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// ops per 100ms — keeps agent below human starvation threshold&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;llmStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agentBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;consume&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="nx"&gt;ydoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transact&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ytext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;insertionPoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;agentOrigin&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 numbers are illustrative — tune them against your provider and room size. The point is that the rate limit lives at the application layer, scoped to the agent's origin, so human operations always get a guaranteed share of the convergence window regardless of how fast the model is generating.&lt;/p&gt;

&lt;p&gt;This is also where the CRDT-vs-OT debate gets re-litigated in 2026. The peer model is still right for human collaboration. For AI agents specifically, you're adding a lightweight central constraint back in — not for correctness, but for fairness.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Undo History: The Origin Problem You Probably Already Have
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;y-undomanager&lt;/code&gt; scopes undo history by origin. This is correct behavior and it's documented. But "correct" and "deliberate" aren't the same thing.&lt;/p&gt;

&lt;p&gt;If the agent's operations share an origin with the user's, &lt;code&gt;Ctrl+Z&lt;/code&gt; becomes a coin flip. If the agent gets its own origin — which it should — you now have a second question: should user-facing undo ever surface agent operations, and if so, in what order relative to the user's own history?&lt;/p&gt;

&lt;p&gt;There's no universal answer, but there is a clear principle: give the agent a separate &lt;code&gt;UndoManager&lt;/code&gt; with its own &lt;code&gt;trackedOrigins&lt;/code&gt;, and expose agent-undo as a distinct UI affordance, not the default &lt;code&gt;Ctrl+Z&lt;/code&gt; path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userUndoManager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UndoManager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ytext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;trackedOrigins&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;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;userOrigin&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agentUndoManager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UndoManager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ytext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;trackedOrigins&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;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;agentOrigin&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// User's Ctrl+Z only touches userUndoManager.&lt;/span&gt;
&lt;span class="c1"&gt;// "Reject AI suggestion" calls agentUndoManager.undo().&lt;/span&gt;
&lt;span class="c1"&gt;// These stacks don't interfere.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same design decision you face when adding comment marks or tracked-change marks to ProseMirror — marks that &lt;em&gt;describe&lt;/em&gt; content rather than &lt;em&gt;being&lt;/em&gt; content need a separate lifecycle from marks the user controls directly. The agent peer is the document-level version of that same pattern.&lt;/p&gt;

&lt;p&gt;If you've ever had a user accidentally undo a comment thread someone else left, you've already felt this problem. The fix is the same: make the ownership boundary explicit at the manager level, not implicit in a &lt;code&gt;if (origin === agentOrigin) return&lt;/code&gt; buried in a command handler.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Presence and Awareness: Coalesce or Drown
&lt;/h2&gt;

&lt;p&gt;The awareness protocol was designed for human-paced cursor updates. A few broadcasts per second per peer is normal; the rendering layer handles it fine.&lt;/p&gt;

&lt;p&gt;An agent generating 3,000 wpm produces position changes at a rate no human can visually process. Broadcasting all of them is noise on the wire and in the React render cycle.&lt;/p&gt;

&lt;p&gt;Two things to do. First, coalesce awareness updates on a fixed interval for agent peers — not per-operation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pendingAwarenessUpdate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;setTimeout&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateAgentAwareness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="nx"&gt;pendingAwarenessUpdate&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="nx"&gt;pendingAwarenessUpdate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;awareness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLocalStateField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cursor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;anchor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;head&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;pendingAwarenessUpdate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;300&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;Second, add a &lt;code&gt;type&lt;/code&gt; field to the agent's awareness state so the rendering layer can distinguish it from a human cursor without conditional logic scattered across components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;awareness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLocalState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;streaming&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AI Assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;anchor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;insertionPoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;head&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;insertionPoint&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;"AI is writing" and "another person is typing" are different affordances. They deserve different visual treatments and different update rates. Encoding that distinction in the awareness state lets the rendering layer make the right call in one place.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Means for Your RFC
&lt;/h2&gt;

&lt;p&gt;The agent-as-peer pattern is the right architecture. Connecting the LLM to Yjs is not the hard part.&lt;/p&gt;

&lt;p&gt;The hard part is going back through every assumption your collaboration system makes about peer symmetry and making those assumptions &lt;em&gt;explicit&lt;/em&gt; — so you can break them deliberately for the agent peer without breaking them for everyone else.&lt;/p&gt;

&lt;p&gt;Concretely: your backpressure strategy assumed no single peer can dominate the convergence cycle, so it needs an application-layer token bucket scoped to the agent's origin. Your undo history assumed all tracked origins belong to the user, so the agent needs a separate &lt;code&gt;UndoManager&lt;/code&gt; surfaced as a distinct UI action. Your awareness rendering assumed cursor updates arrive at human speed, so agent presence needs coalescing and a type discriminant in the awareness state.&lt;/p&gt;

&lt;p&gt;None of these are hard to implement once you've named them. The risk is shipping the integration without naming them and finding the failure modes through user reports six weeks later when the collaborative load is real and the undo history is a mess.&lt;/p&gt;

&lt;p&gt;Treat rate limiting, undo isolation, and presence coalescing as first-class line items in the RFC. Not edge cases caught in code review.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The April 2026 demo and companion repo are at &lt;a href="https://electric.ax/blog/2026/04/08/ai-agents-as-crdt-peers-with-yjs" rel="noopener noreferrer"&gt;electric.ax/blog/2026/04/08/ai-agents-as-crdt-peers-with-yjs&lt;/a&gt;. The &lt;code&gt;y-prosemirror&lt;/code&gt; + awareness setup maps directly onto a TipTap stack — worth reading alongside the Yjs UndoManager docs if you're planning the integration.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Why this, why now:&lt;/em&gt; The April 2026 demo is the first working implementation of the agent-as-Yjs-peer pattern on a production-equivalent stack (&lt;code&gt;y-prosemirror&lt;/code&gt;, awareness protocol, Durable Streams), and it landed just weeks ago. The "agent velocity problem" it surfaces is genuinely new — CRDT literature has no prior answer for asymmetric peer throughput at this scale — and every team currently building collaborative AI editing features will hit the same three failure modes. Writing this now, before the pattern calcifies into bad defaults, is exactly the right time.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>frontend</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Global Precision &amp; Financial Calculations</title>
      <dc:creator>NorfolkD</dc:creator>
      <pubDate>Sat, 30 May 2026 02:17:28 +0000</pubDate>
      <link>https://dev.to/norfolkd/global-precision-financial-calculations-2bjf</link>
      <guid>https://dev.to/norfolkd/global-precision-financial-calculations-2bjf</guid>
      <description>&lt;h2&gt;
  
  
  Global Precision and how it could impact the financial calculations
&lt;/h2&gt;

&lt;p&gt;When you're building anything that handles money, someone eventually asks: &lt;br&gt;
"how many decimal places should we use?" The tempting answer is to pick a &lt;br&gt;
number — 2, or maybe 5 to be safe — set it globally, and move on. It feels &lt;br&gt;
like a reasonable call. It's not.&lt;/p&gt;

&lt;p&gt;I've debugged precision bugs that traced back exactly to this decision. &lt;br&gt;
Once you understand why a single global precision fails, you can't unsee it.&lt;/p&gt;



&lt;p&gt;The core problem is that different parts of a financial system have genuinely &lt;br&gt;
different precision requirements, and treating them the same introduces errors &lt;br&gt;
at the boundaries.&lt;/p&gt;

&lt;p&gt;Billing calculations that appear on invoices need 2 decimal places. That's &lt;br&gt;
cent precision. That's what customers see, what they're charged, what appears &lt;br&gt;
on the contract. Showing 4 decimal places here is wrong for a different reason — &lt;br&gt;
you're creating a number that can't be represented in any real currency.&lt;/p&gt;

&lt;p&gt;Analytics calculations — revenue attribution, cohort aggregations, lifetime &lt;br&gt;
value — need more precision. When you're summing thousands of transactions &lt;br&gt;
before producing a final figure, rounding to 2 places at each step accumulates &lt;br&gt;
error that compounds in ways that matter at scale.&lt;/p&gt;

&lt;p&gt;Internal calculations — intermediate values mid-computation — should use the &lt;br&gt;
highest precision you can reasonably sustain, precisely because they feed into &lt;br&gt;
further operations. Rounding early and then doing arithmetic on the rounded &lt;br&gt;
value is one of the most common sources of financial calculation bugs I've &lt;br&gt;
encountered.&lt;/p&gt;

&lt;p&gt;If you apply a global setting, you're either truncating precision your &lt;br&gt;
analytics pipeline actually needs, or you're putting 4-decimal numbers on &lt;br&gt;
customer invoices. Neither is acceptable.&lt;/p&gt;



&lt;p&gt;The model that works is precision as an explicit input to each domain calculator, &lt;br&gt;
with sensible per-domain defaults:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PrecisionConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;internalCalcPlaces&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;displayPlaces&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;storagePlaces&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BILLING_PRECISION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PrecisionConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;internalCalcPlaces&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;displayPlaces&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;storagePlaces&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine computes everything at internal precision. It rounds to display &lt;br&gt;
or storage precision only when producing output — at the domain boundary, &lt;br&gt;
not in the middle of a calculation chain.&lt;/p&gt;

&lt;p&gt;This brings us to what I think is the subtlest and most consequential mistake &lt;br&gt;
in financial arithmetic: rounding order.&lt;/p&gt;

&lt;p&gt;Consider a line item. Unit price $10.333..., quantity 3, 10% discount.&lt;/p&gt;

&lt;p&gt;Round early: you get $10.33 × 3 = $30.99, apply discount, round to &lt;strong&gt;$27.89&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Round late — maintain full precision through the chain: $10.333... × 3 × 0.9 = $27.899..., &lt;br&gt;
round only at output: &lt;strong&gt;$27.90&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;One cent difference on one line item. Across a large pricing table with currency &lt;br&gt;
conversion also in the chain, accumulated rounding error is real, customer-visible, &lt;br&gt;
and extremely difficult to debug once it's in production — because the bug is &lt;br&gt;
in the &lt;em&gt;order&lt;/em&gt; of operations, not in any individual calculation.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Round at output boundaries. Never in the middle of a computation chain.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Currency conversion adds another layer to this. Rates are typically 4–6 decimal &lt;br&gt;
places. If you convert and immediately round to 2 decimal places before doing &lt;br&gt;
further arithmetic, you've thrown away precision you needed. Convert at full &lt;br&gt;
internal precision, display-round last.&lt;/p&gt;

&lt;p&gt;And never convert and re-convert. Round-trip currency conversion &lt;br&gt;
(&lt;code&gt;USD → EUR → USD&lt;/code&gt;) with intermediate rounding will not give you back the &lt;br&gt;
original number. If any part of your system displays a converted value and &lt;br&gt;
then uses that displayed value in further calculation, you have a latent &lt;br&gt;
bug waiting for the right combination of exchange rate and amount to surface it.&lt;/p&gt;




&lt;p&gt;The practical steps are straightforward: define precision per domain, make &lt;br&gt;
it an explicit parameter rather than global configuration, add a lint rule &lt;br&gt;
that forbids raw &lt;code&gt;Number&lt;/code&gt; arithmetic in your calculation package, and treat &lt;br&gt;
output boundaries as the one and only place where rounding is allowed.&lt;/p&gt;

&lt;p&gt;It sounds like ceremony until you've spent a day debugging a $0.01 discrepancy &lt;br&gt;
on a $50,000 contract that a customer has already signed and is asking why &lt;br&gt;
the numbers don't match.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Precision bugs that involve accumulated rounding are the hardest to reproduce &lt;br&gt;
consistently — they depend on exact input combinations and operation order. &lt;br&gt;
Has anyone built regression suites specifically for these? Property-based &lt;br&gt;
testing feels like the right tool but I'm curious what others have actually shipped.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Pricing logic feels boring until it's wrong.</title>
      <dc:creator>NorfolkD</dc:creator>
      <pubDate>Thu, 21 May 2026 03:03:11 +0000</pubDate>
      <link>https://dev.to/norfolkd/pricing-logic-feels-boring-until-its-wrong-4m4e</link>
      <guid>https://dev.to/norfolkd/pricing-logic-feels-boring-until-its-wrong-4m4e</guid>
      <description>&lt;p&gt;&lt;strong&gt;Build it like infrastructure from day one.&lt;/strong&gt;&lt;br&gt;
Most pricing engines are built wrong. Here's what I'd do instead.&lt;br&gt;
If you're building a CPQ product — or any SaaS tool where users configure and price deals — your pricing logic is probably living in the wrong place.&lt;br&gt;
It's in a component. Or a utility function called from three different places. Or worse, duplicated between your frontend table renderer and your backend invoice service, silently drifting apart until a customer notices the numbers don't match.&lt;br&gt;
I've thought a lot about how to architect this properly. Here's what I'd do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First: treat pricing as infrastructure, not a feature&lt;/strong&gt;&lt;br&gt;
The moment you have billing frequency, line-item discounts, currency formatting, and tax rules composing together, you don't have a utility anymore. You have a domain. It deserves its own package, its own test suite, and its own ownership.&lt;br&gt;
A shared @your-org/pricing-engine package — published internally, consumed by your frontend, your backend, and your export pipeline — means one place where&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unitPrice × quantity × frequency = subtotal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is defined. Not three.&lt;br&gt;
Why does this matter in practice? Consider this scenario:&lt;br&gt;
A sales rep quotes a client €90/month per seat for a SaaS tool, after a 10% discount, converted from USD. That number needs to be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Identical in the live proposal table the rep is editing&lt;/li&gt;
&lt;li&gt;Identical in the PDF the client downloads and signs&lt;/li&gt;
&lt;li&gt;Identical in the invoice the billing system generates on day one&lt;/li&gt;
&lt;li&gt;Identical in the revenue report the finance team pulls at month end&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your frontend, your PDF export service, and your billing backend each have their own implementation of applyDiscount() and convertCurrency(), you will eventually have a discrepancy. Not maybe. Eventually.&lt;/p&gt;

&lt;p&gt;Never use floating-point arithmetic for money. Ever.&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="mf"&gt;0.1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mf"&gt;0.30000000000000004&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't a JavaScript quirk. It's &lt;strong&gt;IEEE 754&lt;/strong&gt; — the way binary floating-point works at a hardware level. And it will silently corrupt your customer invoices.&lt;br&gt;
A real example of how this surfaces: a pricing table with 7 line items, each with a percentage discount and a currency conversion applied. By the time you sum those rows, the float drift compounds. Your table shows $1,200.00. Your invoice says $1,199.99. Your customer notices. Your support team gets a ticket. Your engineers spend a day debugging something that was never going to work correctly.&lt;br&gt;
Use &lt;em&gt;decimal.js&lt;/em&gt; or equivalent to it. Treat it as a hard rule, not a code style preference. Decimal arithmetic is slower — negligibly so at any realistic pricing table scale. There is no valid argument for floating-point in customer-facing money calculations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep your engine pure&lt;/strong&gt;&lt;br&gt;
A pricing engine should be a pure function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;inputs → outputs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Redux. No React. No HTTP calls. No side effects. Just data in, calculated data out.&lt;br&gt;
What the engine looks like from the outside&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;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computeTablePricing&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;rows&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;row-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;unitPrice&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;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;100.00&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;quantity&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;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;billingFrequency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;monthly&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;percentage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&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;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;10&lt;/span&gt;&lt;span class="dl"&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="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EUR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;€&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;symbolPosition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;front&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;decimalPlaces&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;taxRate&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;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&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="c1"&gt;// result.rows["row-1"].subtotal === Decimal("450.00")&lt;/span&gt;
&lt;span class="c1"&gt;// result.frequencyTotals.monthly === Decimal("450.00")&lt;/span&gt;
&lt;span class="c1"&gt;// result.grandTotal === Decimal("450.00")&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters more than it sounds. A pure engine:&lt;/p&gt;

&lt;p&gt;Runs identically on web, server, React Native, and in a unit test&lt;br&gt;
Can be validated against your old implementation in shadow mode before touching production&lt;br&gt;
Can be reasoned about without understanding your component tree&lt;br&gt;
Can be tested with plain input/output assertions — no mocking, no rendering, no Redux store setup&lt;/p&gt;

&lt;p&gt;The billing frequency problem — a concrete example of why this matters&lt;br&gt;
Let's say you want to add billing frequency to your pricing table. Line items can be one-time, monthly, or annual. The footer should show a subtotal per frequency group.&lt;br&gt;
In a component-centric architecture, you add grouping logic to your table renderer. Then you realize your PDF export also needs frequency subtotals, so you add it there too. Then billing needs it. Three implementations. Three places to get out of sync.&lt;br&gt;
With an engine, billing frequency is just a first-class input field on each row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;billingFrequency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;one-time&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;weekly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;monthly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quarterly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;annual&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine computes frequency subtotals as outputs. The table reads them. The PDF reads them. The billing service reads them. Same numbers everywhere, because it's the same function.&lt;/p&gt;

&lt;p&gt;The discount composition problem — where things really break&lt;br&gt;
Discounts are where scattered pricing logic becomes a genuine product risk.&lt;br&gt;
Consider what a real CPQ discount model looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A rep can apply up to 10% at line-item level without approval
Anything above 10% needs manager sign-off
There's a volume discount: 5+ seats get an additional 5% off
There's a seasonal promotion: 15% off annual plans in Q4
These can stack — but only in specific combinations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If discount logic lives in your table component, how does your catalog apply the same rules? How does your billing service validate that the agreed discount is still applied at invoice time? How do you write a unit test for the approval threshold without rendering a table?&lt;br&gt;
&lt;strong&gt;The answer is:&lt;/strong&gt; you can't, cleanly. You end up with discount logic scattered across five files, each with slightly different behavior.&lt;br&gt;
In an engine architecture, discount application is a shared rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// shared/discount-application.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyDiscount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;baseAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;percentage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fixed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Decimal&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="nl"&gt;discountedAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;discountAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Decimal&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;discountAmount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;percentage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;baseAmount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;div&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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="na"&gt;discountedAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;baseAmount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;discountAmount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;discountAmount&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;One function. Tested once. Used by the table calculator, the catalog calculator, the billing service, and the approval workflow validator. Identical behavior everywhere by definition.&lt;/p&gt;

&lt;p&gt;The feature composition problem — this is the real ceiling&lt;br&gt;
Individual features are manageable. The problem is when they compose.&lt;br&gt;
A line item that has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Billing frequency: monthly
Discount: 10% off
Currency: EUR, converted from USD at current rate
Tax: 20% VAT applied after discount
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;...needs to produce exactly the same number in your live editor, your PDF, your invoice, and your analytics pipeline. Every combination of features multiplies the number of cases where scattered logic can diverge.&lt;br&gt;
This is what actually blocks CPQ roadmaps. Not any individual feature — the combinatorial explosion of feature interactions when your calculation logic is spread across the codebase.&lt;/p&gt;

&lt;p&gt;Let your state layer stay clean&lt;br&gt;
In a Redux architecture, the pattern that actually works:&lt;/p&gt;

&lt;p&gt;Redux stores confirmed inputs only (what the user committed — prices, quantities, discounts, frequencies)&lt;br&gt;
Selectors derive all calculated values by passing those inputs through the engine&lt;br&gt;
Components never calculate — they only display&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;// selector reads inputs from Redux, derives outputs via engine&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;selectTablePricingResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;selectTablePricingInputs&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;computeTablePricing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// pure function, memoized automatically&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This eliminates an entire class of bugs where your displayed subtotal disagrees with what gets saved, invoiced, or exported. The Redux store is always clean data. Derived values are never persisted. There is no ambiguity about whether a stored subtotal field is a user input or a computed value — a distinction that causes real bugs in collaborative editing and undo/redo flows.&lt;/p&gt;

&lt;p&gt;The migration play — how to get there without breaking production&lt;br&gt;
If you're refactoring an existing system rather than greenfielding, shadow mode is your best friend.&lt;br&gt;
Run the new engine in parallel with your old implementation. For every calculation your old code produces, the engine produces the same calculation independently. Log any divergence. Ship zero user-facing changes until the outputs match exactly — across every row type, every discount combination, every currency, every edge case you can throw at it.&lt;br&gt;
Silent arithmetic regressions on customer invoices are not a recoverable situation. Shadow mode gives you mathematical certainty before you flip the switch.&lt;br&gt;
The sequencing I'd recommend:&lt;/p&gt;

&lt;p&gt;Engine package first — pure functions, no UI changes, full test coverage&lt;br&gt;
New features through the engine — billing frequency, discounts, anything net-new goes through the engine from day one, minimizing regression risk on existing behavior&lt;br&gt;
Shadow mode on existing features — validate the engine matches current behavior exactly&lt;br&gt;
Migrate existing consumers — replace old calculations one surface at a time, behind a feature flag&lt;br&gt;
Remove the old code — only after full coverage and monitoring confirms correctness&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this unlocks long-term&lt;/strong&gt;&lt;br&gt;
Once you have a pure, shared pricing engine, a lot of things that were hard become straightforward:&lt;/p&gt;

&lt;p&gt;Backend adoption: Your invoice service imports the same npm package your frontend uses. Calculation discrepancies between frontend and backend become structurally impossible&lt;br&gt;
Mobile: React Native consumes the same engine. No separate mobile pricing logic&lt;br&gt;
Analytics: Revenue reports use the same calculation rules as the proposals that generated the revenue&lt;br&gt;
Formula engine: User-programmable pricing formulas&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scheme"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;=quantity&lt;/span&gt; &lt;span class="nv"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;unitPrice&lt;/span&gt; &lt;span class="nv"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;1&lt;/span&gt; &lt;span class="nv"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;become an extension of the engine, not a rewrite of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The point&lt;/strong&gt;&lt;br&gt;
Pricing logic feels boring until it's wrong. Build it like infrastructure from day one — pure, shared, tested, and decoupled from your UI layer. The payoff isn't visible immediately. It's visible when your fifth pricing feature composes correctly with your first four without a single edge case meeting.&lt;br&gt;
Have you tackled pricing complexity at scale? Curious what patterns have worked — especially around currency and tax.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>typescript</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
