<?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>For Review: Matching Plaid Bank Feeds Without Duplicate Ledger Entries</title>
      <dc:creator>Son Do</dc:creator>
      <pubDate>Sun, 26 Jul 2026 05:04:28 +0000</pubDate>
      <link>https://dev.to/son_do_3543eb233b59d424bc/for-review-matching-plaid-bank-feeds-without-duplicate-ledger-entries-24ig</link>
      <guid>https://dev.to/son_do_3543eb233b59d424bc/for-review-matching-plaid-bank-feeds-without-duplicate-ledger-entries-24ig</guid>
      <description>&lt;p&gt;&lt;em&gt;Series -- When the Aggregate Looks Right:&lt;/em&gt; &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/building-mycase-accounting-trust-books-that-can-look-right-while-being-wrong-2a2c"&gt;0: Overview&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/six-categories-some-locked-designing-a-legal-chart-of-accounts-417c"&gt;1: Chart of Accounts&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/bank-firm-client-why-two-way-reconciliation-wasnt-enough-eg3"&gt;2: Three-Way Reconciliation&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-aggregate-can-lie-flagging-trust-account-errors-without-drowning-in-false-positives-2p3a"&gt;3: The Aggregate Can Lie&lt;/a&gt; · &lt;strong&gt;4: Bank Sync&lt;/strong&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/stop-re-keying-invoices-posting-billing-and-lawpay-events-to-the-gl-29dg"&gt;5: GL Integration&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A bank feed line arrives from Plaid. Same amount, same day, same account as&lt;br&gt;
a payment the firm already recorded in MyCase. If Accounting posts the feed&lt;br&gt;
line as new activity, the ledger now has two entries for one real-world&lt;br&gt;
movement of money.&lt;/p&gt;

&lt;p&gt;That collision is the opening problem for bank sync in a practice-management&lt;br&gt;
product. Firms are not starting from an empty book. Trust deposits,&lt;br&gt;
operating payments, and invoice-linked transfers often exist in-system&lt;br&gt;
before the bank feed catches up. Naive import is how you invent duplicates.&lt;br&gt;
This post is about the For Review queue we built so feed lines could be&lt;br&gt;
matched, categorized, split, or excluded -- and about the trust-side rule&lt;br&gt;
that refuses to categorize at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  For Review as the control plane
&lt;/h2&gt;

&lt;p&gt;Unmatched bank transactions land in For Review. From there the firm can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Categorize&lt;/strong&gt; -- map the line to a GL account, including via
firm-authored rules&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match&lt;/strong&gt; -- link the line to an existing MyCase record&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split&lt;/strong&gt; -- allocate across multiple accounts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclude&lt;/strong&gt; -- leave it out of the books&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the action is done, the transaction moves to Reviewed. That queue is&lt;br&gt;
the control plane for both Plaid feeds and the CSV import path for banks&lt;br&gt;
Plaid does not cover. Same pipeline, different door: upload, preview,&lt;br&gt;
column mapping, confirm, then For Review. The CSV path is a cameo here, not&lt;br&gt;
a second product story. What mattered was not inventing a parallel matcher&lt;br&gt;
for file imports.&lt;/p&gt;

&lt;p&gt;On the operating side, user-defined categorization rules are firm-authored&lt;br&gt;
mappings from feed attributes to GL accounts, applied when someone&lt;br&gt;
categorizes (or when a rule can clear the obvious cases). They are&lt;br&gt;
supporting machinery under Categorize, not the spine of the design.&lt;/p&gt;

&lt;h2&gt;
  
  
  The spine: trust skips categorization
&lt;/h2&gt;

&lt;p&gt;The load-bearing decision is the trust exception.&lt;/p&gt;

&lt;p&gt;The rejected alternative was symmetry: treat trust feed lines like&lt;br&gt;
operating feed lines. Categorize them into GL accounts from the bank&lt;br&gt;
description. It looks consistent in a UI mock. It is wrong under the model&lt;br&gt;
in &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/bank-firm-client-why-two-way-reconciliation-wasnt-enough-eg3"&gt;Parts 2&lt;/a&gt; and&lt;br&gt;
&lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-aggregate-can-lie-flagging-trust-account-errors-without-drowning-in-false-positives-2p3a"&gt;3&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Trust-side activity should already exist from firm-recorded deposits,&lt;br&gt;
transfers, and billing-linked posts. The bank is confirming a movement the&lt;br&gt;
firm should already know about. Categorizing trust lines from the feed&lt;br&gt;
creates a second source of truth -- bank narrative inventing trust ledger&lt;br&gt;
reality -- and fights three-way reconciliation and invoice-linked&lt;br&gt;
safeguards.&lt;/p&gt;

