<?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: Son Do</title>
    <description>The latest articles on DEV Community by Son Do (@son_do_3543eb233b59d424bc).</description>
    <link>https://dev.to/son_do_3543eb233b59d424bc</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%2F4029349%2F94eb20c3-0521-4765-af49-9fca96482822.png</url>
      <title>DEV Community: Son Do</title>
      <link>https://dev.to/son_do_3543eb233b59d424bc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/son_do_3543eb233b59d424bc"/>
    <language>en</language>
    <item>
      <title>Six Categories, Some Locked: Designing a Legal Chart of Accounts</title>
      <dc:creator>Son Do</dc:creator>
      <pubDate>Sat, 25 Jul 2026 02:10:14 +0000</pubDate>
      <link>https://dev.to/son_do_3543eb233b59d424bc/six-categories-some-locked-designing-a-legal-chart-of-accounts-417c</link>
      <guid>https://dev.to/son_do_3543eb233b59d424bc/six-categories-some-locked-designing-a-legal-chart-of-accounts-417c</guid>
      <description>&lt;p&gt;&lt;em&gt;Series -- When the Aggregate Looks Right:&lt;/em&gt; 0: Overview · &lt;strong&gt;1: Chart of Accounts&lt;/strong&gt; · 2: Three-Way Reconciliation · 3: The Aggregate Can Lie · 4: Bank Sync · 5: GL Integration&lt;/p&gt;

&lt;p&gt;The default assumption when building an accounting module for businesses is that they know their own books. A law firm that has been tracking its finances for ten years has account names, structures, and categories that work for its practice. The obvious product instinct is to give them full control: let them rename accounts, restructure categories, delete what they do not use.&lt;/p&gt;

&lt;p&gt;That instinct is wrong, at least for the accounts that billing, invoicing, and compliance matching depend on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a legal chart of accounts looks like
&lt;/h2&gt;

&lt;p&gt;The module pre-populated a chart of accounts at onboarding, organized into six top-level categories: Assets, Liabilities (with a segregated trust account under it), Equity, Revenue, Expenses, and Compensation. The legal-specific structure differs from a standard business chart mainly in the trust liability account, which holds client funds the firm has not yet earned, and in how compensation is tracked separately from operating expenses for partnership accounting reasons.&lt;/p&gt;

&lt;p&gt;Each category came with system-defined sub-accounts appropriate to a law firm. Firms could add their own sub-accounts under any category. They could rename their own custom accounts freely. What they could not do was rename or delete the system accounts.&lt;/p&gt;

&lt;p&gt;That restriction is what this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why auto-posting breaks with a moving target
&lt;/h2&gt;

&lt;p&gt;When a firm sends an invoice through MyCase billing, the accounting layer needs to post the corresponding journal entries automatically. An invoice creates an accounts receivable entry on the asset side and a revenue entry on the income side. A payment marks the receivable satisfied and records cash or, for trust-funded payments, moves money from the trust liability account to the operating side.&lt;/p&gt;

&lt;p&gt;Every one of those postings needs to land in a specific, stable place in the ledger. The posting logic does not ask the firm which account to use. It knows, because the chart of accounts has a defined structure with fixed identity for system accounts.&lt;/p&gt;

&lt;p&gt;If a firm could rename "Trust Account" to "Client Funds" or delete it and create an equivalent under a different structure, the billing integration would post to an account that no longer exists under the expected name. The transaction would fail, or worse, post to a default catch-all with no meaningful category.&lt;/p&gt;

&lt;p&gt;The same problem applies to expense entry and to the invoice-exact matching in Part 3. Matching a trust-to-operating transfer against the invoice that authorized it requires finding both the trust transaction and the invoice's corresponding posted entry. If the account names or identities can shift, the lookup falls apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The customization boundary
&lt;/h2&gt;

&lt;p&gt;Locking accounts does not mean firms get no flexibility. The system accounts define the roots and some first-level structure. Anything below that is fair game. A firm that tracks fees in several categories can add sub-accounts under Revenue. A firm with specialized expense categories can build those under Expenses. None of that custom structure touches the system accounts that auto-posting and compliance matching depend on.&lt;/p&gt;

&lt;p&gt;The product framing for this was straightforward: the locked accounts are ones the system writes to on your behalf. Everything else is yours to organize how you see fit.&lt;/p&gt;

&lt;p&gt;In practice, the most common request from early adopters was renaming the trust account to match whatever they had been calling it in their previous system. The answer was a sub-account alias, not a rename of the root. The root kept its identity; the firm could see whatever label they preferred in their own views.&lt;/p&gt;

&lt;h2&gt;
  
  
  The downstream dependency
&lt;/h2&gt;

&lt;p&gt;It would be easy to treat the chart of accounts as plumbing: necessary, not interesting. But the lock decision has a direct effect on everything built on top of it.&lt;/p&gt;

&lt;p&gt;The three-way reconciliation in Part 2 works because the firm trust ledger has a known structure. The matching logic in Part 3 works because a posted invoice has a stable account identity to match against. The bank sync in Part 4 can distinguish trust accounts from operating accounts because that distinction is encoded in the chart, not inferred from whatever the firm named their accounts. The GL integration in Part 5 can post billing and LawPay events at event time because the target accounts are always there.&lt;/p&gt;

&lt;p&gt;Firms that wanted full customization got it in the places where customization does not break the system. Firms that wanted to rename or delete system accounts got an explanation of why that was not possible and what the alternative was. A few firms pushed back. The answer was always the same: these accounts are written to on your behalf, and that only works if they have a stable identity the system can find.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flt9us34p0q55ddc74qzw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flt9us34p0q55ddc74qzw.png" alt="Legal chart of accounts: six categories with locked system accounts and customizable sub-accounts" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6j3ssgve8m0dnojbcwey.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6j3ssgve8m0dnojbcwey.png" alt="What breaks when a system account is renamed: the billing auto-post chain fails silently" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Series -- When the Aggregate Looks Right:&lt;/em&gt; 0: Overview · &lt;strong&gt;1: Chart of Accounts&lt;/strong&gt; · 2: Three-Way Reconciliation · 3: The Aggregate Can Lie · 4: Bank Sync · 5: GL Integration&lt;/p&gt;

</description>
      <category>rails</category>
      <category>backend</category>
      <category>legal</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Building MyCase Accounting: Trust Books That Can Look Right While Being Wrong</title>
      <dc:creator>Son Do</dc:creator>
      <pubDate>Sat, 25 Jul 2026 02:10:09 +0000</pubDate>
      <link>https://dev.to/son_do_3543eb233b59d424bc/building-mycase-accounting-trust-books-that-can-look-right-while-being-wrong-2a2c</link>
      <guid>https://dev.to/son_do_3543eb233b59d424bc/building-mycase-accounting-trust-books-that-can-look-right-while-being-wrong-2a2c</guid>
      <description>&lt;p&gt;&lt;em&gt;Series -- When the Aggregate Looks Right:&lt;/em&gt; &lt;strong&gt;0: Overview&lt;/strong&gt; · 1: Chart of Accounts · 2: Three-Way Reconciliation · 3: The Aggregate Can Lie · 4: Bank Sync · 5: GL Integration&lt;/p&gt;

&lt;p&gt;The call came in mid-morning. A firm had reconciled their trust account the day before. Bank balance matched the firm ledger to the cent. Books were closed for the month.&lt;/p&gt;

&lt;p&gt;The client calling was asking about their retainer. The paralegal pulled up the matter. The trust sub-ledger for that client showed a balance several hundred dollars lower than the client expected. Funds meant for one matter had been drawn against another. No one had noticed because the aggregate absorbed the error. The bank only ever showed one pooled number.&lt;/p&gt;

&lt;p&gt;That failure mode is not a bug in someone's arithmetic. It is what happens when you watch the wrong thing. A pooled IOLTA trust account hides client-level movement inside an aggregate that can reconcile cleanly while individual slices are wrong. The aggregate can lie.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem IOLTA creates
&lt;/h2&gt;

&lt;p&gt;IOLTA stands for Interest on Lawyers' Trust Accounts. Most states require attorneys to hold unearned client funds in a pooled trust account rather than the firm's operating account. Retainers go in. Invoices draw against them. The interest goes to a state bar fund.&lt;/p&gt;

&lt;p&gt;The pooled structure creates an accounting problem the bank cannot solve. The bank sees one account. The firm has obligations to dozens of clients, each with their own retainer and their own matter. State bar rules require firms to know, at any point, exactly how much of that pooled balance belongs to each client. A discrepancy is not an accounting quirk. It is a bar-complaint risk.&lt;/p&gt;

&lt;p&gt;Banks report balances. They do not track sub-ledgers. The firm is responsible for maintaining the client-level view. In 2021, most firms using MyCase were doing that tracking manually, in spreadsheets or separate bookkeeping software, reconciling monthly or not at all.&lt;/p&gt;

&lt;p&gt;That is the gap the module was built to close.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the series covers
&lt;/h2&gt;

&lt;p&gt;The module had five interlocking parts. Each one is a separate post in this series.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 1 -- The chart of accounts.&lt;/strong&gt; Before any posting or matching could work, the module needed a stable ledger structure. Six top-level categories -- Assets, Liabilities (including segregated trust), Equity, Revenue, Expenses, Compensation -- pre-populated with legal-specific accounts. Some accounts were locked because billing, invoicing, and expense entry auto-posted to them. Firms could customize via sub-accounts but could not rename or delete the roots. The downstream safeguards in Part 3 depend on that stability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 2 -- Three-way reconciliation.&lt;/strong&gt; Bank statement versus firm trust ledger versus per-client trust sub-ledgers. Two-way matching (bank against firm books) will pass while a client-level slice is wrong, because the pooled bank account cannot see individual matters. The design decision that made three-way runnable at scale: auto-generating client trust sub-ledgers from existing MyCase client records instead of requiring firms to set up sub-ledgers manually. Firms that had to build the third leg themselves skipped it. Without the third leg, three-way is two-way with paperwork.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 3 -- Safeguards against flagging too much.&lt;/strong&gt; Once you can see client-level balances, the next problem is catching errors without burying reviewers in noise. This is the post that was already published as "The Aggregate Can Lie." It covers naive flag-everything, the false-positive flood it produces, and the invoice-exact matching design that caught real violations without producing daily alert fatigue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 4 -- Plaid bank sync.&lt;/strong&gt; Pulling bank feeds via Plaid required a decision that is not obvious until you see the failure: trust accounts cannot follow the same categorization workflow as operating accounts. If the firm has already recorded a trust transaction in MyCase, importing the bank line and categorizing it into a GL account creates a second source of truth and breaks the three-way model. The For Review pipeline, and why trust transactions skip categorization entirely, is Part 4.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 5 -- GL integration.&lt;/strong&gt; The last loop to close was eliminating re-keying. An invoice already lived in MyCase billing. A payment already hit LawPay. A bookkeeper was re-entering both into accounting. The integration posts billing and payment events directly to the GL at event time -- trust versus operating routing handled by the locked chart of accounts from Part 1 -- so there is nothing left to re-key and no lag between what billing recorded and what the books show.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it shipped as
&lt;/h2&gt;

&lt;p&gt;The module reached general availability after the AffiniPay acquisition closed in June 2022. Within twelve months of GA, 18% of eligible firms had enabled it.&lt;/p&gt;

&lt;p&gt;For those firms, monthly trust reconciliation dropped from roughly six hours of manual work to under thirty minutes per cycle. The six hours was mostly matching -- pulling bank statements, cross-referencing firm records, hunting for discrepancies. The thirty minutes is reviewing a screen of pre-matched items and confirming the ones that need a human decision.&lt;/p&gt;




&lt;h2&gt;
  
  
  What comes first
&lt;/h2&gt;

&lt;p&gt;Part 1 starts with the chart of accounts, because nothing else in the module works without it. The three-way reconciliation needs stable account identity to match against. The bank sync needs to know which accounts are trust and which are operating before it can route a feed line. The GL integration needs locked system accounts to post into.&lt;/p&gt;

&lt;p&gt;A fully customizable chart looks like the right call when you are onboarding a firm that already has books. It turns out to be the wrong call the moment auto-posting and compliance matching need to find an account that has not been renamed or deleted. Part 1 is about that tradeoff.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnacqtyn0m3yswvge2zkj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnacqtyn0m3yswvge2zkj.png" alt="Series map: five parts of the accounting module from chart of accounts through GL integration" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ikktjh56xds1ghm4rtr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ikktjh56xds1ghm4rtr.png" alt="Before and after: monthly trust reconciliation from 6 hours manual to under 30 minutes" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Series -- When the Aggregate Looks Right:&lt;/em&gt; &lt;strong&gt;0: Overview&lt;/strong&gt; · 1: Chart of Accounts · 2: Three-Way Reconciliation · 3: The Aggregate Can Lie · 4: Bank Sync · 5: GL Integration&lt;/p&gt;

</description>
      <category>rails</category>
      <category>backend</category>
      <category>legal</category>
      <category>fintech</category>
    </item>
    <item>
      <title>The Hardest Part Wasn't the Code: Aligning Stakeholders on Metric Definitions</title>
      <dc:creator>Son Do</dc:creator>
      <pubDate>Thu, 23 Jul 2026 09:50:29 +0000</pubDate>
      <link>https://dev.to/son_do_3543eb233b59d424bc/the-hardest-part-wasnt-the-code-aligning-stakeholders-on-metric-definitions-oi6</link>
      <guid>https://dev.to/son_do_3543eb233b59d424bc/the-hardest-part-wasnt-the-code-aligning-stakeholders-on-metric-definitions-oi6</guid>
      <description>&lt;p&gt;&lt;em&gt;Series -- From Ad-Hoc SQL to a Semantic Layer:&lt;/em&gt; &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/replacing-30-ad-hoc-sql-reports-with-a-semantic-layer-28fo"&gt;0: Overview&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/ten-reports-ten-answers-why-ad-hoc-sql-doesnt-scale-4771"&gt;1: Ten Answers&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/why-we-didnt-just-add-more-indexes-choosing-a-semantic-layer-2da3"&gt;2: Why Not Indexes&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/one-definition-30-reports-how-we-designed-the-metrics-model-3g3j"&gt;3: Metrics Model&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/never-miss-a-refresh-building-the-materialized-view-pipeline-4ifi"&gt;4: View Pipeline&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/schema-without-churn-graphql-design-for-an-evolving-metrics-model-a41"&gt;5: Schema Design&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/authorization-at-the-api-layer-why-field-level-checks-live-in-graphql-4c3m"&gt;6: Authorization&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/which-report-goes-first-the-migration-sequencing-logic-2m3k"&gt;7: Migration Sequence&lt;/a&gt; · &lt;strong&gt;8: Stakeholder Alignment&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A domain owner pulled me into a call in early 2024 with two reports open side&lt;br&gt;
