Skip to content

Entity Definition

EntityDefinition<T> declares the UI surface of one aggregate. The manifest aggregator serves it to the frontend, which renders forms, detail views, lists, kanban boards, smart-button relations, and action buttons — all from the same descriptor, with no per-entity React code. One source of truth for the structure of a CRUD-style admin screen.

The primitive sits at the heart of the three-tier metadata architecture (ADR-040): the framework declares the compiled layer here, tenants overlay tier-B customizations (layout reordering), and users save tier-C personal views via EntityView.

using Granit.Entities;
using Granit.Entities.Layouts;
using Granit.Parties.Domain;
public sealed class PartyEntityDefinition : EntityDefinition<Party>
{
public override string Name => "Granit.Parties.Party";
protected override void Configure(EntityDefinitionBuilder<Party> builder) =>
builder
.DisplayKey("Parties:Entity.Party")
.Icon("users")
.PermissionGroup("Parties.Parties")
.DisplayProperty(p => p.Name)
.Query<PartyQueryDefinition>() // list/grid backing
.Export<PartyExportDefinition>() // CSV/XLSX
.Metric<PartyCountMetricDefinition>() // KPI on detail header
.Form("default", f => f
.Section("identity", s => s
.Field(p => p.Name)
.Field(p => p.Status))
.OwnedCollectionSection<PartyEmail>("emails", p => p.Emails, s => s
.ItemDisplayProperty(e => e.Address)
.ItemField(e => e.Address)
.ItemField(e => e.IsPrimary)))
.Detail("default", d =>
{
d.Section("overview", s => s.InheritsFromForm("default"));
d.SidePanel.Audit().Timeline();
})
.KanbanView<PartyStatus>(k => k
.GroupBy(p => p.Status)
.Card(c => c.Title(p => p.Name).Field(p => p.Kind))
.Column(PartyStatus.Active, c => c.Color(KanbanColor.Green))
.Column(PartyStatus.Archived, c => c.Color(KanbanColor.Neutral).Hidden()));
}

Register via services.AddEntityDefinition<Party, PartyEntityDefinition>(). The boot-time integrity check (IntegrityCheckRunner) walks every EntityDefinitionDescriptor and asserts each cited QueryDefinition / ExportDefinition / MetricDefinition is resolvable through DI — host startup fails fast on a typo or missing registration.

PrimitiveSurfaces asDSL
Form("name", ...)Edit form variant (multiple per entity — default, quick, …).Section(...).Field(...), .OwnedCollectionSection<TItem>(...)
Detail("name", ...)Read-only detail view (variants like Form).Section(...).InheritsFromForm("default"), .SidePanel.Audit().Timeline()
Query<T> / Export<T> / Metric<T> / Dashboard<T>Wire references to declarative collection primitives.Query<MyQueryDef>(), .Metric<MyMetricDef>()
HasMany<TRelated> / HasOne<TRelated>Smart-button / tab / sidebar / inline-chips relation on detail.HasMany(p => p.Invoices, r => r.DisplayAs(RelationDisplay.SmartButton).Aggregate(a => a.Count()))
KanbanView<TGroupBy>(...)Kanban board grouped by an enum.GroupBy(p => p.Status).Card(...).Column(...)
CalendarView(...)Time-axis grid (month / week / day) keyed on a date property.StartField(p => p.StartsAt).EndField(p => p.EndsAt).TitleField(p => p.Subject).ColorBy(p => p.Status)
GalleryView(...)Image-card grid keyed on a BlobReference property.ImageField(p => p.Avatar).TitleField(p => p.DisplayName).SubtitleField(p => p.Email).CardSize(GalleryCardSize.Medium)
Action("name", ...)Detail-header / row / kanban-tile button.ApiCall("POST", "/api/.../finalize").RequiresPermission("...")

