ExtraProperties — Entity Extensibility
IHasExtraProperties 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 ExtraPropertySyncInterceptor.
Where it lives
Section titled “Where it lives”DirectoryGranit/Domain/
- IHasExtraProperties.cs interface
- ExtraPropertyExtensions.cs Get/Set/Has helpers
DirectoryGranit.Persistence/ExtraProperties/
- ExtraPropertyMappingOptions.cs MapProperty<T>() fluent API
- ExtraPropertySyncInterceptor.cs SaveChanges interceptor
- IExtraPropertyMappingRegistry.cs mapping resolution
- ExtraPropertyModelBuilderExtensions.cs EF Core Shadow Properties
The interface
Section titled “The interface”public interface IHasExtraProperties{ string? ExtraPropertiesJson { 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 ExtraPropertyExtensions class provides typed access without requiring
manual JSON serialization:
// Set a propertyinvoice.SetExtraProperty("CostCenter", "CC-4200");invoice.SetExtraProperty("Priority", "3");
// Get as stringstring? costCenter = invoice.GetExtraProperty("CostCenter");
// Get with type parsing (uses IParsable<T>)int? priority = invoice.GetExtraProperty<int>("Priority");bool? isUrgent = invoice.GetExtraProperty<bool>("IsUrgent");
// Check existencebool hasCostCenter = invoice.HasExtraProperty("CostCenter");
// Remove (pass null)invoice.SetExtraProperty("CostCenter", null);
// Get all as dictionaryIReadOnlyDictionary<string, string> all = invoice.GetExtraProperties();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 IHasExtraProperties on any of your own entities:
public sealed class Invoice : AuditedEntity, IHasExtraProperties{ public string InvoiceNumber { get; set; } = string.Empty; public decimal Amount { get; set; }
// IHasExtraProperties public string? ExtraPropertiesJson { 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.AddExtraPropertyMappings<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 ExtraPropertySyncInterceptor
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[SetExtraProperty] --> B{Is mapped?}
B -->|No| C[JSON column]
B -->|Yes| D[Shadow Property column]
D --> E[ExtraPropertySyncInterceptor]
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.ApplyExtraPropertyMappings(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 ExtraProperties infrastructure is registered automatically when any module
calls AddExtraPropertyInfrastructure() or AddExtraPropertyMappings<T>():
// Register infrastructure (interceptor + registry) — idempotentservices.AddExtraPropertyInfrastructure();
// Register mappings for a specific entity typeservices.AddExtraPropertyMappings<Invoice>(opts =>{ opts.MapProperty<string>("CostCenter", maxLength: 20);});Architecture
Section titled “Architecture”Performance safeguards
Section titled “Performance safeguards”The ExtraPropertySyncInterceptor is designed for minimal overhead:
- Single instance: one interceptor handles all
IHasExtraPropertiesentity types - ConcurrentDictionary cache: mapping lookup happens once per entity type per app lifetime
- ChangeTracker filter: only
Added/Modifiedentries implementingIHasExtraPropertiesare inspected - Early exit: when no mappings are registered for an entity type, zero work is done
Registry
Section titled “Registry”The IExtraPropertyMappingRegistry aggregates all mappings by entity type. It is
populated at startup by ExtraPropertyMappingRegistryInitializer (an IHostedService)
from all registered IConfigureExtraPropertyRegistry contributors.
See also
Section titled “See also”- Reference Data — uses ExtraProperties for dynamic reference data types
- Persistence — isolated DbContext pattern and EF Core conventions
- Module System — DependsOn and lifecycle hooks