by side. Both claimed to show billable hours for the same firm over the same&lt;br&gt;
period. The numbers were different, and the question was simple: which one is&lt;br&gt;
right. I couldn't answer it. Nobody on the call could. Each report had its&lt;br&gt;
own SQL, written by someone else at some other time, and each had quietly&lt;br&gt;
decided for itself what "billable" meant.&lt;/p&gt;

&lt;p&gt;I have opened this series with that scene once already, because it is the&lt;br&gt;
moment the whole project became undeniable. But I want to come back to it&lt;br&gt;
now, at the end, because the scene contains a trap. It looks like an&lt;br&gt;
engineering problem, and the engineering answer is obvious: build one place&lt;br&gt;
where "billable hours" is defined, and point every report at it. That is what&lt;br&gt;
a semantic layer is. Seven posts of this series are about how we built it.&lt;/p&gt;

&lt;p&gt;This post is about the part that the layer did not solve. Building one&lt;br&gt;
definition does not settle a disagreement. It forces you to put exactly one&lt;br&gt;
definition in the model, and the moment you do, every domain that had been&lt;br&gt;
running a different implicit definition is now wrong by decree. The layer&lt;br&gt;
does not end the argument. It makes the argument mandatory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The easy conflict and the hard one
&lt;/h2&gt;

&lt;p&gt;The billable-hours discrepancy had a root cause you could find. I sat down&lt;br&gt;
with both queries and read them. One of them joined through invoices, so it&lt;br&gt;
was counting only hours that had actually been invoiced. The other queried&lt;br&gt;
time entries directly, so it counted every hour logged with a billable&lt;br&gt;
status, invoiced or not. Same firm, same period, two honest numbers&lt;br&gt;
answering two slightly different questions that both got called "billable&lt;br&gt;
hours." Once you see the join, you understand the whole thing. The&lt;br&gt;
disagreement was an accident of implementation, and the fix was to decide&lt;br&gt;
which question we meant and write it down.&lt;/p&gt;

&lt;p&gt;Trust balance was not like that. Under IOLTA, the rules governing how law&lt;br&gt;
firms hold client money in trust, there were three definitions of "trust&lt;br&gt;
balance" in play, and all three were correct. The bank statement definition&lt;br&gt;
was the actual cash position, what the bank said was in the account right&lt;br&gt;
now. The firm ledger definition was what the firm's own books showed it owed&lt;br&gt;
across all clients. The per-client sub-ledger definition was what one&lt;br&gt;
specific client had in trust at that moment. These are not three&lt;br&gt;
implementations of one number. They are three different numbers that answer&lt;br&gt;
three different questions, and a firm needs all three, for reconciliation,&lt;br&gt;
for accounting, and for client-level compliance respectively.&lt;/p&gt;

&lt;p&gt;There was no SQL to read that would resolve it. Nobody had made a mistake.&lt;br&gt;
The disagreement was legitimate, and the only way through it was to decide&lt;br&gt;
which question the analytics layer was going to answer, and to be explicit&lt;br&gt;
that it was not answering the other two. That is not a debugging task. That&lt;br&gt;
is a governance decision, and it needed someone with the authority to make&lt;br&gt;
it and the willingness to be held to it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F84svajcdcieyqya2l3w3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F84svajcdcieyqya2l3w3.png" alt="Three legally valid IOLTA trust balance definitions side by side" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Who decides what "billable" means
&lt;/h2&gt;

&lt;p&gt;I drove the alignment across the four domains, but I was not the one who got&lt;br&gt;
to decide what the metrics meant. The decisions belonged to the domain&lt;br&gt;
product managers, one each for billing, trust, case, and financial. They&lt;br&gt;
were the people who owned the consequences of getting a definition wrong,&lt;br&gt;
and so they were the ones who had to own the definition.&lt;/p&gt;

&lt;p&gt;What I built was the process that forced the decision to happen and made it&lt;br&gt;
stick. Every contested metric got a written definition doc. The doc named&lt;br&gt;
the competing definitions explicitly, laid out which report or which domain&lt;br&gt;
had been using each one, and stated the canonical resolution: this is what&lt;br&gt;
"billable hours" means in the semantic model, this is the question it&lt;br&gt;
answers, and here is what it deliberately excludes. The relevant domain PM&lt;br&gt;
signed off on the doc before the metric was committed to the model.&lt;/p&gt;

&lt;p&gt;That sign-off was the whole point. Not a verbal agreement on a call, which&lt;br&gt;
evaporates the moment someone's report shows a number they do not like. Not&lt;br&gt;
an escalation to a VP, which turns a definition question into a political&lt;br&gt;
one and teaches everyone to escalate next time. A written doc, with a named&lt;br&gt;
decision, and a name on the approval. When a report later showed a number&lt;br&gt;
someone disputed, the answer was not "let me check the query." The answer&lt;br&gt;
was "here is the definition your domain signed off on, and here is the doc."&lt;br&gt;
The disagreement had already been had. We were just looking up the result.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F35hkuowtma7doghfhiq3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F35hkuowtma7doghfhiq3.png" alt="Metric definition governance: the sign-off flow from conflict to canonical model" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The weeks that were not engineering time
&lt;/h2&gt;

&lt;p&gt;The honest cost of this shows up in the trust domain. The trust accounting&lt;br&gt;
reports were built. The materialized views were ready, the GraphQL schema&lt;br&gt;
could express them, the caching worked. The reports could have shipped. They&lt;br&gt;
did not ship, for weeks, because the three-ledger trust balance question was&lt;br&gt;
still live and nobody had signed a doc.&lt;/p&gt;

&lt;p&gt;Those weeks were not engineering time. Nothing was being coded. The&lt;br&gt;
infrastructure sat finished and waiting while the billing PM, the trust PM,&lt;br&gt;
and the financial PM worked out which trust balance the platform would&lt;br&gt;
report and how to scope the other two so the reports that genuinely needed&lt;br&gt;
them could still get them. When people talk about how long a migration took,&lt;br&gt;
they usually mean how long the code took. On this project, a real fraction&lt;br&gt;
of the calendar was alignment time, and no amount of engineering velocity&lt;br&gt;
would have shortened it, because the bottleneck was agreement, not&lt;br&gt;
implementation.&lt;/p&gt;

&lt;p&gt;I found that clarifying rather than frustrating, eventually. The reports were&lt;br&gt;
blocked by exactly the thing they should have been blocked by. Shipping them&lt;br&gt;
faster would have meant shipping them on a definition nobody had agreed to,&lt;br&gt;
which is how you end up with two reports showing different billable-hours&lt;br&gt;
numbers in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What never resolved
&lt;/h2&gt;

&lt;p&gt;Not every definition got settled. Some metrics stayed domain-specific,&lt;br&gt;
permanently. In the previous post I mentioned the 5 to 10 reports out of the&lt;br&gt;
original 30-plus that never migrated onto the new platform. A subset of those&lt;br&gt;
were not stuck on traffic or effort. They were stuck on definitions that&lt;br&gt;
never got sign-off, because the domains could not agree and the use case was&lt;br&gt;
too narrow to force the question to a conclusion.&lt;/p&gt;

&lt;p&gt;Those reports stayed on their legacy queries indefinitely. Each one kept its&lt;br&gt;
own private definition of whatever it measured, the same way all 30 reports&lt;br&gt;
used to. From an engineering standpoint that is unfinished work. From a&lt;br&gt;
different standpoint it is the most honest output of the project. You migrate&lt;br&gt;
most of a thing like this, and the parts you cannot migrate tell you&lt;br&gt;
something true about your organization's actual ability to agree on shared&lt;br&gt;
meaning. Some questions did not have one right answer that four teams could&lt;br&gt;
live with, and the platform was better for admitting that than for forcing a&lt;br&gt;
false consensus into the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every migration was a governance event
&lt;/h2&gt;

&lt;p&gt;Here is the insight I did not have going in. Forcing reports onto shared&lt;br&gt;
definitions was progress, and it was the entire point of the semantic layer.&lt;br&gt;
But it also meant that every single report that migrated required a decision.&lt;br&gt;
Someone had to say, officially and in writing, what "billable" or "active"&lt;br&gt;
or "trust balance" meant for the platform, and then be accountable for that&lt;br&gt;
decision when a number moved.&lt;/p&gt;

&lt;p&gt;That is a fundamentally different kind of work than building infrastructure.&lt;br&gt;
Materialized views and GraphQL schema evolution are hard in a way that yields&lt;br&gt;
to engineering: you can think harder, test more, refactor. Getting four&lt;br&gt;
teams to agree that they had been measuring the same thing differently, and&lt;br&gt;
that the platform was going to pick one definition and hold it against&lt;br&gt;
everyone's objections, does not yield to any of that. It yields to a written&lt;br&gt;
doc, a named owner, and the patience to let the argument happen instead of&lt;br&gt;
routing around it.&lt;/p&gt;

&lt;p&gt;The day the domain owner checked two reports on the unified platform and the&lt;br&gt;
billable-hours numbers matched, the project felt done to me, well before the&lt;br&gt;
migration actually was. Not because the code was finished. Because the two&lt;br&gt;
numbers were finally answering the same question, and there was a doc, with a&lt;br&gt;
name on it, that said why.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>softwareengineering</category>
      <category>webdev</category>
      <category>leadership</category>
    </item>
    <item>
      <title>Which Report Goes First: The Migration Sequencing Logic</title>
      <dc:creator>Son Do</dc:creator>
      <pubDate>Thu, 23 Jul 2026 09:50:25 +0000</pubDate>
      <link>https://dev.to/son_do_3543eb233b59d424bc/which-report-goes-first-the-migration-sequencing-logic-2m3k</link>
      <guid>https://dev.to/son_do_3543eb233b59d424bc/which-report-goes-first-the-migration-sequencing-logic-2m3k</guid>
      <description>&lt;p&gt;&lt;em&gt;Series -- From Ad-Hoc SQL to a Semantic Layer:&lt;/em&gt; &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/replacing-30-ad-hoc-sql-reports-with-a-semantic-layer-28fo"&gt;0: Overview&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/ten-reports-ten-answers-why-ad-hoc-sql-doesnt-scale-4771"&gt;1: Ten Answers&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/why-we-didnt-just-add-more-indexes-choosing-a-semantic-layer-2da3"&gt;2: Why Not Indexes&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/one-definition-30-reports-how-we-designed-the-metrics-model-3g3j"&gt;3: Metrics Model&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/never-miss-a-refresh-building-the-materialized-view-pipeline-4ifi"&gt;4: View Pipeline&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/schema-without-churn-graphql-design-for-an-evolving-metrics-model-a41"&gt;5: Schema Design&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/authorization-at-the-api-layer-why-field-level-checks-live-in-graphql-4c3m"&gt;6: Authorization&lt;/a&gt; · &lt;strong&gt;7: Migration Sequence&lt;/strong&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-hardest-part-wasnt-the-code-aligning-stakeholders-on-metric-definitions-oi6"&gt;8: Stakeholder Alignment&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The engineering problem was solved. The semantic model worked, the&lt;br&gt;
materialized views were refreshing on schedule, and the authorization-aware&lt;br&gt;
GraphQL API was live and returning correct results. I remember the specific&lt;br&gt;
relief of watching a query come back through the new layer and matching it&lt;br&gt;
by hand against the old read replica. The numbers agreed.&lt;/p&gt;

&lt;p&gt;And then I looked at the list. Thirty-plus reports were still pointing at&lt;br&gt;
the old read replica, none of them aware that a better path existed. The&lt;br&gt;
migration problem was just starting, and it opened with a question that had&lt;br&gt;
nothing to do with materialized views or schema design: in what order do you&lt;br&gt;
move them?&lt;/p&gt;

&lt;h2&gt;
  
  
  Two axes, not one
&lt;/h2&gt;

&lt;p&gt;It is tempting to sequence a migration by traffic alone. Move the heaviest&lt;br&gt;
queries first, get the biggest latency win, declare progress. That instinct&lt;br&gt;
is half right, and the missing half is what made this migration interesting.&lt;/p&gt;

&lt;p&gt;There were two axes that mattered, and the Part 0 overview names both of&lt;br&gt;
them: how load-bearing a report was, and how contested its metric&lt;br&gt;
definitions were.&lt;/p&gt;

&lt;p&gt;"Load-bearing" was not just query volume. It was a combination of replica&lt;br&gt;
traffic and business consequence. A report that firms used to make billing&lt;br&gt;
or collections decisions carried more weight than a case-summary report that&lt;br&gt;
someone glanced at occasionally, even if the two had similar query counts. A&lt;br&gt;
report could be low-traffic and still high-stakes, because the cost of it&lt;br&gt;
being wrong was measured in a firm's money rather than in database load.&lt;br&gt;
Load-bearing meant "how much breaks, and how badly, if this migration goes&lt;br&gt;
wrong."&lt;/p&gt;

&lt;p&gt;"Contested" was a different thing entirely. It measured whether the metric&lt;br&gt;
definitions behind a report required stakeholder alignment before migration&lt;br&gt;
was even technically possible. A report built on a disputed definition could&lt;br&gt;
not move, because there was nothing for it to move onto. The semantic model&lt;br&gt;
demanded exactly one canonical version of each metric, and until the&lt;br&gt;
stakeholders agreed on what "trust balance" meant, the model had no version&lt;br&gt;
of it to offer. Contested meant "how much agreement has to happen before the&lt;br&gt;
new layer can accept this at all."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd423b09to5xz1enzftnx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd423b09to5xz1enzftnx.png" alt="Migration order 2x2 matrix: load-bearing vs contested with domains plotted" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How the two axes interacted
&lt;/h2&gt;

&lt;p&gt;Plot every report on those two axes and the sequence writes itself, mostly.&lt;/p&gt;

&lt;p&gt;Low-load, low-contested reports went first. These were the proof-of-concept&lt;br&gt;
migrations, the ones where the goal was not the latency win but the&lt;br&gt;
demonstration: prove the pattern works, validate that the new layer returns&lt;br&gt;
correct results, build confidence before touching anything that a firm would&lt;br&gt;
notice. If something went wrong here, the blast radius was small.&lt;/p&gt;

&lt;p&gt;High-load, low-contested reports were the compelling early wins. Once the&lt;br&gt;
pattern was proven, these were where the latency numbers came from. Their&lt;br&gt;
definitions were settled, so there was nothing blocking them, and their&lt;br&gt;
traffic meant moving them mattered. This is the group that made the project&lt;br&gt;
look successful to anyone watching the dashboards.&lt;/p&gt;

&lt;p&gt;High-contested reports went last, and here is the part that traffic-first&lt;br&gt;
sequencing gets wrong: they went last regardless of load. A high-traffic,&lt;br&gt;
high-contested report could not jump the queue on the strength of its&lt;br&gt;
traffic, because the semantic model physically could not accept it until its&lt;br&gt;
definitions were settled. Contention was a hard gate, not a soft&lt;br&gt;
preference. You can defer a low-priority migration; you cannot migrate a&lt;br&gt;
report whose central metric has three competing definitions and no agreed&lt;br&gt;
winner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Starting with case analytics
&lt;/h2&gt;

