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.
Field value-kind hints
Section titled “Field value-kind hints”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 winsThe 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.
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.
Cache invalidation on related-entity writes
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 invalidationGranularity 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.
Activities
Section titled “Activities”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.Activitiesruntime (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).
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().
See also
Section titled “See also”- Query Engine — the read-side twin:
ColumnBuildervalue-kind helpers steer grid cell renderers the same wayFieldBuilder.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)