&lt;p&gt;So trust accounts skip categorization. Operating accounts get Categorize,&lt;br&gt;
Match, Split, and Exclude. Trust gets Match or Exclude. If there is nothing&lt;br&gt;
to match, that is a signal to investigate, not a prompt to invent a GL&lt;br&gt;
category from a Plaid memo field.&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%2Fxzms1kk2md1nfj5hzs0o.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%2Fxzms1kk2md1nfj5hzs0o.png" alt="For Review: trust path blocks Categorize; operating path allows Categorize, Match, Split, Exclude" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Match without double-posting
&lt;/h2&gt;

&lt;p&gt;Match is where the opening collision gets resolved. Linking a feed line to&lt;br&gt;
an existing MyCase record is only allowed when the duplicate-prevention&lt;br&gt;
checks agree: amount, payment method, and account. If they disagree, the&lt;br&gt;
link does not go through. That contract blocks attaching a feed line to the&lt;br&gt;
wrong record and blocks the "close enough" match that double-posts under a&lt;br&gt;
different label.&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%2F2ontaavp6yjspq8jkpx0.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%2F2ontaavp6yjspq8jkpx0.png" alt="Match contract: amount, payment method, and account must all agree" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I am not going to invent a near-miss war story for this post. The checks&lt;br&gt;
are the story: three fields, all must agree, or you do not attach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for reconciliation
&lt;/h2&gt;

&lt;p&gt;Reviewed feed lines are what make monthly&lt;br&gt;
&lt;a href="https://dev.to/son_do_3543eb233b59d424bc/bank-firm-client-why-two-way-reconciliation-wasnt-enough-eg3"&gt;three-way reconciliation&lt;/a&gt;&lt;br&gt;
runnable without someone typing the bank statement by hand. The matching&lt;br&gt;
pipeline auto-matched about 75% of Plaid bank-feed transactions to trust&lt;br&gt;
and operating ledger entries for adopting firms. The remaining queue is the&lt;br&gt;
work -- and because trust cannot be casually categorized from the feed, the&lt;br&gt;
work stays pointed at real exceptions instead of invented books.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/son_do_3543eb233b59d424bc/stop-re-keying-invoices-posting-billing-and-lawpay-events-to-the-gl-29dg"&gt;Part 5&lt;/a&gt; is the other half&lt;br&gt;
of "the firm already recorded this": posting billing and LawPay events into&lt;br&gt;
the GL at event time so the records Match is looking for exist in the first&lt;br&gt;
place.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Series -- When the Aggregate Looks Right:&lt;/em&gt; &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/building-mycase-accounting-trust-books-that-can-look-right-while-being-wrong-2a2c"&gt;0: Overview&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/six-categories-some-locked-designing-a-legal-chart-of-accounts-417c"&gt;1: Chart of Accounts&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/bank-firm-client-why-two-way-reconciliation-wasnt-enough-eg3"&gt;2: Three-Way Reconciliation&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-aggregate-can-lie-flagging-trust-account-errors-without-drowning-in-false-positives-2p3a"&gt;3: The Aggregate Can Lie&lt;/a&gt; · &lt;strong&gt;4: Bank Sync&lt;/strong&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/stop-re-keying-invoices-posting-billing-and-lawpay-events-to-the-gl-29dg"&gt;5: GL Integration&lt;/a&gt;&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>compliance</category>
      <category>backend</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Stop Re-Keying Invoices: Posting Billing and LawPay Events to the GL</title>
      <dc:creator>Son Do</dc:creator>
      <pubDate>Sun, 26 Jul 2026 05:04:16 +0000</pubDate>
      <link>https://dev.to/son_do_3543eb233b59d424bc/stop-re-keying-invoices-posting-billing-and-lawpay-events-to-the-gl-29dg</link>
      <guid>https://dev.to/son_do_3543eb233b59d424bc/stop-re-keying-invoices-posting-billing-and-lawpay-events-to-the-gl-29dg</guid>
      <description>&lt;p&gt;&lt;em&gt;Series -- When the Aggregate Looks Right:&lt;/em&gt; &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/building-mycase-accounting-trust-books-that-can-look-right-while-being-wrong-2a2c"&gt;0: Overview&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/six-categories-some-locked-designing-a-legal-chart-of-accounts-417c"&gt;1: Chart of Accounts&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/bank-firm-client-why-two-way-reconciliation-wasnt-enough-eg3"&gt;2: Three-Way Reconciliation&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-aggregate-can-lie-flagging-trust-account-errors-without-drowning-in-false-positives-2p3a"&gt;3: The Aggregate Can Lie&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/for-review-matching-plaid-bank-feeds-without-duplicate-ledger-entries-24ig"&gt;4: Bank Sync&lt;/a&gt; · &lt;strong&gt;5: GL Integration&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The invoice already exists in MyCase. The LawPay settlement already hit the&lt;br&gt;