&lt;p&gt;The first domain we migrated was case analytics: active case count, case&lt;br&gt;
status, and the reports built on them. On both axes, these scored lowest.&lt;/p&gt;

&lt;p&gt;"Active case" is close to an enum-level concept. A case is open or it is&lt;br&gt;
not, and while there are edge cases, they are the kind you can resolve by&lt;br&gt;
reading a status column rather than by convening four teams. There were no&lt;br&gt;
IOLTA implications, no invoicing dependencies, no money riding on the exact&lt;br&gt;
boundary of the definition. And these reports were lower-traffic than the&lt;br&gt;
billing reports that firms hit constantly.&lt;/p&gt;

&lt;p&gt;That made case analytics the right proof-of-concept. We migrated these&lt;br&gt;
reports first not because they were urgent but because they were safe. They&lt;br&gt;
let us exercise the whole pipeline -- semantic model, materialized view,&lt;br&gt;
GraphQL query, cache, frontend shell -- against real reports without&lt;br&gt;
gambling on a definition that might still be under dispute. By the time we&lt;br&gt;
moved to anything financial, the pattern had already survived contact with&lt;br&gt;
production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why trust accounting was last
&lt;/h2&gt;

&lt;p&gt;At the far corner of the grid sat trust accounting, and it stayed there&lt;br&gt;
until the end.&lt;/p&gt;

&lt;p&gt;Trust balance has three legitimate answers under IOLTA, the rules governing&lt;br&gt;
how law firms hold client money. There is the bank statement figure, the&lt;br&gt;
firm ledger figure, and the per-client sub-ledger figure, and each is&lt;br&gt;
legally valid in a different reporting context. This is not a bug or a&lt;br&gt;
disagreement to be resolved by picking the smartest engineer's preference.&lt;br&gt;
All three are simultaneously correct, and choosing which one a given report&lt;br&gt;
should show is a compliance decision, not a technical one.&lt;/p&gt;

&lt;p&gt;No trust report could migrate until that question was resolved and the&lt;br&gt;
domain PM signed off on a canonical definition, or on an explicit scoping of&lt;br&gt;
which definition belonged to which report. So the trust reports sat in the&lt;br&gt;
queue while the alignment work ran alongside the migration. That alignment&lt;br&gt;
work is the subject of Part 8; for sequencing purposes, the relevant fact is&lt;br&gt;
that trust was gated on a conversation, not on engineering effort, and the&lt;br&gt;
conversation took time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parallel running and shadow comparison
&lt;/h2&gt;

&lt;p&gt;Each report migrated one at a time, and each one went through a shadow&lt;br&gt;
comparison period before cutover. The legacy query and the new layer ran&lt;br&gt;
simultaneously, both producing output, and we compared the two before&lt;br&gt;
decommissioning the legacy path. A migration was not "done" when the new&lt;br&gt;
query returned results; it was done when the new query had agreed with the&lt;br&gt;
old one across a validation window.&lt;/p&gt;

&lt;p&gt;For billing and trust reports, that window was longer. When a report drives&lt;br&gt;
a firm's collections decisions, or touches client trust money, the bar for&lt;br&gt;
"we are confident these match" is higher than it is for a case-count report.&lt;br&gt;
More confidence required a longer comparison period, which is another way&lt;br&gt;
the load-bearing axis expressed itself: it did not just influence order, it&lt;br&gt;
influenced how carefully each cutover happened.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhjetw0unp16h8yyo9mm4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhjetw0unp16h8yyo9mm4.png" alt="Shadow comparison cutover method: dual-run, compare, resolve, cut over" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What stayed behind
&lt;/h2&gt;

&lt;p&gt;The honest version of "we migrated the system" is that we migrated most of&lt;br&gt;
it. Twenty to twenty-five of the thirty-plus reports moved. The remaining&lt;br&gt;
five to ten did not, and they fell into two groups.&lt;/p&gt;

&lt;p&gt;The first group was low-traffic, edge-case reports: one-firm customizations,&lt;br&gt;
rarely-accessed summaries, reports where the cost of migration plainly&lt;br&gt;
exceeded the benefit. Nobody was going to reap a latency win on a report run&lt;br&gt;
twice a quarter, and rebuilding it on the new layer was effort spent for&lt;br&gt;
almost no return.&lt;/p&gt;

&lt;p&gt;The second group was blocked on metric definitions that were never fully&lt;br&gt;
resolved. Some disagreements did not converge. Where a definition fight&lt;br&gt;
stalled and the report was not important enough to force the issue, the&lt;br&gt;
report stayed on the legacy query path permanently. Those reports are still&lt;br&gt;
there, as far as I know, quietly running their own SQL against the replica,&lt;br&gt;
each with its own private answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every migrated report was a governance artifact
&lt;/h2&gt;

&lt;p&gt;Here is what the sequencing revealed that the design work had obscured.&lt;br&gt;
Every report that successfully migrated was also proof that a definition had&lt;br&gt;
been agreed on. You could not move a report onto the semantic model without&lt;br&gt;
first settling what its metrics meant, so a migrated report was a&lt;br&gt;
governance artifact as much as an engineering one.&lt;/p&gt;

&lt;p&gt;The write-off case from Part 0 makes this concrete. A write-off was excluded&lt;br&gt;
from one billing report and included in another, and that was not a bug. It&lt;br&gt;
was a definition that had never been made explicit, and migration forced it&lt;br&gt;
into the open. Someone had to decide, on the record, which treatment was&lt;br&gt;
correct, and that decision became part of the model.&lt;/p&gt;

&lt;p&gt;This is why sequencing was not a purely mechanical exercise. The order in&lt;br&gt;
which reports migrated determined which definitions got forced into the open&lt;br&gt;
in which order, and that in turn determined which stakeholder conversations&lt;br&gt;
were live at any given moment. Migrating the billing reports meant the&lt;br&gt;
write-off conversation had to happen then; deferring trust meant the IOLTA&lt;br&gt;
conversation could wait. The migration schedule was, in effect, a schedule&lt;br&gt;
for the arguments.&lt;/p&gt;

&lt;p&gt;That is also why the sequencing decisions were made jointly with the program&lt;br&gt;
PM, not purely by engineering. Which reports moved first was not only a&lt;br&gt;
technical call. The PM had visibility I did not into which firm-facing&lt;br&gt;
reports were politically sensitive or tied to external stakeholder&lt;br&gt;
commitments, and that visibility fed directly into the order. Co-owning the&lt;br&gt;
sequence with someone who understood the business stakes was not a courtesy.&lt;br&gt;
It was the only way to sequence a migration whose real constraint was&lt;br&gt;
agreement rather than code.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>rails</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Authorization at the API Layer: Why Field-Level Checks Live in GraphQL</title>
      <dc:creator>Son Do</dc:creator>
      <pubDate>Thu, 23 Jul 2026 09:50:21 +0000</pubDate>
      <link>https://dev.to/son_do_3543eb233b59d424bc/authorization-at-the-api-layer-why-field-level-checks-live-in-graphql-4c3m</link>
      <guid>https://dev.to/son_do_3543eb233b59d424bc/authorization-at-the-api-layer-why-field-level-checks-live-in-graphql-4c3m</guid>
      <description>&lt;p&gt;&lt;em&gt;Series -- From Ad-Hoc SQL to a Semantic Layer:&lt;/em&gt; &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/replacing-30-ad-hoc-sql-reports-with-a-semantic-layer-28fo"&gt;0: Overview&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/ten-reports-ten-answers-why-ad-hoc-sql-doesnt-scale-4771"&gt;1: Ten Answers&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/why-we-didnt-just-add-more-indexes-choosing-a-semantic-layer-2da3"&gt;2: Why Not Indexes&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/one-definition-30-reports-how-we-designed-the-metrics-model-3g3j"&gt;3: Metrics Model&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/never-miss-a-refresh-building-the-materialized-view-pipeline-4ifi"&gt;4: View Pipeline&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/schema-without-churn-graphql-design-for-an-evolving-metrics-model-a41"&gt;5: Schema Design&lt;/a&gt; · &lt;strong&gt;6: Authorization&lt;/strong&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/which-report-goes-first-the-migration-sequencing-logic-2m3k"&gt;7: Migration Sequence&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-hardest-part-wasnt-the-code-aligning-stakeholders-on-metric-definitions-oi6"&gt;8: Stakeholder Alignment&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Part 1 of this series described a call where two reports claimed to show&lt;br&gt;
billable hours for the same firm over the same period and disagreed, because&lt;br&gt;
each report had quietly decided for itself what "billable" meant. There was a&lt;br&gt;
second version of that same problem that nobody put on a call, because it&lt;br&gt;
never surfaced as a visible contradiction. It surfaced as a compliance&lt;br&gt;
question instead.&lt;/p&gt;

&lt;p&gt;Each of the 30-plus legacy reports was also hand-rolling its own permission&lt;br&gt;
checks against raw tables. One report decided for itself whether the person&lt;br&gt;
running it was allowed to see a firm's trust balance. Another report, pulling&lt;br&gt;
from the same underlying data, made its own separate decision. Thirty reports&lt;br&gt;
meant 30 different answers to "who can see this data," scattered across 30&lt;br&gt;
different SQL queries, with no single place that said what the rule actually&lt;br&gt;
was. For a legal SaaS handling trust accounting and payment data, that&lt;br&gt;
fragmentation was not just a maintenance burden. It was a compliance surface.&lt;br&gt;
Every one of those 30 decisions was a place where the rule could be wrong, and&lt;br&gt;
no way to audit whether it was.&lt;/p&gt;

&lt;p&gt;When I designed the unified analytics platform, authorization was not a&lt;br&gt;
feature I added to the GraphQL API after the fact. It was the constraint that&lt;br&gt;
decided where the API layer had to sit at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three places the check could live
&lt;/h2&gt;

&lt;p&gt;There were three honest candidates for where authorization could be enforced,&lt;br&gt;
and picking the wrong one was easy to do because two of the three look&lt;br&gt;
reasonable until you push on them.&lt;/p&gt;

&lt;p&gt;The first was the controller layer: Rails, before the GraphQL resolver ever&lt;br&gt;
runs. This is the instinct most Rails engineers reach for first, because&lt;br&gt;
that is where authorization usually lives. The problem is that it does not&lt;br&gt;
actually centralize anything. Each report route still has to know which&lt;br&gt;
fields it is about to expose and gate each one. You have moved the check&lt;br&gt;
upstream of the query, but you still need a check per route per field. Thirty&lt;br&gt;
reports still means thirty places that each have to remember the rule. I would&lt;br&gt;
have rebuilt the exact fragmentation I was trying to eliminate, just one layer&lt;br&gt;
higher up the stack.&lt;/p&gt;

&lt;p&gt;The second was the view layer: the shared React report framework deciding&lt;br&gt;
what to render. A report component could check whether the current user holds&lt;br&gt;
&lt;code&gt;view_trust_accounting&lt;/code&gt; and simply not draw the trust balance column if they&lt;br&gt;
do not. This looks clean, and for a lot of applications it would be fine. It&lt;br&gt;
was not fine here, and the reason is the distinction that ends up being the&lt;br&gt;
whole point of this post: by the time the React framework decides not to&lt;br&gt;
render a field, the data has already been fetched. The GraphQL query ran, the&lt;br&gt;
resolver returned the trust balance, it traveled over the wire, and it is&lt;br&gt;
sitting in the browser's memory. The UI is choosing not to paint it. For&lt;br&gt;
IOLTA-governed trust data and PCI-relevant payment fields, withholding the&lt;br&gt;
field in the UI is not the same thing as withholding the data. The data left&lt;br&gt;
the server. That is the event that matters, and the view layer happens after&lt;br&gt;
it.&lt;/p&gt;

&lt;p&gt;That leaves the API layer: the GraphQL field itself. This is the only boundary&lt;br&gt;
in the entire stack where declining to include a field in the response is the&lt;br&gt;
same act as declining to send the data. If the resolver does not return the&lt;br&gt;
trust balance, the trust balance does not enter the query result, does not&lt;br&gt;
cross the network, and never reaches a browser that could be inspected. There&lt;br&gt;
is no gap between "the user cannot see this" and "the data did not move."&lt;/p&gt;

&lt;p&gt;That property is what settled it. Not convenience, not consolidation for its&lt;br&gt;
own sake. The GraphQL field is the only place where "who can see this field"&lt;br&gt;
and "does this data leave the server" are the same decision.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj6wyprummknnkj6l2efz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj6wyprummknnkj6l2efz.png" alt="Three auth candidate layers: controller and view rejected, API layer correct" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two layers that compose without merging
&lt;/h2&gt;

&lt;p&gt;The implementation splits authorization into two boundaries that stack rather&lt;br&gt;
than blend into each other.&lt;/p&gt;

&lt;p&gt;The first boundary is firm isolation, and it lives in the query scope layer. A&lt;br&gt;
shared &lt;code&gt;BaseResolver&lt;/code&gt; sits under every resolver in the schema, and before any&lt;br&gt;
field logic runs, it scopes the query to &lt;code&gt;current_firm&lt;/code&gt;. MyCase is&lt;br&gt;
multi-tenant with law firms as the tenant, so this is the foundational rule:&lt;br&gt;
no query, from any report, ever returns data belonging to a firm other than&lt;br&gt;
the one making the request. Because it lives in the base resolver rather than&lt;br&gt;
in individual resolvers, there is no report and no field that can opt out of&lt;br&gt;
it, forget it, or get it subtly wrong. Firm isolation is not something a&lt;br&gt;
resolver remembers to do. It is the ground every resolver stands on.&lt;/p&gt;

&lt;p&gt;The second boundary is field-level access, and it lives in per-type Pundit&lt;br&gt;
policy objects. A trust analytics type has a policy that checks the&lt;br&gt;
&lt;code&gt;view_trust_accounting&lt;/code&gt; permission. A financial analytics type has its own&lt;br&gt;
policy, with stricter rules on the PCI-relevant fields, because payment data&lt;br&gt;
carries obligations that a case-count metric does not. The &lt;code&gt;BaseResolver&lt;/code&gt;&lt;br&gt;
invokes the appropriate Pundit policy for each type. The authorization logic&lt;br&gt;
lives in the policy object, not inline in the resolver, which means the rule&lt;br&gt;
for a given type is written in exactly one file and read from exactly one&lt;br&gt;
place.&lt;/p&gt;

