Skip to content

Analytics conventions — naming, structure, localization

These conventions are not just style preferences — they’re load-bearing for the whole Granit BI stack. The same metric name appears in localization JSON keys, HTTP route parameters, dashboard widget references, OData EntitySet aliases, and Power BI client measures. Drift here breaks the link between layers.

ElementFormatExample
File pathsrc/Granit.{Module}/Metrics/{MetricName}MetricDefinition.cssrc/Granit.Invoicing/Metrics/UnpaidInvoiceCountMetricDefinition.cs
Class name{MetricName}MetricDefinitionUnpaidInvoiceCountMetricDefinition
Name propertyGranit.{Module}.{MetricName}Metric"Granit.Invoicing.UnpaidInvoiceCountMetric"
Localization keyMetric:{Name}Metric:Granit.Invoicing.UnpaidInvoiceCountMetric

The class file uses the MetricDefinition suffix (parallel to QueryDefinition / ExportDefinition); the Name property drops Definition but keeps Metric so the three primitives can be listed side-by-side in admin tools without ambiguity.

{Subset?}{Entity}{Field?}{Aggregation} — PascalCase, no spaces, no abbreviations, aggregation always at the end.

SlotRoleExamples
Subset (optional)Adjective scoping the dataUnpaid, Active, Recent, Orphan, Pending, Failed, Distinct
EntitySingular nounInvoice, Subscription, Blob, Webhook, Job, Customer
Field (optional)Property being aggregated, when the entity has multiple aggregable dimensionsAmount, PaymentDelay, Duration, LineCount
AggregationSuffix matching the function — always lastCount, Total, Average, Min, Max, Rate, Ratio

Strict suffix-position groups metrics alphabetically by domain in IDE autocomplete, in the admin UI metric picker, and in the OpenAPI schema. Typing Granit.Invoicing.Invoice in any of these surfaces brings up InvoiceCount, InvoiceTotal, InvoiceAmountAverage, InvoiceAmountMin, InvoiceAmountMax clustered together by entity — far more discoverable than scattering them across Average*, Min*, Max* first letters.

DAX users will recognise this as the same intuition behind [Sales Amount Average] (prefer) over [Average Sales Amount] — Microsoft’s own measure-naming guidance for Analysis Services follows the same logic.

SuffixMeaningMaps toDefault ValueKind
CountNumber of rows matching the subsetAggregateFunction.CountCount
TotalSum over a numeric fieldAggregateFunction.SumCurrency (override to Number for non-monetary sums)
AverageArithmetic mean over the named fieldAggregateFunction.Avgdepends on the field
Min / MaxLowest / highest value of the named fieldAggregateFunction.Min / Maxdepends on the field
RateEvents per time unitSum divided by intervalNumber (rate per second / hour…)
RatioPart / total fractionComposite (numerator / denominator)Percentage

Rate vs Ratio follows DAX: a rate is “events per unit of time” (e.g. crash rate per day), a ratio is “part over whole” (e.g. win ratio = wins / total games). Both typically render as percentages, but the underlying math differs.

SubsetIsHigherBetter
Failed*, Unpaid*, Orphan*, Pending*, Stale*, Overdue*false (less is better)
Active*, Successful*, Completed*, Approved*, Distinct*Count, *Revenue, *Total (revenue)true (more is better)

Drives the green/red color of the delta arrow in the UI. Override via the property when the heuristic doesn’t match (e.g. *Total for cost amounts is false).

Modules with internal sub-domains (Invoicing = Invoices + CreditNotes, Identity = Local + Federated, BackgroundJobs = Recurring + One-shot) use a fourth segment:

Granit.Invoicing.CreditNotes.OutstandingCountMetric
Granit.Identity.Local.LockedAccountCountMetric
Granit.BackgroundJobs.Recurring.FailureRateMetric

The sub-module segment is optional — only use it when the same Module + Entity pair would otherwise collide between sub-domains.

Examples — projected on the framework’s modules

Section titled “Examples — projected on the framework’s modules”

Notice how every entity’s metrics cluster alphabetically: all Invoice* together, all PaymentTransaction* together. The aggregation is always the final word.