payment ledger. Somewhere else -- a spreadsheet, a separate accounting tool,&lt;br&gt;
a second set of books -- someone types the same numbers again.&lt;/p&gt;

&lt;p&gt;That re-keying is not a one-off fat-finger story. It is the default&lt;br&gt;
integration pattern when practice management and firm accounting are two&lt;br&gt;
systems of truth. The concrete miss inside the pattern is familiar: a&lt;br&gt;
payment shows as collected in billing, and the books disagree, or the books&lt;br&gt;
never got the line at all. Someone forgot, or delayed, or rounded, or&lt;br&gt;
posted to the wrong place on the second pass.&lt;/p&gt;

&lt;p&gt;This post is about refusing that pattern. We integrated existing MyCase&lt;br&gt;
billing, invoicing, and LawPay payment ledger events into MyCase Accounting&lt;br&gt;
GL posting rules on Rails so time, invoices, and trust/operating payments&lt;br&gt;
posted without re-keying.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rejected alternative: export/import
&lt;/h2&gt;

&lt;p&gt;The easy-sounding integration is a file. Export invoices and payments from&lt;br&gt;
billing. Import them into Accounting on a schedule. Call it done.&lt;/p&gt;

&lt;p&gt;Export/import keeps two systems of truth and reintroduces lag. Anything that&lt;br&gt;
can lag can miss. Anything that can miss puts you back in the phone-call&lt;br&gt;
failure mode from &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/building-mycase-accounting-trust-books-that-can-look-right-while-being-wrong-2a2c"&gt;Part 0&lt;/a&gt;: the&lt;br&gt;
aggregate looks fine until a human notices the gap.&lt;/p&gt;

&lt;p&gt;What we shipped instead: auto-post at the billing and payment event. When&lt;br&gt;
the billing layer records the fact, Accounting posting rules write the GL&lt;br&gt;
entries into the&lt;br&gt;
&lt;a href="https://dev.to/son_do_3543eb233b59d424bc/six-categories-some-locked-designing-a-legal-chart-of-accounts-417c"&gt;locked chart of accounts&lt;/a&gt;&lt;br&gt;
under trust and operating rules. One event, one post, same Rails&lt;br&gt;
transaction boundary where we could keep it honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundary: ledger-to-GL inside the monolith
&lt;/h2&gt;

&lt;p&gt;There is no romantic microservice story here. Billing and Accounting lived&lt;br&gt;
in the same Rails monolith. The seam was a ledger-to-GL bridge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Billing owns the payment and invoice facts.&lt;/li&gt;
&lt;li&gt;Accounting owns the posting rules onto the chart of accounts.&lt;/li&gt;
&lt;li&gt;The contract is prosaic: when billing records X, Accounting posts Y,
routed trust versus operating correctly.&lt;/li&gt;
&lt;/ul&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%2Fmio8dlyi98dnphv708c6.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%2Fmio8dlyi98dnphv708c6.png" alt="Ledger-to-GL bridge: billing events post into the general ledger at event time inside the monolith" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That boring boundary matters. It means the invoice identity&lt;br&gt;
&lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-aggregate-can-lie-flagging-trust-account-errors-without-drowning-in-false-positives-2p3a"&gt;Part 3&lt;/a&gt; matches against is the&lt;br&gt;
same invoice billing just wrote -- not a copied row from last night's CSV&lt;br&gt;
that drifted. It means&lt;br&gt;
&lt;a href="https://dev.to/son_do_3543eb233b59d424bc/for-review-matching-plaid-bank-feeds-without-duplicate-ledger-entries-24ig"&gt;Part 4&lt;/a&gt; Match has real in-system&lt;br&gt;
twins to link bank feed lines to, instead of an empty ledger waiting on&lt;br&gt;
manual entry.&lt;/p&gt;