&lt;p&gt;These two boundaries compose without merging. Firm isolation runs first, at&lt;br&gt;
the query scope, and establishes the tenant. Then, inside that already-scoped&lt;br&gt;
query, the field-level Pundit policies run. A user has to clear firm context&lt;br&gt;
before any field-level check is even asked, and clearing firm context tells&lt;br&gt;
you nothing about whether they can see trust balances. The &lt;code&gt;BaseResolver&lt;/code&gt;&lt;br&gt;
guarantees the first; the policies guarantee the second. Keeping them separate&lt;br&gt;
is deliberate: firm isolation is a tenancy invariant that must never have an&lt;br&gt;
exception, while field-level access is a permission model that legitimately&lt;br&gt;
varies by field and by domain. Collapsing them into one check would have&lt;br&gt;
meant re-deriving the tenancy invariant every time I touched a field rule.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Figlqb3ecxhjd1p6qrqkj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Figlqb3ecxhjd1p6qrqkj.png" alt="Two-layer authorization model: BaseResolver firm scope and per-type Pundit policies" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why IOLTA and PCI drove the design
&lt;/h2&gt;

&lt;p&gt;It would be easy to present field-level authorization as a nice-to-have that&lt;br&gt;
happened to also satisfy compliance. That reverses the actual causality.&lt;br&gt;
Compliance is why the model looks the way it does.&lt;/p&gt;

&lt;p&gt;IOLTA governs how law firms hold client trust funds, and access to trust&lt;br&gt;
account data has to be controlled in a way that can be audited. PCI compliance&lt;br&gt;
for payment-adjacent fields carries a parallel requirement: you have to be&lt;br&gt;
able to demonstrate that cardholder-adjacent data is gated at the data layer,&lt;br&gt;
not merely hidden in the interface. Both of these are questions an auditor can&lt;br&gt;
ask, and both of them demand a specific kind of answer. Not "we filter it in&lt;br&gt;
the UI." Not "each report handles it." The answer that satisfies the question&lt;br&gt;
is "this field is protected by this Pundit policy, and here is the policy&lt;br&gt;
definition."&lt;/p&gt;

&lt;p&gt;That answer is only possible because the checks are consolidated. Thirty&lt;br&gt;
reports each making their own access decision against raw tables cannot&lt;br&gt;
produce an audit trail, because there is no single artifact to point at. The&lt;br&gt;
field-level model gives both IOLTA and PCI a concrete, inspectable boundary: a&lt;br&gt;
named policy object, attached to a named type, checking a named permission.&lt;br&gt;
The compliance story is a byproduct of putting the rule in one place, and&lt;br&gt;
putting the rule in one place was only worth doing at the layer where the rule&lt;br&gt;
also controls the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payoff for every report that came after
&lt;/h2&gt;

&lt;p&gt;The operational return showed up in reports I never touched. As described in&lt;br&gt;
Part 5, new reports get composed from an L7 report-definition config rather&lt;br&gt;
than written as fresh SQL. Because authorization lives in the schema and the&lt;br&gt;
policy layer, a new report defined through that config inherits the entire&lt;br&gt;
authorization model automatically. Firm isolation, trust-accounting gating,&lt;br&gt;
payment-field rules: all of it is already there, underneath the field the new&lt;br&gt;
report is consuming.&lt;/p&gt;

&lt;p&gt;The important part is what a report author cannot do. They cannot forget to&lt;br&gt;
add a permission check, because the check was never theirs to add. It is not&lt;br&gt;
in the report config at all. It lives one layer down, in the schema, attached&lt;br&gt;
to the type. A new report is a new arrangement of fields that are already&lt;br&gt;
governed. There is no path through the config where a report author writes an&lt;br&gt;
access rule, which means there is no path where they write it wrong.&lt;/p&gt;

&lt;p&gt;This is the difference between "authorization-aware" as an adjective bolted&lt;br&gt;
onto a finished GraphQL API and authorization-aware as a property the schema&lt;br&gt;
was designed to have from the start. I did not build the analytics API and&lt;br&gt;
then add access control. I put field-level access in the schema as a&lt;br&gt;
first-class constraint, and that constraint is what made it safe to open the&lt;br&gt;
same API to four separate report domains without each one auditing its own&lt;br&gt;
access logic. The reports did not have to be trusted to enforce the rules.&lt;br&gt;
The layer they query does it for them, once, in the only place where getting&lt;br&gt;
it right and keeping the data on the server are the same thing.&lt;/p&gt;

</description>
      <category>graphql</category>
      <category>backend</category>
      <category>rails</category>
      <category>security</category>
    </item>
    <item>
      <title>Schema Without Churn: GraphQL Design for an Evolving Metrics Model</title>
      <dc:creator>Son Do</dc:creator>
      <pubDate>Thu, 23 Jul 2026 09:50:18 +0000</pubDate>
      <link>https://dev.to/son_do_3543eb233b59d424bc/schema-without-churn-graphql-design-for-an-evolving-metrics-model-a41</link>
      <guid>https://dev.to/son_do_3543eb233b59d424bc/schema-without-churn-graphql-design-for-an-evolving-metrics-model-a41</guid>
      <description>&lt;p&gt;&lt;em&gt;Series -- From Ad-Hoc SQL to a Semantic Layer:&lt;/em&gt; &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/replacing-30-ad-hoc-sql-reports-with-a-semantic-layer-28fo"&gt;0: Overview&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/ten-reports-ten-answers-why-ad-hoc-sql-doesnt-scale-4771"&gt;1: Ten Answers&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/why-we-didnt-just-add-more-indexes-choosing-a-semantic-layer-2da3"&gt;2: Why Not Indexes&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/one-definition-30-reports-how-we-designed-the-metrics-model-3g3j"&gt;3: Metrics Model&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/never-miss-a-refresh-building-the-materialized-view-pipeline-4ifi"&gt;4: View Pipeline&lt;/a&gt; · &lt;strong&gt;5: Schema Design&lt;/strong&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/authorization-at-the-api-layer-why-field-level-checks-live-in-graphql-4c3m"&gt;6: Authorization&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/which-report-goes-first-the-migration-sequencing-logic-2m3k"&gt;7: Migration Sequence&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-hardest-part-wasnt-the-code-aligning-stakeholders-on-metric-definitions-oi6"&gt;8: Stakeholder Alignment&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"Semantic layer" sounds like a foundation. Something you pour once, let cure, and build on top of with confidence that it will not move. That framing is exactly backwards for the state we were actually in. During the migration, the semantic model was the least stable thing in the system. Domain owners were seeing, for the first time, what "billable hours" had meant across four different report histories, and they were renegotiating the definition in real time. A write-off excluded from one billing report but counted in another was not a bug to fix; it was a decision nobody had ever been forced to make out loud.&lt;/p&gt;

&lt;p&gt;So the GraphQL schema had a genuinely awkward job. It was the authoritative interface that live, in-production reports queried against, and it sat on top of a model whose definitions were still being finalized underneath it. A schema designed the obvious way would have needed a type change every time a metric definition shifted, and every one of those changes risked breaking a report that was already serving law firms. The whole post is about how we shaped the schema so those changes had somewhere to go that was not "break the consumers."&lt;/p&gt;

&lt;h2&gt;
  
  
  What the schema's boundary is for
&lt;/h2&gt;

&lt;p&gt;In the stack, GraphQL is L3. It sits between the materialized views at L2 (covered in &lt;a href="//./DRAFT-2026-07-semantic-04-view-pipeline.md"&gt;Part 4&lt;/a&gt;) and the React report framework at L4. The rule that made everything else tractable is a single sentence: reports query the schema, and reports never write SQL against the materialized views directly.&lt;/p&gt;

&lt;p&gt;That boundary is the entire point of having an API layer at all. If reports could reach the views, then authorization, versioning, and consistency would each have to be re-solved by every report author, which is the exact failure mode the project existed to kill. Putting one access surface in front of the model means those three concerns live in one place. When the definition of a metric moves, it moves behind the schema. When a firm's permissions decide what a report can see, that decision is enforced at the schema. The report on the other side of the boundary does not know or care that anything underneath it changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The type hierarchy: domain-scoped roots, metrics-specific types
&lt;/h2&gt;

&lt;p&gt;The first real design decision was the shape of the type hierarchy, and I landed on a hybrid. The query roots are domain-scoped, and each one returns a metrics-specific type. The required context lives as arguments on the domain root field, not nested somewhere inside.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;billingAnalytics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firmId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;!,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dateRange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DateRangeInput&lt;/span&gt;&lt;span class="p"&gt;!):&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BillingAnalytics&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;trustAnalytics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firmId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;!,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dateRange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DateRangeInput&lt;/span&gt;&lt;span class="p"&gt;!):&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TrustAnalytics&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;caseAnalytics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firmId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;!,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dateRange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DateRangeInput&lt;/span&gt;&lt;span class="p"&gt;!):&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;CaseAnalytics&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;financialAnalytics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firmId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;!,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dateRange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DateRangeInput&lt;/span&gt;&lt;span class="p"&gt;!):&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;FinancialAnalytics&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BillingAnalytics&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;billableHours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Float&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;invoicedHours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Float&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;writeOffAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Float&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A billing report queries &lt;code&gt;billingAnalytics&lt;/code&gt;. It does not query &lt;code&gt;cases&lt;/code&gt; or &lt;code&gt;timeEntries&lt;/code&gt;. That is deliberate: the domain objects that the transactional side of the product is built from do not leak into the reporting layer at all. The reporting layer sees metrics, grouped by domain, and nothing else.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffmp6upg5gmuvryrfz69g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffmp6upg5gmuvryrfz69g.png" alt="GraphQL hybrid type hierarchy: Query roots to analytics types to metric fields" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this hierarchy and not the other two
&lt;/h2&gt;

&lt;p&gt;Two alternatives were on the table, and rejecting them is what makes the hybrid a decision rather than a default.&lt;/p&gt;

&lt;p&gt;The first was to make domain objects the query root and hang metrics off them through entity relationships, so a billing report would traverse something like &lt;code&gt;cases { billingMetrics }&lt;/code&gt;. That reads naturally right up until you notice what it does to coupling. It ties the reporting layer to the &lt;em&gt;structure&lt;/em&gt; of the domain model. Every report would have to know how to compose through the entity graph to reach a number, and any refactor of how cases relate to matters or invoices would ripple straight into report queries. The reporting layer has no business knowing the domain model's join topology. Separate metrics types sever that dependency: a metric is a field on an analytics type, full stop, and how it is computed underneath is invisible.&lt;/p&gt;

&lt;p&gt;The second question was where the context arguments go. Putting &lt;code&gt;firmId&lt;/code&gt; and &lt;code&gt;dateRange&lt;/code&gt; on the root domain field, rather than nesting them on individual metric fields, means the firm context and the reporting window are always required and always explicit. There is no way to ask for &lt;code&gt;billableHours&lt;/code&gt; without having already declared which firm and which date range you mean. In a multi-tenant legal SaaS that is not a convenience; it is the property that keeps firm A's numbers from ever being computable in a query that was scoped to firm B.&lt;/p&gt;

&lt;h2&gt;
  
  
  The anti-churn rule: additive-only, deprecate, then remove
&lt;/h2&gt;

&lt;p&gt;The type hierarchy solved coupling. It did not, on its own, solve the moving-model problem. That took a rule about how the schema is allowed to change while reports are live on it, and the rule is additive-only.&lt;/p&gt;

&lt;p&gt;A new metric is a new field on the relevant analytics type. Existing fields are never removed and never renamed while any report is still reading them. When a metric genuinely needs to go away or change meaning, the old field gets GraphQL's &lt;code&gt;@deprecated&lt;/code&gt; directive with a reason, and it stays in the schema, still resolving, until every consumer has moved off it. Only then does it get removed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BillingAnalytics&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;billableHours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Float&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;invoicedHours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Float&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;writeOffAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Float&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="err"&gt;"&lt;/span&gt;&lt;span class="n"&gt;Use&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;billableHours&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;this&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;excluded&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="n"&gt;offs&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;inconsistently&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;across&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;firms&lt;/span&gt;&lt;span class="err"&gt;."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;billableHoursLegacy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Float&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="n"&gt;deprecated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;"&lt;/span&gt;&lt;span class="n"&gt;Superseded&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;billableHours&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;unified&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="n"&gt;off&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;definition&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Remove&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;reports&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cut&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;over&lt;/span&gt;&lt;span class="err"&gt;."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same lifecycle the metric classes use underneath. Where the Ruby side keeps a &lt;code&gt;VERSION&lt;/code&gt; and lets an old and new definition of a metric coexist so history is not silently rewritten, the schema keeps the old field and the new field coexisting so no live report reads a definition that moved out from under it. It is one pattern applied at two layers: a definition change is always a coexistence period, never a hard cutover. When a domain owner renegotiated what "billable" meant, that change landed as a new field with a new definition, the old field went deprecated, and the reports that depended on the old meaning kept working until they were deliberately migrated. Nothing broke on the day the definition changed, which is the only way a moving model and live consumers can share a schema at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl1ph6ezx7f7agilawx7g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl1ph6ezx7f7agilawx7g.png" alt="Additive-only schema evolution with @deprecated" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The payoff at L7: reports as config, not SQL
&lt;/h2&gt;

&lt;p&gt;All of this pointed at one concrete result, and it lives at L7, the report-definition config layer. Because the schema is a stable, composable surface of metrics grouped by domain, a new report is not a new query written against production. It is a config entry that selects fields from one or more analytics types.&lt;/p&gt;

&lt;p&gt;That is the mechanism behind the number I care about most from this project: new-report delivery going from two to three weeks down to about four days. A new report required no schema change and no new SQL. It composed fields that already existed on types that already existed, through the same authorized access surface every other report used. The engineer who built the first report on the new platform never wrote a line of SQL against production, and the reason they did not have to is that the schema had been designed so that composing a report was a config problem, not a query-authoring problem.&lt;/p&gt;

&lt;p&gt;The insight I would carry to the next system is that the schema was designed for evolution, not for stability. Those are not the same goal, and confusing them is how you end up with a schema that is either brittle or frozen. Designing for evolution meant treating the anti-churn rules as load-bearing constraints from the first line of SDL, not as a governance policy bolted on after the schema was already in production. Additive-only, deprecate-then-remove, context on the root, metrics as fields: those were not conventions we adopted once churn became painful. They were the assumptions the schema was built on, precisely because we knew going in that the model underneath it was going to move.&lt;/p&gt;

&lt;p&gt;Part 6 goes into the authorization model that this access surface made possible: why field-level, firm-scoped checks belong at the GraphQL layer and nowhere else.&lt;/p&gt;

