Skip to content

ADR-038: DashboardDefinition vs Dashboard Boundary

Date: 2026-04-28 Authors: Jean-Francois Meyers Scope: granit-dotnet (Granit.Analytics, Granit.Analytics.EntityFrameworkCore, Granit.Analytics.Endpoints) Epic: #1366 — Business Intelligence Story: #1381 — B0

Feature B of the BI epic introduces “composable dashboards” on top of the inline-metric foundation shipped in Feature A (MetricDefinition + POST /metrics/{name}). The word dashboard covers two distinct concepts that were conflated in early design notes:

  1. What a module ships out of the box. “The Invoicing module proposes a Finance overview dashboard with these 7 widgets and these defaults.” Lives in code, like QueryDefinition / MetricDefinition / ExportDefinition (ADR-020). Never mutated at runtime.
  2. What an admin actually composes for their tenant. Persisted, mutable. Layout customised, widgets added/removed/reconfigured, permissions per widget enforced at render time.

Treating the two as one type creates immediate problems:

  • Module upgrades would silently mutate a tenant’s curated layout — a regression risk comparable to a CMS template editing the user’s published page.
  • Admins could not reorder, rename or remove widgets on a “shipped” dashboard without forking the module’s code.
  • The persistence model would force every framework module to ship migrations, which contradicts the Granit “no migrations in framework packages” rule.

Mirror of the Granit.QueryEngine precedent: QueryDefinition<T> is declarative and in-code; the runtime query (QueryRequest payload + applied filter pipeline) is the mutable counterpart on the wire. Dashboards follow the same dichotomy.

1. Two types, two homes — and a dedicated package family

Section titled “1. Two types, two homes — and a dedicated package family”

The framework cannot host dashboards under Granit.Analytics because the same primitive will eventually power IoT real-time dashboards (gauges, camera feeds, alarm lights — none of which are analytics measures). Forcing Granit.IoT to depend on Granit.Analytics (and thus on Granit.QueryEngine) just to declare a widget would be the wrong dependency direction.

The two types therefore live in a dedicated package family that mirrors the QueryEngine.Abstractions / QueryEngine split (ADR-020):

ConceptTypeLives inMutabilityPersistence
Declarative dashboard catalogue entryDashboardDefinitionPer-domain — Granit.Analytics, future Granit.IoT.Dashboards, … — declared via Granit.Dashboards.AbstractionsImmutable — codeNone — assembly only
Tenant-composed dashboard (aggregate)DashboardGranit.Dashboards (aggregate root, story B2)Mutable — admin actionsGranit.Dashboards.EntityFrameworkCore (story B2)

Package responsibilities:

PackageContents
Granit.Dashboards.AbstractionsDashboardDefinition, WidgetDefinition (base), DashboardLayout, WidgetSize, IDashboardDefinitionRegistry, DashboardCategory. Plus presentation-only widgets shared across domains: MarkdownWidgetDefinition, ImageWidgetDefinition, TextWidgetDefinition. Lightweight — only consumers of contracts pull this.
Granit.DashboardsIDashboardDefinitionRegistry implementation, AddGranitDashboards() + AddDashboardDefinition<TDef>() DI extensions. Future home of the persisted Dashboard aggregate, import / re-sync logic, and the real-time hub for IoT live tiles.
Granit.AnalyticsAnalytics-flavoured widgets: KpiWidgetDefinition (binds to MetricDefinition), ChartWidgetDefinition / TableWidgetDefinition / PivotWidgetDefinition (bind to QueryDefinition). Depends on Granit.Dashboards.Abstractions.
Granit.IoT.Dashboards (future)IoT-flavoured widgets: live sensor gauge, camera feed, alarm light. Depends on Granit.Dashboards.Abstractions. Will not depend on Granit.Analytics.

DashboardDefinition itself follows the placement rule of ADR-020: each module declares its own dashboards and registers them via services.AddDashboardDefinition<TDef>(). There is no central aggregation package.