ModuleClassName
InvoicingUnpaidInvoiceCountMetricDefinitionGranit.Invoicing.UnpaidInvoiceCountMetric
InvoicingUnpaidInvoiceTotalMetricDefinitionGranit.Invoicing.UnpaidInvoiceTotalMetric
InvoicingInvoicePaymentDelayAverageMetricDefinitionGranit.Invoicing.InvoicePaymentDelayAverageMetric
InvoicingInvoicePaymentDelayMaxMetricDefinitionGranit.Invoicing.InvoicePaymentDelayMaxMetric
SubscriptionsMonthlyRecurringRevenueMetricDefinitionGranit.Subscriptions.MonthlyRecurringRevenueMetric (canonical SaaS term — see exception below)
SubscriptionsSubscriptionChurnRateMetricDefinitionGranit.Subscriptions.SubscriptionChurnRateMetric
PaymentsPaymentTransactionCountMetricDefinitionGranit.Payments.PaymentTransactionCountMetric
PaymentsPaymentTransactionSuccessRateMetricDefinitionGranit.Payments.PaymentTransactionSuccessRateMetric
PrivacyPendingErasureRequestCountMetricDefinitionGranit.Privacy.PendingErasureRequestCountMetric
BackgroundJobsFailedJobCountMetricDefinitionGranit.BackgroundJobs.FailedJobCountMetric
BlobStorageOrphanBlobCountMetricDefinitionGranit.BlobStorage.OrphanBlobCountMetric
WebhooksWebhookDeliveryFailureRateMetricDefinitionGranit.Webhooks.WebhookDeliveryFailureRateMetric

Time references in the name — when to inline, when not to

Section titled “Time references in the name — when to inline, when not to”

The framework’s principle: time periods are query parameters, not part of the identity. One Granit.Subscriptions.RecurringRevenueMetric answers MRR with ?period=mtd, ARR with ?period=ytd&aggregation=annualize, last-quarter revenue with ?period=pqc, and so on. Adding Monthly / Yearly / Daily to the name multiplies definitions for no analytical gain.

The single exception: when the time unit is part of a canonical industry name and the metric always normalises to it regardless of the query period. SaaS “Monthly Recurring Revenue” and “Annual Recurring Revenue” are the textbook examples — MRR is always the monthly equivalent of the recurring book of business (yearly subscription / 12, etc.) at any point in time. In those cases keep the domain term and document why in the class XML doc:

/// <summary>
/// Monthly Recurring Revenue (MRR) — SaaS canonical metric: the monthly-equivalent
/// recurring book of business at the queried instant. The "Monthly" is part of the
/// metric's identity, not a time period (see Granit conventions).
/// </summary>
public sealed class MonthlyRecurringRevenueMetricDefinition : MetricDefinition<Subscription, decimal> { … }

When in doubt, drop the time word from the name.

The HTTP endpoint accepts named tokens for both the main period and the optional compareTo window. Tokens are case-insensitive and resolved server-side via IClock (never DateTimeOffset.UtcNow), so the same request is reproducible across machines and time zones.

DAX users will recognise the upper-cased acronyms — they’re the same calendar semantics:

TokenDAX acronymWindow
today[start of today, end of today)
yesterday[start of yesterday, start of today)
last_60s / last_5mrolling seconds / minutes (real-time-friendly)
last_7d[7 days ago at midnight, end of today)
last_30d[30 days ago at midnight, end of today)
mtdMTD (Month-to-date)[1st of current month, end of today)
qtdQTD (Quarter-to-date)[1st of current quarter, end of today)
ytdYTD (Year-to-date)[1st of January current year, end of today)
matMAT (Moving Annual Total)[365 days ago, end of today) — rolling 12 months. Planned.

Same DAX acronyms; the framework computes the time shift:

TokenDAX acronymResolves to
previous_periodPP (Previous Period)Equal-length window immediately preceding the main one — works for any main period
pmPM (Previous Month — rolling)Same offset within the previous month. Planned.
pqPQ (Previous Quarter — rolling)Same offset within the previous quarter. Planned.
pyPY (Previous Year — rolling)Same offset within the previous year. Planned.
pmcPMC (Previous Month Complete)Full previous calendar month. Planned.
pqcPQC (Previous Quarter Complete)Full previous calendar quarter. Planned.
pycPYC (Previous Year Complete)Full previous calendar year. Planned.