&lt;p&gt;Firms activate Accounting onto the pre-populated locked chart; posting&lt;br&gt;
rules hang on those system accounts from the first event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the series
&lt;/h2&gt;

&lt;p&gt;The failure mode at the start of this series was simple and ugly: an&lt;br&gt;
aggregate that reconciled to the cent while a client's money was in the&lt;br&gt;
wrong place.&lt;/p&gt;

&lt;p&gt;The path through it was cumulative:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Lock the chart so auto-post and matching have stable identity.&lt;/li&gt;
&lt;li&gt;Auto-generate client trust ledgers so three-way is real, not optional.&lt;/li&gt;
&lt;li&gt;Flag trust-to-operating movement with invoice-exact matching so reviewers
trust every alert.&lt;/li&gt;
&lt;li&gt;Match bank feeds without categorizing trust into a second source of
truth.&lt;/li&gt;
&lt;li&gt;Post billing and LawPay events into the GL at event time so the books and
the practice stay one system of truth.&lt;/li&gt;
&lt;/ol&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%2Fz7vqex56m4lzze1cjudz.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%2Fz7vqex56m4lzze1cjudz.png" alt="Closing the loop: how Parts 1-5 compose into books that cannot hide behind a clean aggregate" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Watch closely without destroying the signal. Stop making a human retype the&lt;br&gt;
signal into a second ledger. That is the work.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Series -- When the Aggregate Looks Right:&lt;/em&gt; &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/building-mycase-accounting-trust-books-that-can-look-right-while-being-wrong-2a2c"&gt;0: Overview&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/six-categories-some-locked-designing-a-legal-chart-of-accounts-417c"&gt;1: Chart of Accounts&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/bank-firm-client-why-two-way-reconciliation-wasnt-enough-eg3"&gt;2: Three-Way Reconciliation&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-aggregate-can-lie-flagging-trust-account-errors-without-drowning-in-false-positives-2p3a"&gt;3: The Aggregate Can Lie&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/for-review-matching-plaid-bank-feeds-without-duplicate-ledger-entries-24ig"&gt;4: Bank Sync&lt;/a&gt; · &lt;strong&gt;5: GL Integration&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>compliance</category>
      <category>backend</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Bank, Firm, Client: Why Two-Way Reconciliation Wasn't Enough</title>
      <dc:creator>Son Do</dc:creator>
      <pubDate>Sun, 26 Jul 2026 05:03:46 +0000</pubDate>
      <link>https://dev.to/son_do_3543eb233b59d424bc/bank-firm-client-why-two-way-reconciliation-wasnt-enough-eg3</link>
      <guid>https://dev.to/son_do_3543eb233b59d424bc/bank-firm-client-why-two-way-reconciliation-wasnt-enough-eg3</guid>
      <description>&lt;p&gt;&lt;em&gt;Series -- When the Aggregate Looks Right:&lt;/em&gt; &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/building-mycase-accounting-trust-books-that-can-look-right-while-being-wrong-2a2c"&gt;0: Overview&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/six-categories-some-locked-designing-a-legal-chart-of-accounts-417c"&gt;1: Chart of Accounts&lt;/a&gt; · &lt;strong&gt;2: Three-Way Reconciliation&lt;/strong&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-aggregate-can-lie-flagging-trust-account-errors-without-drowning-in-false-positives-2p3a"&gt;3: The Aggregate Can Lie&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/for-review-matching-plaid-bank-feeds-without-duplicate-ledger-entries-24ig"&gt;4: Bank Sync&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/stop-re-keying-invoices-posting-billing-and-lawpay-events-to-the-gl-29dg"&gt;5: GL Integration&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Picture a clean close. The bank statement lines are checked off against the&lt;br&gt;
firm's trust ledger. The difference is $0.00. Everyone is ready to move on.&lt;/p&gt;

&lt;p&gt;Then a client calls about a retainer, and one client's sub-ledger is wrong&lt;br&gt;
by a few hundred dollars. Money that should have stayed in their slice was&lt;br&gt;
drawn against another matter. The bank never saw it. The aggregate never&lt;br&gt;
flagged it. Two-way reconciliation had succeeded at the only thing two-way&lt;br&gt;
can see.&lt;/p&gt;

&lt;p&gt;That is why bank-versus-firm matching is not enough for IOLTA trust&lt;br&gt;
accounts. This post is about the third leg -- per-client Client Trust&lt;br&gt;
Ledgers -- and the product decision that made the third leg exist by&lt;br&gt;
default instead of as optional setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two-way fails structurally
&lt;/h2&gt;

