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.

A form field can carry a semantic value-kind — the same display-type vocabulary a query column uses (ValueKind, namespace Granit.QueryEngine). On the write side it lets the frontend pick a better default edit input when the field’s Component was left at its CLR-type default:

.Form("default", f => f
.Section("billing", s => s
.Field(p => p.Website, x => x.ValueKind(ValueKind.Url)) // → url input
.Field(p => p.Total, x => x.ValueKind(ValueKind.Currency)) // → money input
.Field(p => p.PayTerms, x => x.Component("custom:invoicing-terms")))) // explicit wins

The hint is advisory and orthogonal to validation, which keeps flowing from the request validators / OpenAPI schema. Precedence on the frontend is unambiguous: an explicit Component(...) always wins; value-kind only steers the default when no component was declared. It surfaces on the form manifest as FieldDescriptor.valueKind (null when no hint is set). See ADR-041 for the closed component catalog value-kind steers within.

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.

Section titled “Cache invalidation on related-entity writes”

Smart-button counters are cached for 30 seconds (configurable via EntitiesEndpointsOptions.RelationAggregatesCacheTtl). When a related entity implements IEmitEntityLifecycleEvents, the framework wires an event-driven invalidator so writes evict the cached counters surgically — no need to wait for the sliding TTL.

Wire it in the host’s composition root after every entity definition + relation contributor has been registered:

services
.AddEntityDefinition<Party, PartyEntityDefinition>()
.AddEntityRelationContribution<InvoicesOnPartyRelationContribution>()
.AddGranitEntitiesEntityFrameworkCore() // calendar runners
.AddGranitEntitiesRelationAggregateInvalidation(); // smart-button invalidation

Granularity is per (source entity, relation name): when any Invoice row changes, every cached Party.invoices counter is dropped in one RemoveByTagAsync call. The eviction tag scheme — declared on RelationAggregateCacheKey.EvictionTagForRelation(sourceEntityName, relationName) — is coarser than per-(source row id) because RelationDescriptor does not carry an executable foreign-key predicate yet; cached entries also carry the finer per-(source, id) tag for future per-row invalidation when the source row itself changes.

Batched invalidation for host-side bulk operations

Section titled “Batched invalidation for host-side bulk operations”

Hosts that mutate many rows in one operation (CSV import, batch archive, mass re-assignment) should emit a single EntityBulkUpdatedEvent<TEntity> carrying the affected rows instead of N per-row events:

await _localEventBus.PublishAsync(
new EntityBulkUpdatedEvent<Invoice>(updatedInvoices),
cancellationToken);

The same RelationAggregateCacheInvalidator<TRelated> consumes the bulk event and collapses the eviction to one RemoveByTagAsync call per unique tag, regardless of batch size. A 1000-row import emits one cache eviction sweep, not 1000 (story #1794).

Per-row events and bulk events are alternatives, not complements — emit one or the other for the same set of rows, never both.

Opt an entity into hosting cross-entity to-dos (calls, meetings, follow-up tasks…) per ADR-046 by calling .Activities() on the builder:

public sealed class CustomerEntityDefinition : EntityDefinition<Customer>
{
protected override void Configure(EntityDefinitionBuilder<Customer> b)
{
b.PermissionGroup("Customers");
b.Activities(a => a
.AllowedTypes("Call", "Meeting", "Email", "ToDo")
.DefaultAssignee(c => c.AccountManagerUserId));
b.Detail("default", d => d.SidePanel(p => p.Activities()));
}
}

The manifest section entity.activities carries the resolved allowedTypes (filtered against the runtime IActivityRegistry) plus the optional defaultAssignee property name. Activity types listed in AllowedTypes(...) that are not registered with the framework are silently dropped from the manifest — entities can safely opt into types contributed by optional modules (e.g. "Quote" from Granit.Sales) without breaking when those modules are not loaded.

The activities section is omitted entirely when:

  • the entity does not call .Activities(), or
  • the host has not loaded the Granit.Activities runtime (AddGranitActivities()) — without it the registry is unavailable and the catalog cannot be validated.

Detail.SidePanel.Activities() is the standard side-panel hook that surfaces the React <EntityActivitiesPanel /> component on the detail view; it works independently of the .Activities() opt-in (the panel renders an empty state when the entity does not own activities).

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().
  • Query Engine — the read-side twin: ColumnBuilder value-kind helpers steer grid cell renderers the same way FieldBuilder.ValueKind() steers form inputs
  • 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
  • ADR-053 — layer 1 customization (reorder, regroup, hide)