Skip to content

Extra Properties Pattern — Schema-Free Entity Extensibility

The Extra Properties pattern adds a schema-free JSON property bag to any EF Core entity, enabling applications to store custom key-value pairs without modifying the framework’s database schema. Properties that need SQL indexing or query filtering can be promoted to real SQL columns at startup.

Also known as: Property Bag, Object Extension (ABP), EAV (legacy), user_metadata (Auth0), Custom Attributes (Keycloak).

flowchart LR
    subgraph Entity["Entity (e.g., GranitUser)"]
        JSON["ExtraPropertiesJson<br/>(JSONB column)"]
        COL1["JobTitle<br/>(SQL column)"]
        COL2["IsVip<br/>(SQL column)"]
    end

    APP["Application code"] -->|SetExtraProperty| JSON
    APP -->|MapProperty&lt;T&gt;| COL1
    APP -->|MapProperty&lt;T&gt;| COL2

    subgraph Read["Read path"]
        R1["entity.GetExtraProperty('key')"] --> JSON
        R2["EF.Property&lt;bool&gt;(u, 'IsVip')"] --> COL2
    end

    subgraph Save["Save (Interceptor)"]
        S1["Mapped values → SQL columns"]
        S2["Non-mapped values → JSON"]
        S3["No duplication"]
    end
Granit.Domain
public interface IHasExtraProperties
{
string? ExtraPropertiesJson { get; set; }
}

Any entity implementing IHasExtraProperties gets automatic JSON-backed extensibility. The framework provides typed extension methods:

// Read
string? dept = entity.GetExtraProperty("Department");
bool? isVip = entity.GetExtraProperty<bool>("IsVip");
bool exists = entity.HasExtraProperty("LicenseNumber");
// Write
entity.SetExtraProperty("Department", "Engineering");
entity.SetExtraProperty("LicenseNumber", null); // removes

Every IHasExtraProperties entity stores properties in a jsonb column (PostgreSQL) or nvarchar(max) (SQL Server). No migrations needed — the column is always present.

// Any entity
public class ReferenceDataEntity : AuditedEntity, IHasExtraProperties
{
public string? ExtraPropertiesJson { get; set; }
}
// Usage
country.SetExtraProperty("Alpha3Code", "BEL");
country.SetExtraProperty("IsEuMember", "true");

Level 2 — SQL column promotion (for indexing)

Section titled “Level 2 — SQL column promotion (for indexing)”

Properties that need SQL WHERE clauses, indexes, or ORDER BY can be promoted to real EF Core Shadow Properties at startup:

// In the host application's module (ConfigureServices)
services.AddExtraPropertyMappings<GranitUser>(options =>
{
options.MapProperty<string>("JobTitle", maxLength: 128);
options.MapProperty<string>("Department", maxLength: 64);
options.MapProperty<bool>("IsVip");
});

This adds real SQL columns on the entity’s table. The application must then regenerate EF Core migrations:

Terminal window
dotnet ef migrations add AddUserExtensions --context OpenIddictDbContext

Level 3 — Sync interceptor (no duplication)

Section titled “Level 3 — Sync interceptor (no duplication)”

The ExtraPropertySyncInterceptor in Granit.Persistence ensures that promoted properties are excluded from the JSON bag at save time:

  • Mapped property set via JSON → interceptor moves value to SQL column, removes from JSON
  • Mapped property set via Shadow Property → stays in SQL column, not in JSON
  • Non-mapped property → stays in JSON only

This prevents data duplication (source of truth = SQL column for promoted props, JSON for the rest).

EntityPackageJSON column
GranitUserGranit.OpenIddictCustomAttributesJson
UserCacheEntryGranit.Identity.Federated.EntityFrameworkCoreExtraPropertiesJson
ReferenceDataEntityGranit.ReferenceData.EntityFrameworkCoreExtraPropertiesJson
NeedStrategyExample
Simple key-value, no SQL queriesJSON bag (Level 1)User preferences, UI settings
SQL WHERE, ORDER BY, indexesPromoted column (Level 2)IsVip, Department filter
Complex domain data, relationshipsCompanion EntityEmployee with Salary, HireDate
FrameworkMechanismSQL promotionSchema-free
GranitIHasExtraProperties + MapProperty<T>YesYes
ABPExtraProperties + ObjectExtensionManagerYesYes
Auth0user_metadata / app_metadataNoYes
Keycloakattributes dictionaryNoYes
Entra IDextensionAttribute1-15Yes (fixed)No (15 max)
EAV (Magento)Attribute-Value pivot tablesYesYes
FilePurpose
src/Granit/Domain/IHasExtraProperties.csInterface
src/Granit/Domain/ExtraPropertyExtensions.csGet/Set/Has extension methods
src/Granit.Persistence/ExtraProperties/ExtraPropertyMappingOptions.csMapProperty<T>() config
src/Granit.Persistence/ExtraProperties/ExtraPropertySyncInterceptor.csSave-time sync (no duplication)
src/Granit.Persistence/ExtraProperties/ExtraPropertyModelBuilderExtensions.csApplies Shadow Properties to ModelBuilder
src/Granit.Persistence/ExtraProperties/ExtraPropertyServiceCollectionExtensions.csDI registration helpers
  • Don’t store structured data in ExtraProperties — use a Companion Entity for complex types with relationships
  • Don’t query JSON with EF Core — promote to SQL column if you need WHERE
  • Don’t use ExtraProperties for security-sensitive data — the JSON column is not encrypted (use IStringEncryptionService separately)
  • Don’t duplicate — if a property is promoted to SQL, it must NOT also be in JSON (the interceptor enforces this)