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.
Quick example
Section titled “Quick example”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.
Primitives
Section titled “Primitives”| Primitive | Surfaces as | DSL |
|---|---|---|
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.
Cross-module contributions
Section titled “Cross-module contributions”Other modules graft relations and actions onto an entity they don’t own through the contributor pattern (ADR-045, ADR-048):
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.
Calendar view
Section titled “Calendar view”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.
Gallery view
Section titled “Gallery view”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— everyGalleryLayoutDescriptor.ImagePropertyresolves to aBlobReference(or nullable) property.GalleryFieldWhitelistPairingTests— every property a gallery card surfaces (image, title, subtitle) is whitelisted in the matchingQueryDefinition.GetColumns().