Presentation-only widgets (Markdown, Image, Text) ship in Granit.Dashboards.Abstractions because they are domain-neutral — a dashboard in any vertical may want a banner, a logo or a heading. Putting them anywhere else would force IoT modules to depend on Analytics for a banner.

2. Dashboards are imported, not auto-instantiated

Section titled “2. Dashboards are imported, not auto-instantiated”

When a tenant first installs a module, no Dashboard row is created automatically. The admin browses the catalogue (GET /dashboards/catalog) and explicitly imports a definition (POST /dashboards/from-definition/{name}), which deep-copies the definition into a new Dashboard aggregate.

Justification:

  • Auto-instantiation would dump dozens of dashboards on every tenant the moment a module ships — cognitive overload, no signal of intent.
  • Explicit import gives admins the right reflex: “this is mine now; the module no longer drives it.”
  • Symmetry with how email templates already work in Granit.Notifications (admins override embedded templates by explicitly persisting their version).

Once a Dashboard is created from a DashboardDefinition, module upgrades do NOT retro-edit it. The shipped definition becomes the seed; the persisted dashboard is the living copy and evolves independently.

Drift is surfaced — not silently reconciled — via two mechanisms:

  • Dashboard.SourceDefinitionVersion records the version of the definition at import time. Admin UI can flag “the Finance overview shipped by Invoicing has been updated since you imported it” and offer a manual re-sync action that the admin must trigger.
  • A Dashboard.IsSystem flag (see decision 5) reserves a one-way escape hatch for framework-critical dashboards.

Justification:

  • A tenant invests effort customising layout and widget configuration. Silently overwriting that on dotnet ef database update would be a CMS-grade regression.
  • Re-sync is an admin-driven UX, not a migration concern. It belongs at the application layer, not in EF Core.

4. Dashboards may reference widgets outside any definition

Section titled “4. Dashboards may reference widgets outside any definition”

A Dashboard is not constrained to widgets present in some DashboardDefinition. Admins compose freely — any widget pointing to any metric / query they have permission on may be added.

Justification:

  • The module-shipped definition is a suggestion, not a contract. Admins know their business better than module authors.
  • The pairing rule already enforced by D1 (MetricDefinitionQueryDefinition) and by ADR-020 (QueryDefinitionExportDefinition) guarantees that any metric an admin could pin has a queryable counterpart and an exportable counterpart. Permission filtering at render time (story B4) closes the security loop.

5. IsSystem flag for framework-critical dashboards

Section titled “5. IsSystem flag for framework-critical dashboards”

A DashboardDefinition may set IsSystem = true. When imported, the resulting Dashboard carries the flag and cannot be deleted by admin UI (only re-sync).

Use cases:

  • An ops dashboard imposed by the platform operator (multi-tenant SaaS host) that the tenant must keep visible — typically tied to a contractual SLA dashboard.
  • A compliance dashboard required by a regulated industry deployment.

IsSystem is not the default; framework modules ship dashboards with IsSystem = false. The flag is a deliberate opt-in for hosting scenarios.

// Aggregate root
class Dashboard : AuditedAggregateRoot, IMultiTenant, ISoftDelete
{
public DashboardId Id { get; private set; }
public Guid? TenantId { get; private set; }
public string Name { get; private set; } // tenant-renamable
public string? Slug { get; private set; } // /dashboards/{slug}
public string? SourceDefinitionName { get; private set; } // null = ad-hoc
public string? SourceDefinitionVersion { get; private set; }
public bool IsSystem { get; private set; }
public DashboardLayout Layout { get; private set; } // value object — grid/columns/breakpoints
public IReadOnlyList<WidgetInstance> Widgets => _widgets;
private readonly List<WidgetInstance> _widgets = [];
}
// Owned entity — one row per pinned widget, ordered
class WidgetInstance : AuditedEntity
{
public WidgetInstanceId Id { get; private set; }
public DashboardId DashboardId { get; private set; }
public int Position { get; private set; } // dense-ranked
public WidgetKind Kind { get; private set; } // Kpi | Chart | Table | Pivot | Markdown | Map
public string? MetricName { get; private set; } // for Kpi / Chart
public string? QueryDefinitionName { get; private set; } // for Table / Pivot
public string ConfigJson { get; private set; } // kind-specific JSON: ChartType, ColumnSet, ...
public string? RequiredPermission { get; private set; } // optional override; default derived from Metric/Query
public WidgetSize Size { get; private set; } // value object — { width, height } in grid units
}