&lt;p&gt;A pooled trust account is one bank balance and many client slices. The bank&lt;br&gt;
statement is authoritative for the pool. The firm's trust ledger is&lt;br&gt;
authoritative for what the firm recorded. Neither one, alone or together,&lt;br&gt;
proves that each client's slice is correct.&lt;/p&gt;

&lt;p&gt;Two-way can tell you the pool matches. It cannot tell you whose money moved&lt;br&gt;
inside the pool. Under IOLTA, whose money moved is the whole point.&lt;br&gt;
Three-way reconciliation -- Trust Account Bank Statement, Firm Trust&lt;br&gt;
Ledger, and Client Trust Ledger (balance by matter) -- is not a MyCase&lt;br&gt;
invention. It is what the rules already require. The engineering question is&lt;br&gt;
whether the product makes the third ledger inevitable or optional.&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%2Fccyn8d3bkt994zuub9r8.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%2Fccyn8d3bkt994zuub9r8.png" alt="Two-way passes while a client slice is wrong; three-way surfaces the mismatch" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision: auto-generate the third leg
&lt;/h2&gt;

&lt;p&gt;The rejected alternative was manual setup. Ask the firm to create&lt;br&gt;
per-client sub-ledgers, then reconcile against them once they exist.&lt;/p&gt;

&lt;p&gt;That alternative fails quietly. Three-way on paper with a missing third leg&lt;br&gt;
is two-way in practice. Firms under time pressure skip optional&lt;br&gt;
configuration. The aggregate-lie failure mode stays invisible until a&lt;br&gt;
client phones in.&lt;/p&gt;

&lt;p&gt;What we shipped: Client Trust Ledgers are auto-generated per client as&lt;br&gt;
matters and trust activity require them. "Balance by matter" is the default&lt;br&gt;
path, not a checklist item. If you are recording trust activity in MyCase&lt;br&gt;
Accounting, the third ledger is already there when reconciliation starts.&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%2Faods84g8vz8dn6echwlt.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%2Faods84g8vz8dn6echwlt.png" alt="Auto-generated client sub-ledgers vs optional manual setup that firms skip" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That was the load-bearing product decision inside an otherwise mandated&lt;br&gt;
model. IOLTA says you need three-way. Auto-generation says you will still&lt;br&gt;
have three-way on a Thursday afternoon when nobody wants to configure&lt;br&gt;
anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workflow
&lt;/h2&gt;

&lt;p&gt;Reconciliation itself is deliberately boring. Compare bank statement lines&lt;br&gt;
against logged journal entries. Check off matches. Target a $0.00&lt;br&gt;
difference -- no variance band. A tolerance band is the crack a real error&lt;br&gt;
slips through; the same philosophy shows up again in&lt;br&gt;
&lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-aggregate-can-lie-flagging-trust-account-errors-without-drowning-in-false-positives-2p3a"&gt;Part 3&lt;/a&gt; on invoice-exact matching.&lt;/p&gt;

&lt;p&gt;The module supports checking, savings, credit card, and trust accounts on&lt;br&gt;
that check-off path. Trust is the hard case: it adds the client sub-ledger&lt;br&gt;
leg and the IOLTA constraints. Operating account types reuse the same&lt;br&gt;
statement-versus-journal workflow without client sub-ledgers. Supporting&lt;br&gt;
four account types was real work, but it is not the spine of this story.&lt;br&gt;
The spine is making the third trust ledger unavoidable.&lt;/p&gt;

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

&lt;p&gt;Once client-level balances are visible by default, you can finally watch&lt;br&gt;
the failure mode two-way cannot see. Watching introduces a new problem:&lt;br&gt;
how do you flag real trust-to-operating mistakes without burying reviewers&lt;br&gt;
in routine, correct transfers?&lt;/p&gt;