Every MetricDefinition.Name MUST have a Metric:{Name} key in all 18 base / regional cultures of the owning module’s Localization/ folder. This includes:

  • 15 base cultures: en, fr, nl, de, es, it, pt, zh, ja, pl, tr, ko, sv, cs, hi
  • 3 regional variants: fr-CA, en-GB, pt-BR (only differing keys; fall through to base culture for unchanged strings)

Architecture test (story #1397, planned) will fail the build when a metric ships without complete coverage.

Metric endpoints inherit the underlying entity’s read permission. A metric over Invoice is gated by Invoicing.Invoices.Read — never a metric-specific permission. Rationale: the metric exposes an aggregate of data the user could already see via the list endpoint; granting a separate metric permission would be a confusing layer without security value.

DashboardDefinition — placement and lifecycle

Section titled “DashboardDefinition — placement and lifecycle”

Dashboards split into two distinct types — module-shipped declarations and tenant-composed aggregates. Conventions are pinned by ADR-038.

TypeLives inMutabilityPersistence
DashboardDefinitionsrc/Granit.{Module}/Dashboards/{Name}DashboardDefinition.cs (base module)Immutable — codeNone — assembly only
Dashboard (aggregate)Granit.Analytics aggregate, Granit.Analytics.EntityFrameworkCore configMutable — admin actionsEF Core multi-tenant + soft-delete

Key rules:

  • Definitions are imported, not auto-instantiated. When a tenant first installs a module, no Dashboard is created automatically. Admins explicitly import via POST /dashboards/from-definition/{name}, which deep-copies the definition.
  • Once imported, dashboards are frozen. Module upgrades surface drift via Dashboard.SourceDefinitionVersion + an admin-driven re-sync action — never silent retro-edit.
  • WidgetInstance.ConfigJson is a JSON blob, not a polymorphic hierarchy. New widget kinds (Kpi, Chart, Table, Pivot, Markdown, Map…) ship without an EF Core migration.
  • Naming: {Name}DashboardDefinition (PascalCase). Name property uses Granit.{Module}.{Name} (e.g. Granit.Invoicing.FinanceOverview).
  • Localization key: Dashboard:{Name} in the same Localization/{Module}/ folder as the metric keys, all 18 cultures.
  • Pairing rule (D1 #1396): every dashboard widget references a metric or query the user can permission-check. Widgets that point at a missing metric/query render as a widget unavailable placeholder card — the dashboard itself stays usable.

Migration story for module upgrades that remove a DashboardDefinition:

  1. Persisted Dashboard rows are not affected — the definition was deep-copied at import time.
  2. Widgets pointing at missing metrics / queries degrade gracefully (placeholder).
  3. Drift is queryable via the catalogue endpoint; admins choose to re-sync, clear the source link (turn into ad-hoc), or delete the dashboard.
  • Abbreviations: UnpaidInvCnt ❌. DAX rule applies — verbose names beat clever ones, especially when the same identifier surfaces in URLs and JSON keys.
  • Mixed case in module segment: Granit.invoicing.UnpaidInvoiceCountMetric ❌. The module segment must match the C# module name exactly.
  • Time intelligence baked into the name: Granit.Sales.SalesYTDMetric ❌. Use the period parameter — see the note callout above.
  • Hyphens / underscores in Name: granit-invoicing-unpaid-invoice-count ❌. PascalCase + dot-separated only. Background-jobs are the exception (- separator) because they’re a different namespace category.
  • Redundant suffixes: Granit.Invoicing.UnpaidInvoiceCountMetricDefinition ❌ (the Definition suffix lives on the C# class only, not the wire identifier).
  • Overview — Three-layer BI stack, package structure.
  • Inline metrics — Walk-through of declaring, registering, and rendering a MetricDefinition end-to-end.
  • Dashboards conventionsDashboardDefinition shape, the seven widget kinds, Datasource abstraction, JSON wire format.
  • Dashboards endpoints — REST surface (catalogue / import / list / read / state transitions / widget CRUD) that hosts the rendered metrics.