Skip to content

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.

  • 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
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.

The ExtraPropertyExtensions class provides typed access without requiring manual JSON serialization:

// Set a property
invoice.SetExtraProperty("CostCenter", "CC-4200");
invoice.SetExtraProperty("Priority", "3");
// Get as string
string? costCenter = invoice.GetExtraProperty("CostCenter");
// Get with type parsing (uses IParsable<T>)
int? priority = invoice.GetExtraProperty<int>("Priority");
bool? isUrgent = invoice.GetExtraProperty<bool>("IsUrgent");
// Check existence
bool hasCostCenter = invoice.HasExtraProperty("CostCenter");
// Remove (pass null)
invoice.SetExtraProperty("CostCenter", null);
// Get all as dictionary
IReadOnlyDictionary<string, string> all = invoice.GetExtraProperties();
EntityPackageNotes
GranitUserGranit.OpenIddictExplicit implementation mapping to CustomAttributesJson
ReferenceDataEntityGranit.ReferenceDataAll reference data types inherit it
UserCacheEntryGranit.Identity.Federated.EntityFrameworkCoreFederated 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; }
}

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:

  1. On save: reads the JSON bag, moves mapped values to their Shadow Properties
  2. Removes mapped keys from JSON: prevents data duplication
  3. 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]

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);
}

Shadow Properties declared with isFilterable: true or isSortable: true integrate with Granit.QueryEngine via the ShadowColumn() API on QueryDefinitionBuilder:

// In a QueryDefinition
builder.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.

The ExtraProperties infrastructure is registered automatically when any module calls AddExtraPropertyInfrastructure() or AddExtraPropertyMappings<T>():

// Register infrastructure (interceptor + registry) — idempotent
services.AddExtraPropertyInfrastructure();
// Register mappings for a specific entity type
services.AddExtraPropertyMappings<Invoice>(opts =>
{
opts.MapProperty<string>("CostCenter", maxLength: 20);
});

The ExtraPropertySyncInterceptor is designed for minimal overhead:

  • Single instance: one interceptor handles all IHasExtraProperties entity types
  • ConcurrentDictionary cache: mapping lookup happens once per entity type per app lifetime
  • ChangeTracker filter: only Added/Modified entries implementing IHasExtraProperties are inspected
  • Early exit: when no mappings are registered for an entity type, zero work is done

The IExtraPropertyMappingRegistry aggregates all mappings by entity type. It is populated at startup by ExtraPropertyMappingRegistryInitializer (an IHostedService) from all registered IConfigureExtraPropertyRegistry contributors.