&lt;p&gt;That is &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-aggregate-can-lie-flagging-trust-account-errors-without-drowning-in-false-positives-2p3a"&gt;Part 3&lt;/a&gt; -- the aggregate&lt;br&gt;
can lie, and a naive safeguard can lie louder.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Series -- When the Aggregate Looks Right:&lt;/em&gt; &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/building-mycase-accounting-trust-books-that-can-look-right-while-being-wrong-2a2c"&gt;0: Overview&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/six-categories-some-locked-designing-a-legal-chart-of-accounts-417c"&gt;1: Chart of Accounts&lt;/a&gt; · &lt;strong&gt;2: Three-Way Reconciliation&lt;/strong&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-aggregate-can-lie-flagging-trust-account-errors-without-drowning-in-false-positives-2p3a"&gt;3: The Aggregate Can Lie&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/for-review-matching-plaid-bank-feeds-without-duplicate-ledger-entries-24ig"&gt;4: Bank Sync&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/stop-re-keying-invoices-posting-billing-and-lawpay-events-to-the-gl-29dg"&gt;5: GL Integration&lt;/a&gt;&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>compliance</category>
      <category>backend</category>
      <category>architecture</category>
    </item>
    <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; &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/building-mycase-accounting-trust-books-that-can-look-right-while-being-wrong-2a2c"&gt;0: Overview&lt;/a&gt; · &lt;strong&gt;1: Chart of Accounts&lt;/strong&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/bank-firm-client-why-two-way-reconciliation-wasnt-enough-eg3"&gt;2: Three-Way Reconciliation&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-aggregate-can-lie-flagging-trust-account-errors-without-drowning-in-false-positives-2p3a"&gt;3: The Aggregate Can Lie&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/for-review-matching-plaid-bank-feeds-without-duplicate-ledger-entries-24ig"&gt;4: Bank Sync&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/stop-re-keying-invoices-posting-billing-and-lawpay-events-to-the-gl-29dg"&gt;5: GL Integration&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The tempting chart of accounts design for a law-firm product is the&lt;br&gt;
QuickBooks-shaped one: ship a starter tree, then let firms rename, move, and&lt;br&gt;
delete whatever they want. Firms know their own books. Flexibility feels&lt;br&gt;
like respect.&lt;/p&gt;

&lt;p&gt;We did not ship that.&lt;/p&gt;

&lt;p&gt;I designed MyCase Accounting's chart of accounts as a legal-specific,&lt;br&gt;
pre-populated structure with six top-level categories -- Assets, Liabilities&lt;br&gt;
(including segregated trust), Equity, Revenue, Expenses, and Compensation --&lt;br&gt;
and with a hard rule underneath: some system accounts are locked. Billing,&lt;br&gt;
invoicing, and related auto-posting write to those accounts. Firms customize&lt;br&gt;
under them with sub-accounts. They do not get to mutate the roots the&lt;br&gt;
product depends on.&lt;/p&gt;

&lt;p&gt;This post is about that lock decision: the rejected alternative, what firms&lt;br&gt;
can still change, and the downstream matching that only works because the&lt;br&gt;
identity stayed stable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rejected alternative
&lt;/h2&gt;

&lt;p&gt;A fully customizable chart looks customer-friendly until the product has to&lt;br&gt;
post on the firm's behalf.&lt;/p&gt;

&lt;p&gt;MyCase already had billing, invoicing, and payment flows. Accounting had to&lt;br&gt;
record those events into the general ledger without a bookkeeper retyping&lt;br&gt;
amounts into a second system. That means the product, not the firm, chooses&lt;br&gt;
which account receives an invoice post or a trust-to-operating movement.&lt;br&gt;
If the firm can rename, merge, or delete those accounts, every auto-post&lt;br&gt;
becomes a moving target -- and every compliance check that needs "the trust&lt;br&gt;
liability account" or "the operating revenue path for this invoice" loses&lt;br&gt;
its handle.&lt;/p&gt;

&lt;p&gt;The alternative we rejected was full editability with a shrug: "firms will&lt;br&gt;
configure it correctly." That is not a design. It is hope. Hope is a bad&lt;br&gt;
dependency when the money is client trust funds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six categories, customization underneath
&lt;/h2&gt;

&lt;p&gt;The shape we shipped is deliberately legal, not a generic GAAP starter kit&lt;br&gt;
with trust bolted on later.&lt;/p&gt;

&lt;p&gt;Liabilities include segregated trust. That is not a cosmetic label. Trust&lt;br&gt;
money is not the firm's money, and the chart has to say so in a place the&lt;br&gt;
rest of the system can rely on. Assets, equity, revenue, expenses, and&lt;br&gt;
compensation fill out the operating side firms already understand from&lt;br&gt;
practice management.&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="Six top-level categories with locked system accounts and firm-customizable sub-accounts" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pre-population mattered for adoption. Accounting activates onto that&lt;br&gt;
starter chart so a firm is not staring at a blank tree on day one.&lt;br&gt;
Customization is real -- sub-accounts under the locked roots -- but it does&lt;br&gt;
not include deleting the system accounts billing posts into.&lt;/p&gt;