Per-field defense in depth: RequiresPermission(...) drops the field from the manifest payload when the user does not hold the permission (ADR-040 §6 and story #1549) — never just hidden, always absent. Same rule applies to sections, side panels, relations, and actions.

Other modules graft relations and actions onto an entity they don’t own through the contributor pattern (ADR-045, ADR-048):

Granit.Invoicing.Endpoints/Relations/InvoicesOnPartyRelationContribution.cs
internal sealed class InvoicesOnPartyRelationContribution : IEntityRelationContributor
{
public void Contribute(IEntityRelationContributionContext context) =>
context.AddRelation<Party, Invoice>(
name: "invoices",
targetEntityName: "Granit.Invoicing.Invoice",
r => r.DisplayAs(RelationDisplay.SmartButton)
.RequiresPermission(InvoicingPermissions.Invoices.Read)
.Aggregate(a => a.Count().Sum(i => i.Total))
.OnKanbanCard());
}

Register with services.AddEntityRelationContribution<InvoicesOnPartyRelationContribution>(). The merger (EntityRelationMerger) folds contributions into the matching descriptor at boot — name conflicts resolve in favour of the entity’s intra-module declarations.

IEntityActionContributor follows the symmetric pattern for cross-module action grafts.

A calendar layout reads its rows from a dedicated range-query endpoint that the framework mounts alongside the manifest:

GET /api/entities/{name}/calendar?from=2026-05-01T00:00:00Z&to=2026-05-31T23:59:59Z[&calendar=primary]

The handler composes an overlap predicate (Start <= to AND from <= (End ?? Start)) on top of the entity’s IQueryable<T>, projects each row into CalendarItemResponse (Id + Start + End? + Title + Color?), and returns the list. The window is capped at 366 days; an inverted window (To < From) is rejected with 400. Responses are FusionCache-backed (1-minute sliding TTL keyed on (entity, calendar, from, to, user-perms-hash, culture)) and carry a strong ETag so polling clients save bandwidth via 304 Not Modified short-circuits. Per-entity eviction tags drop every cached window for the entity when one of its rows is created, updated, or deleted (the framework wires this automatically for entities implementing IEmitEntityLifecycleEvents).

The executor lives in the Granit.Entities.EntityFrameworkCore companion package — register it in your composition root after every AddEntityDefinition<T, TDef>() call so the per-entity dispatcher picks up every calendar layout you declared:

builder.Services
.AddEntityDefinition<Meeting, MeetingEntityDefinition>()
.AddEntityDefinition<Reservation, ReservationEntityDefinition>();
// AFTER every AddEntityDefinition — the loop reads the registered descriptors
// at this moment to wire one closed-generic CalendarRangeRunner<T> per entity
// that exposes a CalendarLayoutDescriptor.
builder.Services.AddGranitEntitiesEntityFrameworkCore();

Without the companion package, the calendar endpoint stays mounted but returns an empty list (the framework’s NullCalendarRangeService default). See the view catalog ADR for the full range-endpoint contract.

A gallery layout renders an image-card grid. The ImageField selector is typed to BlobReference? (Granit.Domain.ValueObjects.BlobReference) — the framework’s opaque reference for blob-storage objects. The renderer resolves each card’s preview to a pre-signed URL via the host’s Granit.BlobStorage download endpoint, which also enforces tenant isolation:

public sealed class Photo : Entity
{
public BlobReference? Thumbnail { get; private set; }
public string Caption { get; private set; } = string.Empty;
public string Author { get; private set; } = string.Empty;
}
public sealed class PhotoEntityDefinition : EntityDefinition<Photo>
{
public override string Name => "Granit.Photos.Photo";
protected override void Configure(EntityDefinitionBuilder<Photo> builder) =>
builder
.DisplayProperty(p => p.Caption)
.Query<PhotoQueryDefinition>()
.GalleryView(g => g
.ImageField(p => p.Thumbnail)
.TitleField(p => p.Caption)
.SubtitleField(p => p.Author)
.CardSize(GalleryCardSize.Medium));
}

The DSL signature rejects non-BlobReference selectors at compile time. BlobReference is a SingleValueObject<string> with implicit string ↔ BlobReference operators (EF Core converters auto-applied via ApplyGranitConventions — same column type, no migration needed; JSON-serialised as a plain string via SingleValueObjectJsonConverterFactory, so wire format with HTTP clients and Wolverine outboxes stays identical to the previous string shape).

Two architecture tests keep the contract honest at boot time:

  • GalleryImageFieldTypeTests — every GalleryLayoutDescriptor.ImageProperty resolves to a BlobReference (or nullable) property.
  • GalleryFieldWhitelistPairingTests — every property a gallery card surfaces (image, title, subtitle) is whitelisted in the matching QueryDefinition.GetColumns().
  • Entity View — user-saved view configurations (filters, sort, columns, kanban overlays)
  • Workspace — navigation tree that hosts entities
  • ADR-040 — three-tier metadata architecture
  • ADR-041 — component catalog
  • ADR-042 — view catalog
  • ADR-045 — IoC contributor pattern
  • ADR-048 — cross-module relations