Metadata — Entity Extensibility
IHasMetadata is a domain interface that adds a JSON property bag to any
entity. Applications can attach arbitrary key-value pairs at runtime without
schema changes. For properties that need SQL indexes or query filtering, the
MapProperty<T>() API promotes them to real EF Core Shadow Properties — stored
in dedicated columns, automatically synchronized by the MetadataSyncInterceptor.
Where it lives
Section titled “Where it lives”DirectoryGranit/Domain/
- IHasMetadata.cs interface
- MetadataExtensions.cs Get/Set/Has helpers
DirectoryGranit.Persistence/Metadata/
- MetadataMappingOptions.cs MapProperty<T>() fluent API
- MetadataSyncInterceptor.cs SaveChanges interceptor
- IMetadataMappingRegistry.cs mapping resolution
- MetadataModelBuilderExtensions.cs EF Core Shadow Properties
The interface
Section titled “The interface”public interface IHasMetadata{ string? MetadataJson { get; set; }}Any entity implementing this interface gets a jsonb column (PostgreSQL) or
nvarchar(max) (SQL Server) storing a Dictionary<string, string> as JSON.
Reading and writing properties
Section titled “Reading and writing properties”The MetadataExtensions class provides typed access without requiring
manual JSON serialization:
// Set a propertyinvoice.SetMetadataValue("CostCenter", "CC-4200");invoice.SetMetadataValue("Priority", "3");
// Get as stringstring? costCenter = invoice.GetMetadataValue("CostCenter");
// Get with type parsing (uses IParsable<T>)int? priority = invoice.GetMetadataValue<int>("Priority");bool? isUrgent = invoice.GetMetadataValue<bool>("IsUrgent");
// Check existencebool hasCostCenter = invoice.HasMetadataValue("CostCenter");
// Remove (pass null)invoice.SetMetadataValue("CostCenter", null);
// Get all as dictionaryIReadOnlyDictionary<string, string> all = invoice.GetMetadata();Which entities use it
Section titled “Which entities use it”| Entity | Package | Notes |
|---|---|---|
GranitUser | Granit.OpenIddict | Explicit implementation mapping to CustomAttributesJson |
ReferenceDataEntity | Granit.ReferenceData | All reference data types inherit it |
UserCacheEntry | Granit.Identity.Federated.EntityFrameworkCore | Federated identity cache |
You can implement IHasMetadata on any of your own entities:
public sealed class Invoice : AuditedEntity, IHasMetadata{ public string InvoiceNumber { get; set; } = string.Empty; public decimal Amount { get; set; }
// IHasMetadata public string? MetadataJson { get; set; }}Promoting properties to SQL columns
Section titled “Promoting properties to SQL columns”Properties stored in the JSON bag cannot be indexed or filtered at SQL level. When you need query performance, promote them to real EF Core Shadow Properties:
services.AddMetadataMappings<Invoice>(opts =>{ opts.MapProperty<string>("CostCenter", maxLength: 20, isRequired: true); opts.MapProperty<int>("Priority"); opts.MapProperty<bool>("IsUrgent", isFilterable: true);});This creates real SQL columns on the entity’s table. The MetadataSyncInterceptor
handles synchronization at save time:
- On save: reads the JSON bag, moves mapped values to their Shadow Properties
- Removes mapped keys from JSON: prevents data duplication
- SQL columns are the source of truth for mapped properties
flowchart LR
A[SetMetadataValue] --> B{Is mapped?}
B -->|No| C[JSON column]
B -->|Yes| D[Shadow Property column]
D --> E[MetadataSyncInterceptor]
E --> F[Removes from JSON]
EF Core configuration
Section titled “EF Core configuration”Apply Shadow Property mappings in your DbContext:
protected override void OnModelCreating(ModelBuilder modelBuilder){ base.OnModelCreating(modelBuilder);
// Apply Shadow Properties for Invoice extra properties modelBuilder.ApplyMetadataMappings(invoiceExtensionOptions);
modelBuilder.ApplyGranitConventions(currentTenant, dataFilter);}QueryEngine Shadow Properties
Section titled “QueryEngine Shadow Properties”Shadow Properties declared with isFilterable: true or isSortable: true integrate
with Granit.QueryEngine via the ShadowColumn() API on QueryDefinitionBuilder:
// In a QueryDefinitionbuilder.ShadowColumn<string>("CostCenter", c => c.Filterable().Sortable());builder.ShadowColumn<bool>("IsUrgent", c => c.Filterable());This generates EF.Property<T>(entity, "CostCenter") expressions for SQL-level
filtering and sorting — transparently handled by FilterExpressionBuilder and
QueryableSortExtensions.
DI registration
Section titled “DI registration”The Metadata infrastructure is registered automatically when any module
calls AddMetadataInfrastructure() or AddMetadataMappings<T>():
// Register infrastructure (interceptor + registry) — idempotentservices.AddMetadataInfrastructure();
// Register mappings for a specific entity typeservices.AddMetadataMappings<Invoice>(opts =>{ opts.MapProperty<string>("CostCenter", maxLength: 20);});Architecture
Section titled “Architecture”Performance safeguards
Section titled “Performance safeguards”The MetadataSyncInterceptor is designed for minimal overhead:
- Single instance: one interceptor handles all
IHasMetadataentity types - ConcurrentDictionary cache: mapping lookup happens once per entity type per app lifetime
- ChangeTracker filter: only
Added/Modifiedentries implementingIHasMetadataare inspected - Early exit: when no mappings are registered for an entity type, zero work is done
Registry
Section titled “Registry”The IMetadataMappingRegistry aggregates all mappings by entity type. It is
populated at startup by MetadataMappingRegistryInitializer (an IHostedService)
from all registered IConfigureMetadataRegistry contributors.
See also
Section titled “See also”- Reference Data — uses Metadata for dynamic reference data types
- Persistence — isolated DbContext pattern and EF Core conventions
- Module System — DependsOn and lifecycle hooks