&lt;p&gt;When a firm wanted to rename a locked account, the answer was a hard stop&lt;br&gt;
and a redirect: create a sub-account for the label you want. The roots stay&lt;br&gt;
put.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the lock pays off downstream
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-aggregate-can-lie-flagging-trust-account-errors-without-drowning-in-false-positives-2p3a"&gt;Part 3&lt;/a&gt; covers the safeguard that&lt;br&gt;
flags trust-to-operating transfers that do not exactly match an unpaid&lt;br&gt;
invoice. That safeguard only works if a posted invoice and its&lt;br&gt;
corresponding trust transaction share a stable link through the chart.&lt;/p&gt;

&lt;p&gt;Without locked, billing-linked accounts, there is nothing precise to match&lt;br&gt;
a transfer against. You get amount-ish heuristics, tolerance bands, and the&lt;br&gt;
false-positive flood that kills reviewer trust. The chart of accounts is&lt;br&gt;
not a setup screen you finish and forget. It is the identity layer the rest&lt;br&gt;
of the module hangs on.&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="Locked chart enables invoice-exact matching; unlocked chart loses stable identity" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/son_do_3543eb233b59d424bc/stop-re-keying-invoices-posting-billing-and-lawpay-events-to-the-gl-29dg"&gt;Part 5&lt;/a&gt; closes the other&lt;br&gt;
half of the same decision: when billing and LawPay ledger events fire,&lt;br&gt;
posting rules write into those locked accounts under trust and operating&lt;br&gt;
rules. Event-time auto-post into a moving chart is how you reintroduce&lt;br&gt;
re-keying with extra steps. Event-time auto-post into a locked chart is how&lt;br&gt;
you eliminate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this post is not
&lt;/h2&gt;

&lt;p&gt;This is not a tour of every system account name we locked. The rule matters&lt;br&gt;
more than the roster: if billing or compliance matching depends on an&lt;br&gt;
account, it is locked; firm-facing labels live in sub-accounts.&lt;/p&gt;

&lt;p&gt;It is also not the three-way reconciliation story. The chart makes precise&lt;br&gt;
matching possible. &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/bank-firm-client-why-two-way-reconciliation-wasnt-enough-eg3"&gt;Part 2&lt;/a&gt;&lt;br&gt;
is about why the third ledger has to exist by default.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Series -- When the Aggregate Looks Right:&lt;/em&gt; &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/building-mycase-accounting-trust-books-that-can-look-right-while-being-wrong-2a2c"&gt;0: Overview&lt;/a&gt; · &lt;strong&gt;1: Chart of Accounts&lt;/strong&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/bank-firm-client-why-two-way-reconciliation-wasnt-enough-eg3"&gt;2: Three-Way Reconciliation&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-aggregate-can-lie-flagging-trust-account-errors-without-drowning-in-false-positives-2p3a"&gt;3: The Aggregate Can Lie&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/for-review-matching-plaid-bank-feeds-without-duplicate-ledger-entries-24ig"&gt;4: Bank Sync&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/stop-re-keying-invoices-posting-billing-and-lawpay-events-to-the-gl-29dg"&gt;5: GL Integration&lt;/a&gt;&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>compliance</category>
      <category>backend</category>
      <category>architecture</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; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/six-categories-some-locked-designing-a-legal-chart-of-accounts-417c"&gt;1: Chart of Accounts&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/bank-firm-client-why-two-way-reconciliation-wasnt-enough-eg3"&gt;2: Three-Way Reconciliation&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-aggregate-can-lie-flagging-trust-account-errors-without-drowning-in-false-positives-2p3a"&gt;3: The Aggregate Can Lie&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/for-review-matching-plaid-bank-feeds-without-duplicate-ledger-entries-24ig"&gt;4: Bank Sync&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/stop-re-keying-invoices-posting-billing-and-lawpay-events-to-the-gl-29dg"&gt;5: GL Integration&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A law firm's trust account holds other people's money. Under IOLTA rules in&lt;br&gt;
most US states, every client with an open matter owns a slice of one pooled&lt;br&gt;
bank account, and mixing up whose money is whose is not a bookkeeping quirk.&lt;br&gt;
It is the kind of mistake that gets a lawyer disbarred.&lt;/p&gt;

