Series -- From Ad-Hoc SQL to a Semantic Layer: 0: Overview · 1: Ten Answers · 2: Why Not Indexes · 3: Metrics Model · 4: View Pipeline · 5: Schema Design · 6: Authorization · 7: Migration Sequence · 8: Stakeholder Alignment
"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.
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."
What the schema's boundary is for
In the stack, GraphQL is L3. It sits between the materialized views at L2 (covered in Part 4) 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.
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.
The type hierarchy: domain-scoped roots, metrics-specific types
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.
type Query {
billingAnalytics(firmId: ID!, dateRange: DateRangeInput!): BillingAnalytics
trustAnalytics(firmId: ID!, dateRange: DateRangeInput!): TrustAnalytics
caseAnalytics(firmId: ID!, dateRange: DateRangeInput!): CaseAnalytics
financialAnalytics(firmId: ID!, dateRange: DateRangeInput!): FinancialAnalytics
}
type BillingAnalytics {
billableHours: Float
invoicedHours: Float
writeOffAmount: Float
}
A billing report queries billingAnalytics. It does not query cases or timeEntries. 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.
Why this hierarchy and not the other two
Two alternatives were on the table, and rejecting them is what makes the hybrid a decision rather than a default.
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 cases { billingMetrics }. That reads naturally right up until you notice what it does to coupling. It ties the reporting layer to the structure 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.
The second question was where the context arguments go. Putting firmId and dateRange 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 billableHours 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.
The anti-churn rule: additive-only, deprecate, then remove
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.
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 @deprecated 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.
type BillingAnalytics {
billableHours: Float
invoicedHours: Float
writeOffAmount: Float
"Use billableHours; this excluded write-offs inconsistently across firms."
billableHoursLegacy: Float @deprecated(reason: "Superseded by billableHours as of the unified write-off definition. Remove after all reports cut over.")
}
This is the same lifecycle the metric classes use underneath. Where the Ruby side keeps a VERSION 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.
The payoff at L7: reports as config, not SQL
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.
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.
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.
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.


Top comments (0)