</description>
      <category>graphql</category>
      <category>backend</category>
      <category>rails</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Never Miss a Refresh: Building the Materialized View Pipeline</title>
      <dc:creator>Son Do</dc:creator>
      <pubDate>Thu, 23 Jul 2026 09:50:14 +0000</pubDate>
      <link>https://dev.to/son_do_3543eb233b59d424bc/never-miss-a-refresh-building-the-materialized-view-pipeline-4ifi</link>
      <guid>https://dev.to/son_do_3543eb233b59d424bc/never-miss-a-refresh-building-the-materialized-view-pipeline-4ifi</guid>
      <description>&lt;p&gt;&lt;em&gt;Series -- From Ad-Hoc SQL to a Semantic Layer:&lt;/em&gt; &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/replacing-30-ad-hoc-sql-reports-with-a-semantic-layer-28fo"&gt;0: Overview&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/ten-reports-ten-answers-why-ad-hoc-sql-doesnt-scale-4771"&gt;1: Ten Answers&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/why-we-didnt-just-add-more-indexes-choosing-a-semantic-layer-2da3"&gt;2: Why Not Indexes&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/one-definition-30-reports-how-we-designed-the-metrics-model-3g3j"&gt;3: Metrics Model&lt;/a&gt; · &lt;strong&gt;4: View Pipeline&lt;/strong&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/schema-without-churn-graphql-design-for-an-evolving-metrics-model-a41"&gt;5: Schema Design&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/authorization-at-the-api-layer-why-field-level-checks-live-in-graphql-4c3m"&gt;6: Authorization&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/which-report-goes-first-the-migration-sequencing-logic-2m3k"&gt;7: Migration Sequence&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-hardest-part-wasnt-the-code-aligning-stakeholders-on-metric-definitions-oi6"&gt;8: Stakeholder Alignment&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The metrics model from the last post gave us one place to define what a business word means. Materialized views gave us a fast place to read it from. But a materialized view is a photograph, not a mirror. It shows the data as it was the last time something took the picture, and the whole speed advantage of the semantic layer rested on a fact that was easy to underestimate: those pictures only stay useful if something keeps retaking them.&lt;/p&gt;

&lt;p&gt;I want to invert the Part 0 framing here, because the inversion is the point. The interesting problem was not how to invalidate the cache. It was how to design the refresh pipeline so that cache invalidation stopped being a separate problem at all. Once I saw that the refresh schedule and the cache freshness were the same question wearing two hats, most of the design fell out on its own. Getting there meant being honest about the failure modes of the word "schedule," which sounds solved right up until you ask what happens on the run that doesn't finish.&lt;/p&gt;

&lt;h2&gt;
  
  
  The refresh pipeline
&lt;/h2&gt;

&lt;p&gt;The scheduler was Sidekiq-Cron, chosen for the least glamorous reason available: Sidekiq was already in the stack. We had workers, a Redis instance behind them, retry semantics, and a web UI that on-call already knew how to read. A dedicated scheduling process, or pg_cron inside the database, would have meant a new operational surface to solve a problem the existing one already covered.&lt;/p&gt;

&lt;p&gt;The job itself was deliberately dumb. A single &lt;code&gt;ViewRefreshJob&lt;/code&gt; iterated every registered &lt;code&gt;Analytics::Metrics&lt;/code&gt; subclass and, for each one, ran &lt;code&gt;REFRESH MATERIALIZED VIEW CONCURRENTLY&lt;/code&gt; against that metric's backing view. Because the metrics model already knew every view name (that was the whole point of the registry from Part 3), the refresh job did not maintain its own list of what to refresh. It asked the model, so a newly added metric showed up in the next refresh cycle for free.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;CONCURRENTLY&lt;/code&gt; flag was not decoration. A plain &lt;code&gt;REFRESH MATERIALIZED VIEW&lt;/code&gt; takes an exclusive lock for the duration of the rebuild, so every report reading that view blocks until the refresh finishes. For a reporting layer that firms hit during business hours, that is a self-inflicted outage on a timer. &lt;code&gt;CONCURRENTLY&lt;/code&gt; rebuilds the view alongside the old one and swaps it in, so reads keep hitting the last good copy the entire time. It costs more and requires a unique index on the view, but it buys the one property this layer could not live without: the refresh is invisible to the person reading a report.&lt;/p&gt;

&lt;p&gt;Every run also logged its per-view duration to Datadog, which is what the monitoring later hung off of.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffferzw5vxyy7oshwz00q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffferzw5vxyy7oshwz00q.png" alt="ViewRefreshJob happy-path flow: Sidekiq-Cron to REFRESH CONCURRENTLY" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the background was the right place for the cost
&lt;/h2&gt;

&lt;p&gt;The trade the whole architecture made was to move expense off the read path and onto the refresh path. A report reading from a materialized view answered in single-digit milliseconds, because it was reading a purpose-built table, not reconstructing an answer out of the OLTP schema on every request. The bespoke queries these views replaced took seconds, and they took those seconds while a user waited. Rebuilding a view is not free, but that cost landed in a background worker with nobody watching a spinner. The reports felt instant because the slow part had already happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when a refresh fails
&lt;/h2&gt;

&lt;p&gt;Here is the question that separates a schedule from a pipeline: what does the system do on the run that does not finish. A worker gets OOM-killed, a rebuild deadlocks, the database is briefly unreachable. The refresh fails. Then what.&lt;/p&gt;

&lt;p&gt;The behavior we designed for, and got, was stale-and-silent. Postgres does not blank out a materialized view when a refresh fails; it keeps the last successful state. So a failed &lt;code&gt;ViewRefreshJob&lt;/code&gt; left the view exactly as it was after the previous good refresh. The job failed loudly in Sidekiq, where the failure belonged, but no exception reached the report consumer. The report kept returning the last good data as if nothing had happened.&lt;/p&gt;

&lt;p&gt;For this domain, that was the correct failure mode, on purpose. The consumers were billing reports, trust accounting, financial summaries. If a refresh fails at 6am, a firm looking at yesterday's numbers a few minutes into the delay is seeing data that is slightly stale but internally consistent and still trustworthy. Handing that same firm a stack trace where a number should be is strictly worse. Stale data a person can reason about beats an error a person cannot, so the pipeline was built to degrade toward the last known good answer rather than a visible break.&lt;/p&gt;

&lt;p&gt;Silent staleness is only safe if it is not actually silent to the people running the system, and that is what the monitoring was for. Two signals, not one. The first was obvious: a Datadog alert on &lt;code&gt;ViewRefreshJob&lt;/code&gt; failure, so a failed run paged someone. The second was subtler and more important. We tracked view age directly and alerted if any view had not refreshed within its expected cadence. That caught the failure modes the first one missed: the runs that never threw an exception because they never started, and the ones that hung past the window without technically failing. A job that fails is easy to see. A job that quietly stops running is the one that lets data rot, and the view-age check was the trap for exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cache invalidation insight
&lt;/h2&gt;

&lt;p&gt;Everything above sets up the decision I am most attached to on this project, because it is where two problems collapsed into one.&lt;/p&gt;

&lt;p&gt;Sitting in front of the views was Redis, and the question was how a cached report answer knew when to stop trusting itself. I looked hard at two standard approaches and turned both down, for reasons that only become visible once you understand how the views actually refresh.&lt;/p&gt;

&lt;p&gt;A fixed TTL, cache each answer for N minutes and then evict, has a blind spot that is structural, not tunable. A TTL knows how long it has been since the answer was cached. It knows nothing about when the underlying view last refreshed. Those are different clocks. A freshly refreshed view and a badly stale one, cached at the same moment, expire at the same moment, because the TTL cannot tell them apart. To guarantee freshness you would have to set the TTL short enough to chase the fastest possible refresh, throwing away most of the caching benefit to wait on a timer with no relationship to when the data actually changed.&lt;/p&gt;

&lt;p&gt;Event-driven invalidation, bust the specific keys the instant the underlying data changes, is more precise on paper and was a worse fit in practice. Being precise about "when the data changes" means hooking every write path that could change it, across case, billing, trust, and financial domains. That is an N-domain coordination problem, and one missed write path means a report serving stale data with no signal that anything is wrong. It was at its most dangerous during the migration, exactly the window this whole series lives inside, because some legacy reports were still writing directly against the same tables the new model was reading. The domain boundaries were the least stable they would ever be, and this approach demanded I coordinate across all of them at precisely that moment. It was the right tool aimed at the wrong week.&lt;/p&gt;

&lt;p&gt;So I keyed the cache to each view's own refresh timestamp instead. The cache key for a report answer included the refresh time of the view it was computed from, and the mechanism that follows is almost too simple to feel like a decision. When a view refreshes, its timestamp changes, so every key derived from it changes, so the next read is a cache miss that recomputes against fresh data. When a view does not refresh, whether on schedule or because a run failed, its timestamp does not move, the key stays valid, and the cache keeps serving the last good answer. A stale view produces a stale key produces a miss, with no invalidation code anywhere in the system. Freshness stopped being a second system I had to keep in sync with the refresh pipeline and became a side effect of the pipeline itself. Updating the data is what invalidates the cache, because the data's own freshness marker is baked into the key. The problem was never how to invalidate the cache; it was how to design the refresh so that invalidation was something the refresh already did.&lt;/p&gt;

&lt;p&gt;On the hot paths, the billing and case reports that firms hit on essentially every login, this landed at a 65% cache hit rate. Those are the queries where the same handful of views get read over and over between refreshes, which is exactly the shape timestamp keying rewards: a stable key between refreshes means repeated hits, and the one guaranteed miss per refresh is the miss you want.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8a62wfcvgs59pnweggfp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8a62wfcvgs59pnweggfp.png" alt="Cache invalidation strategy comparison: fixed TTL, event-driven, timestamp-keyed" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the whole thing
&lt;/h2&gt;

&lt;p&gt;What I keep coming back to is that the failure behavior was layered on purpose. A failure in the refresh pipeline degrades gracefully: the view holds its last good state, the timestamp-keyed cache keeps serving from it, the view-age check catches the staleness before a user does, and the reader never sees an error in a report a firm is using to move client money.&lt;/p&gt;

&lt;p&gt;None of the individual pieces here are exotic: Sidekiq, &lt;code&gt;REFRESH MATERIALIZED VIEW CONCURRENTLY&lt;/code&gt;, a Redis key with a timestamp in it. The design was in how they compose. Put the expense in the background, let failure fall toward stale rather than broken, and derive the cache key from the same freshness signal the refresh already produces, and the pieces stop being three systems that have to agree with each other and become one system that only has to be right once.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>rails</category>
      <category>redis</category>
    </item>
    <item>
      <title>One Definition, 30 Reports: How We Designed the Metrics Model</title>
      <dc:creator>Son Do</dc:creator>
      <pubDate>Thu, 23 Jul 2026 09:50:10 +0000</pubDate>
      <link>https://dev.to/son_do_3543eb233b59d424bc/one-definition-30-reports-how-we-designed-the-metrics-model-3g3j</link>
      <guid>https://dev.to/son_do_3543eb233b59d424bc/one-definition-30-reports-how-we-designed-the-metrics-model-3g3j</guid>
      <description>&lt;p&gt;&lt;em&gt;Series -- From Ad-Hoc SQL to a Semantic Layer:&lt;/em&gt; &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/replacing-30-ad-hoc-sql-reports-with-a-semantic-layer-28fo"&gt;0: Overview&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/ten-reports-ten-answers-why-ad-hoc-sql-doesnt-scale-4771"&gt;1: Ten Answers&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/why-we-didnt-just-add-more-indexes-choosing-a-semantic-layer-2da3"&gt;2: Why Not Indexes&lt;/a&gt; · &lt;strong&gt;3: Metrics Model&lt;/strong&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/never-miss-a-refresh-building-the-materialized-view-pipeline-4ifi"&gt;4: View Pipeline&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/schema-without-churn-graphql-design-for-an-evolving-metrics-model-a41"&gt;5: Schema Design&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/authorization-at-the-api-layer-why-field-level-checks-live-in-graphql-4c3m"&gt;6: Authorization&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/which-report-goes-first-the-migration-sequencing-logic-2m3k"&gt;7: Migration Sequence&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-hardest-part-wasnt-the-code-aligning-stakeholders-on-metric-definitions-oi6"&gt;8: Stakeholder Alignment&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The overview post ended on a governance insight: ten reports meant ten definitions of "billable hours," and fixing that meant someone had to arbitrate the disagreements. That is true, but it is only half the story. An agreement that lives in a meeting recording or a Confluence page decays the moment the next engineer writes the next report and quietly encodes their own interpretation again. The arbitration only sticks if it is embedded in something the code physically cannot route around.&lt;/p&gt;

&lt;p&gt;So the design question for this post is narrower than "how do we align stakeholders." It is: once you decide to fix ten reports and ten definitions, what does "one definition" actually look like as software? What is the artifact you build that makes the agreement load-bearing instead of aspirational?&lt;/p&gt;

&lt;h2&gt;
  
  
  What a metric definition has to contain
&lt;/h2&gt;

&lt;p&gt;The naive answer is that a metric is a SQL query. Define the query once, point every report at it, done. That is not wrong, but it undersells what the definition needs to carry to survive in a real codebase across a multi-month migration.&lt;/p&gt;

&lt;p&gt;A query alone has no name that a human reading Ruby can reason about. It has no version, which matters enormously the moment you need an old definition and a new definition to coexist while reports migrate one at a time. And it has no place to record the business meaning in plain language, which is the single most valuable thing a metric definition can carry, because the whole reason the old world produced ten answers was that the meaning lived in nobody's head consistently.&lt;/p&gt;

&lt;p&gt;So a metric definition, as we built it, had to contain four things. A name, so the definition is a first-class thing code refers to rather than an anonymous string of SQL. A version, so migration can happen incrementally without a flag-day cutover. A description, the business meaning stated in a sentence a domain owner could read and confirm. And a query, expressed not as raw SQL but as something reports could compose against rather than copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The class shape
&lt;/h2&gt;

&lt;p&gt;Here is what that turned into. Every named metric became a Ruby class in an &lt;code&gt;Analytics::Metrics&lt;/code&gt; namespace inside the Rails application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Analytics&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Metrics&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BillableHours&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Base&lt;/span&gt;
      &lt;span class="no"&gt;VERSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
      &lt;span class="no"&gt;DESCRIPTION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Billable-status time entries, write-offs excluded"&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;view_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;:analytics_billable_hours_v2&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;
        &lt;span class="no"&gt;TimeEntry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;joins&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:matter&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;billable: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;written_off: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;query&lt;/code&gt; method returns an ActiveRecord scope, not a string. That choice matters more than it looks. A scope is composable: a report that needs billable hours for a single firm over a date range starts from &lt;code&gt;BillableHours.query&lt;/code&gt; and adds its own &lt;code&gt;where&lt;/code&gt; clauses on top, rather than pasting the join-and-filter logic into yet another bespoke query. The definition of what "billable" means lives in exactly one method, and everything downstream builds from there. Reports call the metric class. Reports never write the &lt;code&gt;billable: true, written_off: false&lt;/code&gt; predicate themselves, because the entire point is that they no longer get to decide what that predicate is.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;view_name&lt;/code&gt; is where the definition meets the physical database. The materialized views that the overview post described are not hand-written SQL files maintained separately from these classes. They are generated from the class definitions. &lt;code&gt;analytics_billable_hours_v2&lt;/code&gt; exists because &lt;code&gt;BillableHours&lt;/code&gt; at &lt;code&gt;VERSION = 2&lt;/code&gt; declares it. The class is the source of truth, and the Postgres view is a materialized projection of what the class says. This is the mechanism that makes the canonical definition genuinely canonical: there is no second place where the meaning of billable hours could drift, because the view cannot exist without the class that defines it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4r1qq9mcdjfvcpgwcrmu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4r1qq9mcdjfvcpgwcrmu.png" alt="Analytics::Metrics class hierarchy: Base, BillableHours, TrustBalance, ActiveCase" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Versioning as a migration tool
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;VERSION = 2&lt;/code&gt; on that class is not decoration. It is what let this whole thing migrate incrementally instead of as a rewrite.&lt;/p&gt;