&lt;p&gt;Here is the failure mode that shaped everything we built: the pooled&lt;br&gt;
account's aggregate balance can match the bank exactly, down to the cent,&lt;br&gt;
while one client's individual slice is wrong. The bank does not know about&lt;br&gt;
client-level sub-ledgers. It only sees one account. A reconciliation that&lt;br&gt;
stops at "does the aggregate match" will pass that check every time, right&lt;br&gt;
up until a client asks where their money went.&lt;/p&gt;

&lt;p&gt;We watched that happen during early testing. One firm's trust account&lt;br&gt;
reconciled clean against the bank. Everyone would have moved on. Then a&lt;br&gt;
client called about their retainer, and the firm went looking: that one&lt;br&gt;
client's sub-ledger was off by a few hundred dollars. Funds that should have&lt;br&gt;
stayed in their slice had been drawn against another matter. The aggregate&lt;br&gt;
had absorbed the error and reported nothing. A phone call was the only&lt;br&gt;
reason anyone found it.&lt;/p&gt;

&lt;p&gt;I was the hands-on tech lead on a 6-8 engineer squad for MyCase Accounting,&lt;br&gt;
the legal bookkeeping module we built on Rails from late 2021 through early&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;This post is the series map, not the deep dives. The deep dives start
in Part 1.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The thesis
&lt;/h2&gt;

&lt;p&gt;Three-way reconciliation is not the engineering invention. IOLTA already&lt;br&gt;
requires matching the bank statement, the firm's trust ledger, and every&lt;br&gt;
client's sub-ledger against each other. The real work is building a system&lt;br&gt;
firms can actually run while watching closely enough to catch client-level&lt;br&gt;
errors -- without drowning reviewers in false positives, and without&lt;br&gt;
re-keying the same invoices and payments across two systems of truth.&lt;/p&gt;

&lt;p&gt;Watch closely without destroying the signal. That is the series in one line.&lt;/p&gt;

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

&lt;p&gt;Five deep dives walk the path through that problem:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Chart of accounts&lt;/strong&gt; -- six legal-specific categories, with system
accounts locked so billing can auto-post and compliance matching has a
stable identity to hang on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three-way reconciliation&lt;/strong&gt; -- why bank-vs-firm two-way is not enough,
and why per-client sub-ledgers have to be auto-generated or the third leg
never exists in practice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IOLTA safeguards&lt;/strong&gt; -- how we flagged real trust-to-operating problems
without flooding the review queue (the already-written deep dive).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plaid bank sync&lt;/strong&gt; -- the For Review queue that matches feed lines to
existing MyCase records without double-posting, including the trust-side
rule that skips categorization entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Billing to GL&lt;/strong&gt; -- posting invoice and LawPay payment events into the
chart of accounts at event time, so firms stop re-keying.&lt;/li&gt;
&lt;/ol&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 deep dives from chart of accounts through GL integration" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Expense tracking, matter allocation, and financial reporting shipped in the&lt;br&gt;
same module. Those were a teammate's primary work, not mine, and they are&lt;br&gt;
out of scope for this series.&lt;/p&gt;

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

&lt;p&gt;Firms using the reconciliation module cut trust reconciliation from roughly&lt;br&gt;
6 hours of manual work a month to under 30 minutes per cycle. Within twelve&lt;br&gt;
months of 2022 general availability, 18% of eligible firms enabled the&lt;br&gt;
module.&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 to under 30 minutes" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Those numbers matter, but they are not the thesis. The thesis is that an&lt;br&gt;
aggregate can look perfect while a client's money is in the wrong place, and&lt;br&gt;
that the engineering problem is how to watch for that without building a&lt;br&gt;
safeguard nobody trusts.&lt;/p&gt;

&lt;p&gt;Part 1 starts where the precision has to live first: the chart of accounts.&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; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/six-categories-some-locked-designing-a-legal-chart-of-accounts-417c"&gt;1: Chart of Accounts&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/bank-firm-client-why-two-way-reconciliation-wasnt-enough-eg3"&gt;2: Three-Way Reconciliation&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/the-aggregate-can-lie-flagging-trust-account-errors-without-drowning-in-false-positives-2p3a"&gt;3: The Aggregate Can Lie&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/for-review-matching-plaid-bank-feeds-without-duplicate-ledger-entries-24ig"&gt;4: Bank Sync&lt;/a&gt; · &lt;a href="https://dev.to/son_do_3543eb233b59d424bc/stop-re-keying-invoices-posting-billing-and-lawpay-events-to-the-gl-29dg"&gt;5: GL Integration&lt;/a&gt;&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>compliance</category>
      <category>backend</category>
      <category>architecture</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>
  </channel>
</rss>