Notes:

  • WidgetInstance.ConfigJson is intentionally a JSON blob, not a polymorphic hierarchy — keeps the schema additive when new widget kinds ship (story B7 will add Map without a migration). Validated server-side by a per-WidgetKind validator.
  • RequiredPermission is optional. When absent, the runtime resolves the effective permission from the linked metric / query — keeping the most-restrictive contract without forcing the admin to retype it on every widget. The override exists for composite widgets (e.g. a Kpi that surfaces an aggregate of two metrics under a single coarser permission).
  • Soft-delete is applied to dashboards (admins can restore an accidentally-deleted dashboard during a 30-day window via the standard IDataFilter.Disable<ISoftDelete>() scope).
  • No domain events on Dashboard for now (story B2). Re-sync orchestration in B6 may introduce them.

DashboardDefinition mirrors the same shape but with declarative, immutable counterparts:

abstract class DashboardDefinition
{
public abstract string Name { get; } // "Granit.Invoicing.FinanceOverview"
public abstract string DisplayKey { get; } // "Dashboard:Granit.Invoicing.FinanceOverview"
public abstract string Version { get; } // semver — used for drift detection
public virtual bool IsSystem => false;
public abstract DashboardLayout Layout { get; }
public abstract IReadOnlyList<WidgetDefinition> Widgets { get; }
}
abstract record WidgetDefinition(
int Position,
WidgetKind Kind,
string? MetricName,
string? QueryDefinitionName,
string ConfigJson,
string? RequiredPermission,
WidgetSize Size);

7. Migration story — definition removed in module upgrade

Section titled “7. Migration story — definition removed in module upgrade”

When a module upgrade removes a DashboardDefinition that some tenants imported:

  1. The persisted Dashboard is not affected (it was deep-copied at import time). The dashboard continues to render exactly as before.
  2. Any WidgetInstance whose MetricName / QueryDefinitionName no longer resolves renders as a widget unavailable placeholder card, listing the missing reference and a “remove this widget” action. The dashboard itself stays usable; only the broken widgets degrade.
  3. The catalogue endpoint (GET /dashboards/catalog) silently stops listing the removed definition. The drift-detection UI flags imported dashboards whose SourceDefinitionName no longer exists — admin can choose to keep them as ad-hoc (clearing SourceDefinitionName / SourceDefinitionVersion) or delete them.

Rationale: the framework cannot proactively delete tenant-owned data on a code change. Graceful degradation + admin-driven cleanup matches the wider Granit principle ([feedback] no destructive framework migrations) and the regulatory baseline (GDPR data minimisation does not authorise the framework to wipe user-created records).

Alternative A — single mutable Dashboard shared across tenants

Section titled “Alternative A — single mutable Dashboard shared across tenants”

Rejected. Re-introduces the conflation. A multi-tenant SaaS where any admin’s edit affects every tenant breaks isolation entirely.

Alternative B — auto-instantiate one Dashboard per DashboardDefinition on first login

Section titled “Alternative B — auto-instantiate one Dashboard per DashboardDefinition on first login”

Rejected. Overwhelms tenants with dashboards none of them asked for. Prior art (both Odoo and Power BI App Workspaces) shows that explicit import drives adoption, auto-instantiation drives noise.

Alternative C — track-the-source dashboards (module upgrades retro-edit user copies)

Section titled “Alternative C — track-the-source dashboards (module upgrades retro-edit user copies)”

Rejected. Silent retro-edit on database update is a CMS-grade regression risk. Drift detection + manual re-sync gives admins the agency they need, while still surfacing the upgrade.