&lt;p&gt;When a metric definition changed, we did not edit the existing class in place and break every report reading from it. We wrote a new class at a new version, which generated a new view, and left the old class and its view running untouched. Reports migrated off the old version onto the new one at their own pace, one at a time, and the old definition kept serving its remaining consumers correctly the entire time. When the last report reading &lt;code&gt;v1&lt;/code&gt; finally moved to &lt;code&gt;v2&lt;/code&gt;, we dropped the &lt;code&gt;v1&lt;/code&gt; class and its view.&lt;/p&gt;

&lt;p&gt;The cost of this is honest: view accumulation. For a while you have two materialized views representing two versions of the same metric, both refreshing, both taking up space, and you are carrying that duplication until the last laggard report cuts over. We accepted that cost deliberately. The alternative was coordinating a synchronized cutover of every report that touched a metric, at the same moment, which is exactly the kind of flag-day migration that turns a multi-month project into a single terrifying deploy. Coexistence bought us the ability to be incremental, and being incremental was the only way a team our size was going to migrate 20-plus reports without stopping the world.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fae9o3qtpwwjuc3b6n0xd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fae9o3qtpwwjuc3b6n0xd.png" alt="Metric versioning coexistence: v1 and v2 running in parallel during migration" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the model got hard
&lt;/h2&gt;

&lt;p&gt;If every metric had been as clean as billable hours, this would be a tidy engineering story and not much else. The hard ones were the metrics where more than one definition was legitimately correct.&lt;/p&gt;

&lt;p&gt;Trust balance was the sharpest example. A law firm holds client money in trust, and "how much is in trust" has at least three legally meaningful answers. There is the bank statement balance, the actual cash sitting in the trust account. There is the firm ledger balance, what the firm's own books say is owed to clients in aggregate. And there is the per-client sub-ledger balance, what a specific client has in trust right now. Under IOLTA rules governing client trust accounting, all three are real, all three get reported in different contexts, and they can legitimately differ from one another at any given moment because of timing, uncleared transactions, and reconciliation lag.&lt;/p&gt;

&lt;p&gt;This was not a case where one definition was right and the others were sloppy. It was a compliance-stakes alignment problem that no amount of reading the code could resolve, because the code was not where the answer lived. Getting to a single canonical &lt;code&gt;TrustBalance&lt;/code&gt; metric, or deciding we actually needed three distinct named metrics, required pulling in the people who owned the compliance obligation and having them state which balance a given report was legally supposed to show. The class shape did not solve that. What the class shape did was give the resolution a permanent home: once the stakeholders agreed, the agreement became a &lt;code&gt;DESCRIPTION&lt;/code&gt; string and a &lt;code&gt;query&lt;/code&gt; method that could not be quietly reinterpreted later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model evolved as reality forced it to
&lt;/h2&gt;

&lt;p&gt;The metrics model was not designed once, up front, and then frozen. It grew as the migration surfaced definitions nobody had ever written down.&lt;/p&gt;

&lt;p&gt;The write-off case from the overview is the cleanest illustration. Write-offs were excluded from billable hours in one billing report and included in another. Neither was a bug. They were two undocumented decisions, made by different people at different times, that had coexisted for years precisely because there was no single place where the treatment of write-offs had to be stated. Migration forced the question into the open. You cannot write &lt;code&gt;where(written_off: false)&lt;/code&gt; in a shared &lt;code&gt;BillableHours.query&lt;/code&gt; without someone confirming that write-offs are, in fact, excluded, and confirming it as a decision that now applies everywhere.&lt;/p&gt;

&lt;p&gt;That reframed what a migration actually was. Moving a report onto the semantic layer was never just a technical port. Each one was a governance artifact, proof that a definition had been explicitly agreed on, written into a class, and made binding on every future report that touched the same metric. The model got more complete with every report we moved, not because we planned each addition, but because each report dragged one more buried assumption into the light.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payoff in the shape
&lt;/h2&gt;

&lt;p&gt;The reason all of this was worth building as a class hierarchy rather than a folder of SQL files shows up in what the &lt;code&gt;Base&lt;/code&gt; class bought us for free.&lt;/p&gt;

&lt;p&gt;Because every metric is a subclass of &lt;code&gt;Base&lt;/code&gt;, every metric automatically participates in the machinery around it without any per-metric wiring. The refresh pipeline that keeps the materialized views current, which is the subject of the next post, iterates over the registered subclasses and refreshes each one's view. Nobody adds a metric to a refresh schedule by hand. The GraphQL schema that reports actually query, which is the post after that, is generated from the same registry. A new metric becomes a queryable field the moment its class exists.&lt;/p&gt;

&lt;p&gt;That is the quiet win of putting the definition in a class. You are not just recording what "billable hours" means in one place. You are making every metric a citizen of a system that already knows how to refresh it, expose it, and authorize access to it, so that adding the thirty-first metric costs almost nothing beyond agreeing on what it means. And agreeing on what it means, as the trust-balance story showed, was always the expensive part.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>rails</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Why We Didn't Just Add More Indexes: Choosing a Semantic Layer</title>
      <dc:creator>Son Do</dc:creator>
      <pubDate>Thu, 23 Jul 2026 09:50:07 +0000</pubDate>
      <link>https://dev.to/son_do_3543eb233b59d424bc/why-we-didnt-just-add-more-indexes-choosing-a-semantic-layer-2da3</link>
      <guid>https://dev.to/son_do_3543eb233b59d424bc/why-we-didnt-just-add-more-indexes-choosing-a-semantic-layer-2da3</guid>
      <description>&lt;p&gt;&lt;em&gt;Series -- From Ad-Hoc SQL to a Semantic Layer:&lt;/em&gt; &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/replacing-30-ad-hoc-sql-reports-with-a-semantic-layer-28fo"&gt;0: Overview&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/ten-reports-ten-answers-why-ad-hoc-sql-doesnt-scale-4771"&gt;1: Ten Answers&lt;/a&gt; · &lt;strong&gt;2: Why Not Indexes&lt;/strong&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/one-definition-30-reports-how-we-designed-the-metrics-model-3g3j"&gt;3: Metrics Model&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/never-miss-a-refresh-building-the-materialized-view-pipeline-4ifi"&gt;4: View Pipeline&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/schema-without-churn-graphql-design-for-an-evolving-metrics-model-a41"&gt;5: Schema Design&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/authorization-at-the-api-layer-why-field-level-checks-live-in-graphql-4c3m"&gt;6: Authorization&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/which-report-goes-first-the-migration-sequencing-logic-2m3k"&gt;7: Migration Sequence&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-hardest-part-wasnt-the-code-aligning-stakeholders-on-metric-definitions-oi6"&gt;8: Stakeholder Alignment&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first thing we tried was the obvious thing. We took the worst-offending&lt;br&gt;
report queries, looked at their execution plans, and added indexes. We tuned&lt;br&gt;
them one at a time, the way you would tune any slow query against a&lt;br&gt;
production replica. It worked. For about two weeks the reports were fast&lt;br&gt;
again and the load on the replica settled down, and it felt like we had&lt;br&gt;
handled it.&lt;/p&gt;

&lt;p&gt;Then the next report landed, and the same latency and load problems came&lt;br&gt;
back. That was the tell. When a fix buys you two weeks and then the problem&lt;br&gt;
returns wearing slightly different clothes, you are not looking at a&lt;br&gt;
performance problem. As I covered in &lt;a href="//./DRAFT-2026-07-semantic-01-ten-reports.md"&gt;Part 1&lt;/a&gt;,&lt;br&gt;
what actually broke at MyCase was that thirty-plus reports had each quietly&lt;br&gt;
decided for themselves what "billable hours" meant, and no two of them agreed.&lt;br&gt;
Index tuning treats the symptom of a query we would re-derive again next time.&lt;br&gt;
It does nothing about the fact that the same definitions kept getting&lt;br&gt;
re-derived at all. You cannot index your way out of a governance problem.&lt;/p&gt;

&lt;p&gt;So we stopped tuning queries and started asking a harder question: what is&lt;br&gt;
the right level of abstraction to put underneath thirty reports so that&lt;br&gt;
"billable hours" gets defined once. That question had several plausible&lt;br&gt;
answers, and most of this post is about the ones we ruled out, because the&lt;br&gt;
reasons we ruled them out are what pointed us at the semantic layer.&lt;/p&gt;
&lt;h2&gt;
  
  
  The BI tool that solved the wrong three problems
&lt;/h2&gt;

&lt;p&gt;The most tempting off-ramp was a business-intelligence tool. Something in the&lt;br&gt;
Metabase or Looker family, where analysts build reports against a modeled&lt;br&gt;
data source and you never write raw SQL for a report again. On paper it&lt;br&gt;
addresses exactly what we were complaining about.&lt;/p&gt;

&lt;p&gt;We evaluated one and rejected it on three blockers that all applied at the&lt;br&gt;
same time. First, it could not embed natively in the MyCase product UI. These&lt;br&gt;
reports are a product feature that law firms use inside the application, not&lt;br&gt;
an internal dashboard for our own analysts, and a bolted-on iframe to a&lt;br&gt;
separate BI product is not that. Second, it could not enforce firm-scoped,&lt;br&gt;
field-level authorization. MyCase is a multi-tenant legal SaaS, and a trust&lt;br&gt;
accounting figure that firm A can see is a figure firm B must never see, down&lt;br&gt;
to the individual field. General-purpose BI tools are not built to enforce&lt;br&gt;
that kind of per-tenant, per-field rule as a hard invariant. Third, it could&lt;br&gt;
not express our business logic. What counts as billable, how a write-off is&lt;br&gt;
categorized, how trust balances reconcile across three ledgers: that is domain&lt;br&gt;
logic, and encoding it inside a BI tool's modeling layer would have just&lt;br&gt;
moved the thirty-definitions problem into a tool that was worse at owning it&lt;br&gt;
than our own codebase was.&lt;/p&gt;

&lt;p&gt;Any one of those might have been workable on its own. All three at once meant&lt;br&gt;
the BI tool was solving a problem we did not have while failing at the three&lt;br&gt;
we did.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fziko854ug6liws4pp6yp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fziko854ug6liws4pp6yp.png" alt="Rejected alternatives: BI tool, data warehouse, more indexes" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The data warehouse that was a later step, not an alternative
&lt;/h2&gt;

&lt;p&gt;The other reflexive answer was a data warehouse. Move the analytics workload&lt;br&gt;
off the transactional replica entirely, into a system built for it. I want to&lt;br&gt;
be precise here, because a warehouse is a genuinely good idea and we did not&lt;br&gt;
reject it on its merits. We rejected it as an &lt;em&gt;answer to this problem&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A warehouse moves data. It does not decide what the data means. If you take&lt;br&gt;
thirty ad-hoc queries and point them at a warehouse, you have thirty ad-hoc&lt;br&gt;
warehouse queries, and each one still gets to invent its own definition of&lt;br&gt;
billable hours on the way through. The consistency problem survives the move&lt;br&gt;
completely intact, now with a bigger bill attached. The semantic layer is the&lt;br&gt;
prerequisite, not the alternative. Once the definitions are canonical and live&lt;br&gt;
in one place, a warehouse can absolutely sit underneath as a performance and&lt;br&gt;
scale decision. But it has to sit under the definitions, not instead of them.&lt;/p&gt;

&lt;p&gt;That reframing mattered for sequencing too. Treating the warehouse as a future&lt;br&gt;
step meant we did not block the fix that actually addressed trust erosion on a&lt;br&gt;
much larger infrastructure migration.&lt;/p&gt;
&lt;h2&gt;
  
  
  The faster database that would have preserved the problem
&lt;/h2&gt;

&lt;p&gt;The last one is the subtlest, and it is the one I have to talk teams out of&lt;br&gt;
most often. If reports are slow and hammering the replica, why not just make&lt;br&gt;
the reads faster: a beefier database, or a caching proxy in front of the&lt;br&gt;
existing queries.&lt;/p&gt;

&lt;p&gt;Both of those preserve the actual problem exactly. The meaning of the data&lt;br&gt;
would still live in thirty different places. You would have made thirty&lt;br&gt;
inconsistent answers arrive more quickly. A cache in front of divergent&lt;br&gt;
queries caches divergent results. Part 0 documents this reasoning directly,&lt;br&gt;
and it is the load-bearing insight of the whole project: performance tooling&lt;br&gt;
downstream of competing definitions locks in the mess instead of clearing it.&lt;br&gt;
The fix had to move the definition upstream, and no amount of read throughput&lt;br&gt;
does that.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the semantic layer, and why not just materialized views
&lt;/h2&gt;

&lt;p&gt;Here is the distinction that took me the longest to articulate to&lt;br&gt;
stakeholders, because materialized views were part of the eventual design and&lt;br&gt;
it is easy to conflate the two.&lt;/p&gt;

&lt;p&gt;The materialized views are a performance optimization. They pre-compute the&lt;br&gt;
shape of the data so a report is not reverse-engineering it out of OLTP tables&lt;br&gt;
on every request. What they do not do is decide what "billable" means. A&lt;br&gt;
report querying a view directly still has to make that decision at query time,&lt;br&gt;
which means we would be right back to thirty query authors making thirty local&lt;br&gt;
choices, just against a faster source.&lt;/p&gt;

&lt;p&gt;The semantic layer is what moves that decision upstream and locks it down. In&lt;br&gt;
our case that layer was a Ruby module namespace, one class per named metric,&lt;br&gt;
with the canonical definition living in the class and versioned by convention.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Analytics&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Metrics&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BillableHours&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Base&lt;/span&gt;
      &lt;span class="no"&gt;VERSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
      &lt;span class="no"&gt;DESCRIPTION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Billable-status time entries, write-offs excluded"&lt;/span&gt;
      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;view_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;:analytics_billable_hours_v2&lt;/span&gt;
      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;
        &lt;span class="no"&gt;TimeEntry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;joins&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:matter&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;billable: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;written_off: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The class is the single source of truth. Its &lt;code&gt;query&lt;/code&gt; method defines the metric,&lt;br&gt;
its &lt;code&gt;view_name&lt;/code&gt; points at the materialized view that pre-computes it, and its&lt;br&gt;
&lt;code&gt;VERSION&lt;/code&gt; lets a definition change without silently rewriting history under&lt;br&gt;
reports that depended on the old meaning. Reports never write SQL. They call&lt;br&gt;
the metric class. When the business redefines billable hours, we change one&lt;br&gt;
class, bump the version, and every downstream report inherits the new&lt;br&gt;
definition at once. That is the property none of the alternatives could give&lt;br&gt;
us, because none of them had a single place for the definition to live.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F69uwv8iyskiqx4dty2zu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F69uwv8iyskiqx4dty2zu.png" alt="Semantic layer vs raw materialized views: the key distinction" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The authorization insight that picked the API
&lt;/h2&gt;