Alternative D — typed widget hierarchy (one C# class per WidgetKind) instead of ConfigJson

Section titled “Alternative D — typed widget hierarchy (one C# class per WidgetKind) instead of ConfigJson”

Rejected. Every new widget kind would force a migration, contradicting the no migrations in framework packages rule. JSON blob + per-kind validator gives the same type safety at the API layer with an additive schema. Weighed and accepted as the classic “JSON column for polymorphic config” trade-off — see EF Core 10 owned-entity guidance.

Alternative E — store widget order via a linked-list (PreviousId / NextId)

Section titled “Alternative E — store widget order via a linked-list (PreviousId / NextId)”

Rejected. Reorder operations would touch O(n) rows. A dense-ranked Position integer with periodic compaction matches the workload (a dashboard rarely exceeds 30 widgets) and keeps reorder updates to two rows.

  • Mirror of established framework patterns. DashboardDefinition follows QueryDefinition / ExportDefinition / MetricDefinition (declarative, in-code, per-module ownership, registered via DI extension method). Predictable for module authors.
  • Mirror of established UX patterns. Import + freeze + drift detection matches Granit.Templating (embedded templates can be overridden by an admin-persisted copy) and Granit.Notifications (subscription tables are populated explicitly, not auto-generated).
  • Tenant data sovereignty. Once an admin invests effort composing a dashboard, the framework treats it as tenant-owned and never silently mutates it.
  • Additive schema. JSON ConfigJson + dense-ranked Position keeps story B7 (MapWidget) and any future widget kind shippable without a migration in Granit.Dashboards.EntityFrameworkCore.
  • Domain-extensible. A new domain (IoT, observability, …) ships its own widget records against Granit.Dashboards.Abstractions without touching Granit.Analytics or vice versa. The dashboard runtime is the only horizontal piece.
  • Module upgrades are safe. Framework authors ship new versions of DashboardDefinition without fear of clobbering tenant data.
  • Drift detection becomes a first-class admin UX rather than a hidden migration.
  • Widget kinds are pluggable per story (B3, B7, …) with no schema impact.
  • The IsSystem flag gives multi-tenant SaaS hosts a clean way to impose mandatory ops dashboards without abusing tenant overrides.
  • Two types instead of one — admins/devs must remember which one they are dealing with. Mitigated by clear naming (Dashboard vs DashboardDefinition) and by shipping them in the same package family so IDE auto-complete keeps them adjacent.
  • Two new framework packages (Granit.Dashboards.Abstractions + Granit.Dashboards) rather than reusing Granit.Analytics. Marginal — the framework already absorbs the same split twice for Granit.QueryEngine and Granit.DataExchange. The runtime cost is paid only by hosts that actually expose dashboards.
  • Drift between definition and persisted copy must be surfaced in the admin UI. Story B5 (frontend composer) is responsible for this UX. Until B5 ships, drift is queryable via API but not visualised.
  • Story B4 (endpoints) must implement the deep-copy logic on import, including permission resolution per widget. This is intentional and matches the pairing invariants enforced by D1 and ADR-020.
  1. B1 (#1382) — split Granit.Dashboards.Abstractions (base types + presentation widgets: Markdown, Image, Text) and Granit.Dashboards (registry + DI). Analytics-flavoured widgets (Kpi, Chart, Table, Pivot) ship in Granit.Analytics. Architecture test enforces widget references resolve at composition time.
  2. B2 (#1383) — implement Dashboard aggregate, WidgetInstance owned entity, EF configurations, migration in Granit.Dashboards.EntityFrameworkCore. Apply ApplyGranitConventions (multi-tenant + soft-delete).
  3. B3 (#1384) — ship per-kind ConfigJson validators on the persisted side.
  4. B4 (#1385) — REST endpoints (GET /dashboards/catalog, POST /dashboards/from-definition/{name}, full CRUD on persisted dashboards) + permission filtering at render time.
  5. B5 (#1386) — frontend composer + read-mode rendering with drift indicator.
  6. B6 (#1387) — docs-site page mirroring this ADR for end users.
  7. B7 (#1404) — MapWidget (Leaflet) — additive, no schema change.