&lt;p&gt;One decision was left over. Reports could have queried the semantic model by&lt;br&gt;
running SQL against its materialized views. We put a GraphQL API in front&lt;br&gt;
instead, and the reason was authorization.&lt;/p&gt;

&lt;p&gt;Field-level, firm-scoped authorization in a multi-tenant legal SaaS is not a&lt;br&gt;
thing you want thirty report authors each implementing against raw tables.&lt;br&gt;
Get it wrong in one place and a firm sees another firm's trust balance. If&lt;br&gt;
every report hand-rolls its own permission checks, you have thirty chances to&lt;br&gt;
leak data and no single place to audit. The API layer was the one place to&lt;br&gt;
enforce those rules once, for every consumer, as an invariant rather than a&lt;br&gt;
convention. That is the same logic that ruled out the BI tool, arriving from&lt;br&gt;
the other direction: authorization was non-trivial enough that it wanted to&lt;br&gt;
own an interface, and that interface was the API.&lt;/p&gt;

&lt;p&gt;Each layer in the eventual stack earned its place by solving a specific&lt;br&gt;
constraint the alternatives could not. The next post gets concrete about the&lt;br&gt;
metrics model itself: how a named, versioned metric actually behaves, and what&lt;br&gt;
changed the day reports stopped writing SQL.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>rails</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Ten Reports, Ten Answers: Why Ad-Hoc SQL Doesn't Scale</title>
      <dc:creator>Son Do</dc:creator>
      <pubDate>Thu, 23 Jul 2026 09:50:03 +0000</pubDate>
      <link>https://dev.to/son_do_3543eb233b59d424bc/ten-reports-ten-answers-why-ad-hoc-sql-doesnt-scale-4771</link>
      <guid>https://dev.to/son_do_3543eb233b59d424bc/ten-reports-ten-answers-why-ad-hoc-sql-doesnt-scale-4771</guid>
      <description>&lt;p&gt;&lt;em&gt;Series -- From Ad-Hoc SQL to a Semantic Layer:&lt;/em&gt; &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/replacing-30-ad-hoc-sql-reports-with-a-semantic-layer-28fo"&gt;0: Overview&lt;/a&gt; · &lt;strong&gt;1: Ten Answers&lt;/strong&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/why-we-didnt-just-add-more-indexes-choosing-a-semantic-layer-2da3"&gt;2: Why Not Indexes&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/one-definition-30-reports-how-we-designed-the-metrics-model-3g3j"&gt;3: Metrics Model&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/never-miss-a-refresh-building-the-materialized-view-pipeline-4ifi"&gt;4: View Pipeline&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/schema-without-churn-graphql-design-for-an-evolving-metrics-model-a41"&gt;5: Schema Design&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/authorization-at-the-api-layer-why-field-level-checks-live-in-graphql-4c3m"&gt;6: Authorization&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/which-report-goes-first-the-migration-sequencing-logic-2m3k"&gt;7: Migration Sequence&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-hardest-part-wasnt-the-code-aligning-stakeholders-on-metric-definitions-oi6"&gt;8: Stakeholder Alignment&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ad-hoc SQL is the fastest way to ship a report and the slowest way to ship the tenth one. The first few feel like free wins. Someone needs a number, you write a query against the read replica, you hand back a table, everyone moves on. Nobody is thinking about the tenth report while writing the first. But by the time you have thirty, each one has quietly decided for itself what the business words mean, and no two of them decided quite the same way.&lt;/p&gt;

&lt;p&gt;I told the front of this story in the &lt;a href="//./2026-07-mycase-semantic-layer.md"&gt;overview post&lt;/a&gt;: a domain owner pulled me into a call in early 2024 with two reports open side by side, both claiming to show billable hours for the same firm over the same period, and the numbers didn't match. The question on the call was which one was right. I couldn't answer it, and neither could anyone else in the room. This post is about why that call was inevitable, and why the thing that looked like a performance problem turned out to be something worse underneath.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two queries that measured different things
&lt;/h2&gt;

&lt;p&gt;When I finally pulled up the SQL behind those two billable-hours reports, the divergence was almost boring. One query joined through the billing and invoice tables. It counted hours that had actually been invoiced. The other query hit &lt;code&gt;time_entries&lt;/code&gt; directly and counted every entry with a billable status, regardless of whether it had ever made it onto an invoice.&lt;/p&gt;

&lt;p&gt;Neither query was wrong. That is the part that took a moment to sit with. The first report was answering "how many billable hours have we invoiced," which is exactly the number you want when you are looking at collections. The second was answering "how many billable hours have our attorneys logged," which is exactly the number you want when you are looking at productivity or work in progress. Two legitimate questions, two correct answers, two reports that both put the label "billable hours" at the top of a column and left the reader to assume they meant the same thing.&lt;/p&gt;

&lt;p&gt;The divergence wasn't a bug anyone introduced. It was the natural result of two engineers, at two different times, each making a locally reasonable choice about what "billable" meant and encoding it inline. There was no shared definition to conform to, so each query became its own definition. Multiply that across thirty reports and you don't have thirty queries, you have thirty competing opinions about the vocabulary of the business.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9v0uazdrt2tmnhy6jfg3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9v0uazdrt2tmnhy6jfg3.png" alt="Two SQL query paths diverging: invoice-join vs direct time_entries" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  It wasn't just billing
&lt;/h2&gt;

&lt;p&gt;I want to be honest that if this had been a billing-only quirk, we might have papered over it with a footnote and moved on. It wasn't. Once I started auditing, the same pattern showed up everywhere the reports touched a business concept with any nuance.&lt;/p&gt;

&lt;p&gt;Active case count was its own small argument. Which case statuses count as "active" is a genuine business question, and different reports had drawn the line in different places, so a firm could see one active-case number on a dashboard and a different one on a summary export. Trust balances, which carry real regulatory weight in legal accounting, could differ depending on which ledger a report queried. Financial summaries produced discrepancies that users noticed and asked about. None of these were exotic edge cases. They were the core numbers firms use to run their practices, and they disagreed with each other in ways that were visible to the people who depended on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways this actually hurt
&lt;/h2&gt;

&lt;p&gt;The damage came in three forms, and the order matters because the last one is the one that scared me.&lt;/p&gt;

&lt;p&gt;The first was wrong decisions. Firms make real calls on billing and collections data. When the number a firm is looking at depends on which report they happened to open, some of those calls are being made on the wrong figure without anyone knowing it.&lt;/p&gt;

&lt;p&gt;The second was operational drag. Because you couldn't trust any single report, the workaround was to cross-check. Domain owners started manually reconciling conflicting reports before they'd share a number with a stakeholder, which is exactly the kind of invisible tax that never shows up in a metrics dashboard but eats hours every week.&lt;/p&gt;

&lt;p&gt;The third, and the worst, was the credibility loss. Once a domain owner catches two authoritative reports disagreeing, they stop trusting the reports. They stop trusting all of them, not just the two that disagreed. People began asking for manual verification of numbers the system was supposed to be the source of truth for. That is a slow erosion, not a crash, and it is much harder to recover from than an outage. An outage ends. A reputation for unreliable numbers follows every report you ship afterward.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The index-tuning detour
&lt;/h2&gt;

&lt;p&gt;Here is the part I find most instructive in hindsight, because we got the diagnosis wrong first.&lt;/p&gt;

&lt;p&gt;The presenting symptom was latency. The reports were slow, load on the read replica was climbing with every new report, and the obvious move was to make the slow queries faster. So that is what we did. We took the worst-offending queries, added indexes, and tuned them one at a time. It worked. Latency came down, the replica breathed easier, and for about two weeks it felt like we had solved the problem.&lt;/p&gt;

&lt;p&gt;Then the next report landed, written from scratch against the production schema like all the others, and the latency and load problems came right back. That was the tell. We were treating the symptoms of one query while the actual disease, the fact that every report re-derived its own definitions and its own access pattern from nothing, was completely untouched. You cannot index your way out of thirty definitions of the same concept. Indexing makes a wrong-but-slow answer into a wrong-but-fast one.&lt;/p&gt;

&lt;p&gt;That detour taught me that performance and consistency are different failure modes that happen to look identical from the outside. Both present as slow, unreliable reports. But performance is a downstream problem you fix with faster queries and better caching, and consistency is an upstream problem you fix by moving the definition to a single place before anyone queries it. Optimizing performance downstream of thirty competing definitions doesn't just fail to fix the consistency problem, it locks the mess in by making it cheaper to keep adding to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the audit really found
&lt;/h2&gt;

&lt;p&gt;We went into this project to fix performance. Latency was the ticket, the replica load was the pressure, and the two-week index reprieve was the false summit that told us we hadn't found the real thing yet.&lt;/p&gt;

&lt;p&gt;What we actually found was definition drift. The reports weren't just slow, they were quietly answering thirty different questions while presenting the answers under the same names. That reframe is the whole reason the rest of this series exists. If the problem had really been latency, a faster database or a caching proxy would have been the end of it. But the meaning of the data lived in thirty places instead of one, and no amount of tuning consolidates meaning.&lt;/p&gt;

&lt;p&gt;In the next post I'll get into why we didn't just keep optimizing, and why "add more indexes" and even "add a data warehouse" were the wrong shape of fix for a problem that was really about where a definition is allowed to live.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>rails</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Why We Chose Permanent Sync Over Migration</title>
      <dc:creator>Son Do</dc:creator>
      <pubDate>Thu, 23 Jul 2026 05:29:44 +0000</pubDate>
      <link>https://dev.to/son_do_3543eb233b59d424bc/why-we-chose-permanent-sync-over-migration-lgj</link>
      <guid>https://dev.to/son_do_3543eb233b59d424bc/why-we-chose-permanent-sync-over-migration-lgj</guid>
      <description>&lt;p&gt;&lt;em&gt;MyCase Immigration / Docketwise Integration:&lt;/em&gt; 0: Why We Synced · 1: Before/After · 2: OAuth2 · &lt;strong&gt;3: Sync vs Migration&lt;/strong&gt; · 4: Sync Boundary · 5: Family Graph · 6: Write Patterns · 7: Webhooks · 8: Billing&lt;/p&gt;

&lt;p&gt;When you are integrating two systems that have overlapping data, migration is the obvious first move. Pick a canonical schema, export from the source, import to the destination, point everything at the new store, and decommission the old one. One system. No sync to maintain. Clean.&lt;/p&gt;

&lt;p&gt;We rejected migration for this integration, and the decision was not close.&lt;/p&gt;

&lt;p&gt;This post covers why migration was the wrong choice for Docketwise immigration data specifically, and what permanent sync requires by comparison.&lt;/p&gt;




&lt;h2&gt;
  
  
  What migration would have required
&lt;/h2&gt;

&lt;p&gt;A migration from Docketwise to MyCase would have meant: export all client and case data from Docketwise, transform it to the MyCase schema, import it into MyCase, and make MyCase the sole write system for those records going forward.&lt;/p&gt;

&lt;p&gt;This runs into three problems immediately.&lt;/p&gt;

&lt;p&gt;The first is that Docketwise's schema has no direct equivalent in MyCase for immigration-specific fields. Visa categories, USCIS form associations, matter types, and typed party role graphs are Docketwise-native concepts. MyCase's schema does not have columns for these. The migration would have required either a MyCase schema extension large enough to model the full immigration domain natively, or a decision to discard immigration-specific data on import.&lt;/p&gt;

&lt;p&gt;Neither option was acceptable. A schema extension that imports the full Docketwise immigration model would have turned MyCase into an immigration case management system rather than a practice management system with immigration capabilities. Discarding immigration-specific data on import would have broken active case workflows.&lt;/p&gt;

&lt;p&gt;The second problem is Docketwise continuity. Docketwise's standalone product would continue operating. Attorneys who use Docketwise without MyCase would continue creating and updating records in Docketwise. A migration that moved data to MyCase on day 1 would have Docketwise and MyCase diverging immediately. The migrated data in MyCase would be stale the moment any Docketwise user made a change.&lt;/p&gt;

&lt;p&gt;The third problem is the definition of "when is migration done." A migration of active case data has no natural completion point. An immigration case is not a closed record; it is an active workflow. A petitioner's address changes. A case receives a USCIS response. A new derivative beneficiary is added. All of those are Docketwise writes that would need to replicate to MyCase after a migration. Which is to say: a migration of active case data is a sync that runs once and then stops, leaving both systems divergent. It does not actually solve the synchronization problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why immigration data is operational, not archival
&lt;/h2&gt;

&lt;p&gt;The migration argument is most compelling for archival data. If Docketwise were only storing completed historical cases, a migration could work. Export the archive, import it to MyCase, stop writing to Docketwise for historical records.&lt;/p&gt;

&lt;p&gt;Immigration data is not archival. An active immigration case has a USCIS timeline. Deadlines are live. Form statuses change when USCIS acts. Party relationships are updated when a dependent is added or removed. An attorney querying a case expects the current state, not the state as of the most recent MyCase sync.&lt;/p&gt;

&lt;p&gt;The consequence of this is that whoever holds the immigration data must be the system that processes immigration writes. Docketwise handles USCIS form preparation, processes immigration-specific validations, and has the domain logic for immigration case management. Migrating the data to MyCase without migrating that domain logic would have given MyCase a copy of immigration data with no way to correctly process changes to it.&lt;/p&gt;

&lt;p&gt;Docketwise was not going to be decommissioned as a write system. That determined the rest of the decision.&lt;/p&gt;




&lt;h2&gt;
  
  
  The staleness argument
&lt;/h2&gt;

&lt;p&gt;The most direct case against migration was this: a migration on day 1 is stale by day 2.&lt;/p&gt;

&lt;p&gt;On day 1, we import all Docketwise data to MyCase. Every Client and Case record in MyCase reflects the state in Docketwise as of the import timestamp. MyCase users see accurate data.&lt;/p&gt;

&lt;p&gt;On day 2, an attorney using Docketwise standalone updates a beneficiary's address. Docketwise's record is correct. MyCase's record, the one from the migration, is now wrong. The attorney using MyCase to check that client's contact information has stale data.&lt;/p&gt;

&lt;p&gt;A migration that does not include a replication mechanism to keep the two systems in sync after day 1 has exchanged one synchronization problem (manual updates) for a different one (divergent records). The divergent records problem is worse because it is invisible. The attorney using MyCase does not know the data is stale. The attorney using Docketwise does not know that MyCase has old data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft79fgqdz4p6bnwubvftl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft79fgqdz4p6bnwubvftl.png" alt="Migration staleness: Docketwise and MyCase diverge immediately after migration runs as Docketwise standalone users continue making changes; permanent sync keeps both systems current indefinitely" width="799" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Permanent sync makes the synchronization mechanism explicit and operational from day 1. There is no point at which both systems are assumed to be identical; they are kept current by the sync layer as long as both systems are live.&lt;/p&gt;




&lt;h2&gt;
  
  
  What permanent sync requires
&lt;/h2&gt;

&lt;p&gt;If both systems are live write systems for their respective domains, the integration requires a synchronization layer that keeps shared records current in both.&lt;/p&gt;

&lt;p&gt;The sync layer has three requirements.&lt;/p&gt;

&lt;p&gt;The first is a defined boundary. Not all data syncs. The sync covers the records that both systems need for their core workflows. Everything else stays native to its owner. Part 4 covers how we drew that boundary.&lt;/p&gt;

&lt;p&gt;The second is source-of-truth partitioning. Even within the records that sync, ownership of specific fields must be clear. When MyCase and Docketwise both have a Client record, and a field on that record is updated in one system, the sync layer needs to know which system's value wins. Partitioning by field domain prevents write conflicts without requiring a distributed locking mechanism.&lt;/p&gt;

&lt;p&gt;The third is handling for continuous updates. The sync layer is not a one-time import. Both systems generate events continually. Webhook delivery, ordering failures, and duplicate processing are ongoing concerns. Part 7 covers specifically what went wrong with webhooks in staging and what the fixes were.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we chose to sync
&lt;/h2&gt;

&lt;p&gt;The sync covers Client and Case records only. These are the entities that both systems need for their core workflows: MyCase for billing and practice management, Docketwise for immigration case work.&lt;/p&gt;

&lt;p&gt;The scope of what syncs within Client and Case is narrow: identity fields that both systems need to refer to the same person and the same matter. Part 4 covers the specific boundary in detail.&lt;/p&gt;

&lt;p&gt;Everything else stays native. Docketwise owns immigration-specific fields. MyCase owns practice management fields. The sync layer does not attempt to reconcile fields that only one system cares about.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvie65tpxynl2chdcsfea.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvie65tpxynl2chdcsfea.png" alt="Migration vs permanent sync: migration produces a point-in-time copy that goes stale the moment a Docketwise user edits a record; permanent sync keeps both systems current indefinitely as long as customers use either one" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: Part 4. Drawing the Sync Boundary: Client and Case Only. Once we chose sync over migration, the next question was what to sync. Not everything that overlaps belongs in the sync boundary. This post covers the test we used to decide what crossed the boundary and what the decision excluded.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rails</category>
      <category>python</category>
      <category>backend</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Generalizing Addon Billing Without Touching Stripe or Zuora</title>
      <dc:creator>Son Do</dc:creator>
      <pubDate>Thu, 23 Jul 2026 05:27:52 +0000</pubDate>
      <link>https://dev.to/son_do_3543eb233b59d424bc/generalizing-addon-billing-without-touching-stripe-or-zuora-1f2b</link>
      <guid>https://dev.to/son_do_3543eb233b59d424bc/generalizing-addon-billing-without-touching-stripe-or-zuora-1f2b</guid>
      <description>&lt;p&gt;&lt;em&gt;MyCase Immigration / Docketwise Integration:&lt;/em&gt; 0: Why We Synced · 1: Before/After · 2: OAuth2 · 3: Sync vs Migration · 4: Sync Boundary · 5: Family Graph · 6: Write Patterns · 7: Webhooks · &lt;strong&gt;8: Billing&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When we started building the Immigration addon billing flow, the existing addon billing service was not a service. It was a set of methods tied to a single addon ID.&lt;/p&gt;

&lt;p&gt;The billing lifecycle logic (trial, conversion, proration, cancellation) was correct and generally applicable. The binding that made it work only for the Accounting addon was not. This post covers what was hardcoded, what the generalization looked like, and one design decision that was not obvious until we looked at the customer reversion path: Stripe soft-deactivation.&lt;/p&gt;




&lt;h2&gt;
  
  
  What was hardcoded
&lt;/h2&gt;

&lt;p&gt;The original billing code for addon lifecycle events referenced &lt;code&gt;ACCOUNTING_ADDON_ID&lt;/code&gt; as a constant in two places: the service that orchestrated lifecycle events and the background jobs that handled billing transitions.&lt;/p&gt;

&lt;p&gt;The lifecycle logic itself handled: trial start, trial expiry, conversion from trial to paid, mid-cycle proration when a firm upgraded mid-month, downgrade handling, and cancellation. None of this logic was specific to Accounting. The same lifecycle applied to any addon that had a trial period and a monthly billing cycle.&lt;/p&gt;

&lt;p&gt;What was specific to Accounting: the addon ID used to look up the subscription configuration, the billing interval, and the routing to Stripe. Accounting customers are billed via Stripe. That assumption was buried in the service without a routing decision; the service assumed Stripe without checking which provider was configured for the customer.&lt;/p&gt;

&lt;p&gt;The Immigration addon introduced a new variable: some Immigration customers (those converting from Docketwise standalone) would be billed via Zuora, not Stripe. The hardcoded Stripe assumption in the Accounting billing service could not handle them without modification.&lt;/p&gt;




&lt;h2&gt;
  
  
  The generalization
&lt;/h2&gt;

&lt;p&gt;The generalization had three parts.&lt;/p&gt;

&lt;p&gt;First: the billing service became a class that accepts an addon record as its constructor argument. Instead of &lt;code&gt;ACCOUNTING_ADDON_ID&lt;/code&gt; embedded in the code, the service looks up addon configuration from what it is passed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AddonBillingService&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;addon&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="n"&gt;firm&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
    &lt;span class="vi"&gt;@addon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;addon&lt;/span&gt;
    &lt;span class="vi"&gt;@firm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;firm&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;begin_trial&lt;/span&gt;
    &lt;span class="n"&gt;provider_for_firm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_trial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;firm: &lt;/span&gt;&lt;span class="vi"&gt;@firm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;addon: &lt;/span&gt;&lt;span class="vi"&gt;@addon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;trial_days: &lt;/span&gt;&lt;span class="vi"&gt;@addon&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trial_duration_days&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;convert_to_paid&lt;/span&gt;
    &lt;span class="n"&gt;provider_for_firm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge_subscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;firm: &lt;/span&gt;&lt;span class="vi"&gt;@firm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;addon: &lt;/span&gt;&lt;span class="vi"&gt;@addon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;amount: &lt;/span&gt;&lt;span class="vi"&gt;@addon&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monthly_price&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;provider_for_firm&lt;/span&gt;
    &lt;span class="vi"&gt;@firm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;billing_provider_zuora?&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="no"&gt;ZuoraProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;StripeProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second: an &lt;code&gt;addons&lt;/code&gt; config table with ActiveRecord records. Each row has the same schema: &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;trial_duration_days&lt;/code&gt;, &lt;code&gt;monthly_price&lt;/code&gt;, &lt;code&gt;billing_interval&lt;/code&gt;, &lt;code&gt;active&lt;/code&gt;. The Accounting addon row was created from the existing constants. The Immigration addon row was added with its own configuration. No code change is required to add a new addon; it requires a new row in the table and a new addon activation flow.&lt;/p&gt;

&lt;p&gt;Third: provider routing inside the service. The &lt;code&gt;provider_for_firm&lt;/code&gt; method checks which provider is configured for the firm and delegates to &lt;code&gt;ZuoraProvider&lt;/code&gt; or &lt;code&gt;StripeProvider&lt;/code&gt; accordingly. Neither provider integration was modified. The service is the routing layer; the providers are the execution layer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6j84htzkb2sclp5gkjyq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6j84htzkb2sclp5gkjyq.png" alt="AddonBillingService architecture: parameterized service accepts addon and firm as constructor arguments, checks firm billing provider, and delegates to ZuoraProvider or StripeProvider; both provider integrations are unchanged beneath the routing layer" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The full-switch Stripe soft-deactivation
&lt;/h2&gt;

&lt;p&gt;A specific customer flow required extra handling: a Docketwise standalone customer converting to MyCase Immigration.&lt;/p&gt;

&lt;p&gt;Before the integration, these customers had a Docketwise subscription managed through Stripe. When they converted to the MyCase Immigration addon, the active billing relationship moved to Zuora.&lt;/p&gt;

&lt;p&gt;The naive approach: cancel the Stripe subscription, create a Zuora subscription, done.&lt;/p&gt;

&lt;p&gt;The problem with cancellation: Stripe subscription cancellation permanently ends the billing relationship. If the customer later decided to cancel the MyCase Immigration addon and revert to standalone Docketwise, they would need to re-enter payment details and go through Stripe subscription creation again. Any billing history attached to the original Stripe subscription would be separated from the new one. The reversion path, which we expected some early customers to take, required full re-onboarding through Stripe.&lt;/p&gt;

&lt;p&gt;The soft-deactivation approach: instead of canceling the Stripe subscription, we deactivate it. The subscription moves to a &lt;code&gt;cancel_at_period_end&lt;/code&gt; state in Stripe. The subscription record is preserved. The payment method is preserved. The billing history stays attached to the original subscription ID.&lt;/p&gt;

&lt;p&gt;When the firm's billing period ends, Stripe does not charge. Zuora becomes the active billing relationship for the Immigration addon.&lt;/p&gt;

&lt;p&gt;If the customer later cancels the Immigration addon and reverts to standalone Docketwise:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Cancel the Zuora subscription.&lt;/li&gt;
&lt;li&gt;Reactivate the Stripe subscription by removing the &lt;code&gt;cancel_at_period_end&lt;/code&gt; flag and setting it back to active.&lt;/li&gt;
&lt;li&gt;Stripe resumes billing at the next cycle.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The customer sees no re-onboarding flow. Their billing history in Stripe is continuous. Their payment method does not need to be re-entered.&lt;/p&gt;

&lt;p&gt;Without soft-deactivation, a customer who converted and then reverted would have needed to start a new Stripe subscription from scratch. That friction was a meaningful deterrent to letting customers try the integrated product. Preserving the reversion path via soft-deactivation made conversion less risky from the customer's perspective, which mattered during the early access period when we expected higher churn than at GA.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fin8robvwl7m2cel7w0uz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fin8robvwl7m2cel7w0uz.png" alt="Stripe soft-deactivation state diagram: on conversion, Stripe subscription moves to cancel_at_period_end (preserved, not billed) and Zuora becomes active; on reversion, Zuora subscription is canceled and Stripe subscription is reactivated with no re-onboarding required" width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The billing transition in code
&lt;/h2&gt;

&lt;p&gt;The convert-to-immigration flow in the Rails backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConvertToImmigrationAddon&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firm&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
    &lt;span class="vi"&gt;@firm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;firm&lt;/span&gt;
    &lt;span class="vi"&gt;@immigration_addon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Addon&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="s1"&gt;'immigration'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;
    &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;soft_deactivate_stripe_subscription&lt;/span&gt;
      &lt;span class="vi"&gt;@firm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;billing_provider: :zuora&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="no"&gt;AddonBillingService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;addon: &lt;/span&gt;&lt;span class="vi"&gt;@immigration_addon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;firm: &lt;/span&gt;&lt;span class="vi"&gt;@firm&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;begin_trial&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;soft_deactivate_stripe_subscription&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="vi"&gt;@firm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stripe_subscription_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt;

    &lt;span class="no"&gt;StripeProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deactivate_at_period_end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;subscription_id: &lt;/span&gt;&lt;span class="vi"&gt;@firm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stripe_subscription_id&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The transaction wraps the Stripe deactivation, the provider switch, and the Zuora trial creation. If any step fails, the firm remains on Stripe with no changes. Stripe deactivation is called first; if it raises, the firm's provider is not changed and no Zuora subscription is started.&lt;/p&gt;

&lt;p&gt;The reversion path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RevertToStandaloneDocketwise&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firm&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
    &lt;span class="vi"&gt;@firm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;firm&lt;/span&gt;
    &lt;span class="vi"&gt;@immigration_addon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Addon&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="s1"&gt;'immigration'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;
    &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="no"&gt;AddonBillingService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;addon: &lt;/span&gt;&lt;span class="vi"&gt;@immigration_addon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;firm: &lt;/span&gt;&lt;span class="vi"&gt;@firm&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;
      &lt;span class="vi"&gt;@firm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;billing_provider: :stripe&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;reactivate_stripe_subscription&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reactivate_stripe_subscription&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="vi"&gt;@firm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stripe_subscription_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt;

    &lt;span class="no"&gt;StripeProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reactivate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;subscription_id: &lt;/span&gt;&lt;span class="vi"&gt;@firm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stripe_subscription_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reactivation calls Stripe's subscription update endpoint to remove the &lt;code&gt;cancel_at_period_end&lt;/code&gt; flag. The subscription resumes normal billing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Regression protection
&lt;/h2&gt;

&lt;p&gt;After the generalization, the Accounting addon continued working. The regression test was: run the Accounting addon's existing billing test suite unchanged and confirm all tests pass.&lt;/p&gt;

&lt;p&gt;The test suite covered trial start, conversion, proration, downgrade, and cancellation for the Accounting addon. After the generalization, &lt;code&gt;AddonBillingService&lt;/code&gt; received the Accounting addon record as its argument instead of using &lt;code&gt;ACCOUNTING_ADDON_ID&lt;/code&gt; directly. The behavior was identical. All tests passed without modification.&lt;/p&gt;

&lt;p&gt;This was the validation that the generalization was correctly scoped. The lifecycle logic changed in form (parameterized instead of hardcoded) but not in behavior for the existing case. Adding a new addon did not change what the existing addon did.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the billing refactor did not touch
&lt;/h2&gt;

&lt;p&gt;The Stripe integration code was not modified. The Zuora integration code was not modified. Neither provider's API client, authentication, or payload construction changed.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;AddonBillingService&lt;/code&gt; sits above both provider integrations as an orchestration layer. It makes decisions about lifecycle state (is this firm in a trial? has the trial expired? is this a proration?) and delegates the actual payment operations to whichever provider is appropriate for the firm.&lt;/p&gt;

&lt;p&gt;Adding a third addon in the future requires: a new row in the &lt;code&gt;addons&lt;/code&gt; config table, a new activation flow, and confirmation that &lt;code&gt;AddonBillingService&lt;/code&gt; handles the new addon's configuration correctly. No provider integration changes. No new service class. No new background job types.&lt;/p&gt;

&lt;p&gt;The billing generalization satisfied the design constraint without a metric to attach to it. No customer was disrupted during the transition. Both providers kept running. Standalone Docketwise customers converting to MyCase Immigration saw a continuous billing experience. That is what the constraint required.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is the final post in the series. If you came here for the webhook ordering fix, that is Part 7. If you came for the sync vs. migration decision, that is Part 3. If you came for the family graph schema problem, that is Part 5. Each post stands alone; this post is also where the billing side of the series closes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>backend</category>
      <category>billing</category>
    </item>
  </channel>
</rss>
