Module
Runtime-extensible custom fields.
Core.Properties enables any aggregate to support user-defined custom fields at runtime without schema changes. Properties are stored as JSONB, support 14 field types, and come with admin API, UI components, and automatic type discovery.
Overview
Dynamic Properties allow end users to add custom fields to any entity at runtime. A Contact can have a 'phone_number' today and a 'loyalty_tier' tomorrow — no database migration needed.
The system revolves around three concepts: PropertyDefinition (what the field looks like), PropertyGroup (how fields are organized), and IHasDynamicProperties (which entities support custom fields). Values are stored as strings in a JSONB column.
| Package | Purpose | Key Contents |
|---|---|---|
| Sillium.Core.Properties | Domain, application, infrastructure | PropertyDefinition, PropertyGroup, CQRS commands/queries, EF configurations, type discovery |
| Sillium.Core.Properties.Api | REST API endpoints | Definition and group CRUD endpoints with authorization policies |
| Sillium.Core.Properties.Components | Blazor UI components | DynamicPropertyField, DynamicPropertyPanel, PropertyEditor |
Property Types
The PropertyType enum defines 14 field types. Each type maps to a specific input component in the UI and determines validation behavior.
| Type | Value | Rendered As | Description |
|---|---|---|---|
| SingleLineText | 1 | TextInput | Short text input |
| MultiLineText | 2 | TextArea | Multi-line text area |
| Number | 3 | NumberInput | Numeric input (supports PositiveNumbersOnly) |
| SingleCheckbox | 4 | Checkbox | Boolean toggle |
| MultipleCheckboxes | 5 | Checkbox[] | Multiple selection (stored as JSON array) |
| RadioSelect | 6 | RadioGroup | Single selection from options |
| DropdownSelect | 7 | SelectInput | Single selection via dropdown |
| RichText | 8 | TextArea | Rich text area (6 rows) |
| PhoneNumber | 9 | TextInput (tel) | Phone number input (tel type) |
| EmailAddress | 10 | TextInput (email) | Email address input (email type) |
| DatePicker | 11 | DateInput | Date selection (stored as yyyy-MM-dd) |
| DateTimePicker | 12 | DateTimeInput | Date and time (stored as ISO 8601) |
| File | 13 | FileUpload | File upload |
| Url | 15 | TextInput (url) | URL input (url type) |
String Storage
All property values are stored as strings in a Dictionary<string, string?> regardless of their PropertyType. Type conversion happens at the serialization boundary. This keeps the storage schema simple and avoids migration issues when property types change.
Entity Integration
To make an aggregate support dynamic properties, implement the IHasDynamicProperties interface and configure the EF Core mapping.
IHasDynamicProperties Interface
The interface defines the contract for reading and writing dynamic property values.
Show code
Copy code
public interface IHasDynamicProperties
{
IReadOnlyDictionary<string, string?> DynamicProperties { get; }
void SetProperty(string jsonName, string? value);
string? GetProperty(string jsonName);
T? GetProperty<T>(string jsonName);
bool HasProperty(string jsonName);
void RemoveProperty(string jsonName);
}Implementation Example
Add a private Dictionary backing field and implement the interface methods.
Show code
Copy code
public class Contact : AggregateRoot<ContactId>, IHasDynamicProperties
{
private readonly Dictionary<string, string?> _dynamicProperties = new();
public string Name { get; private set; }
public string Email { get; private set; }
// IHasDynamicProperties implementation
public IReadOnlyDictionary<string, string?> DynamicProperties
=> _dynamicProperties;
public void SetProperty(string jsonName, string? value)
=> _dynamicProperties[jsonName] = value;
public string? GetProperty(string jsonName)
=> _dynamicProperties.GetValueOrDefault(jsonName);
public T? GetProperty<T>(string jsonName) { /* type conversion */ }
public bool HasProperty(string jsonName)
=> _dynamicProperties.ContainsKey(jsonName);
public void RemoveProperty(string jsonName)
=> _dynamicProperties.Remove(jsonName);
}EF Core Configuration
Use the extension method to configure the JSONB column mapping.
JSONB Column
ConfigureDynamicProperties maps the private _dynamicProperties field to a PostgreSQL JSONB column named 'PropertiesData'. It configures JSON serialization and a ValueComparer for proper EF Core change tracking.
Show code
Copy code
// In your EF Core entity configuration
public class ContactConfiguration : IEntityTypeConfiguration<Contact>
{
public void Configure(EntityTypeBuilder<Contact> builder)
{
builder.ToTable("Contacts");
builder.HasKey(x => x.Id);
// Maps _dynamicProperties to JSONB column "PropertiesData"
builder.ConfigureDynamicProperties<Contact>();
}
}Setup
Register Dynamic Properties services and map the admin API endpoints.
Service Registration
AddDynamicProperties registers type discovery, CQRS handlers, and authorization policies.
Show code
Copy code
// Program.cs — Register Dynamic Properties
services.AddDynamicProperties(options =>
{
// Assembly containing your domain aggregates
options.DomainAssembly = typeof(Contact).Assembly;
});Endpoint Mapping
Map admin endpoints for property definition and group management.
Show code
Copy code
// Program.cs — Map admin endpoints
app.MapGroup("/api/admin")
.RequireAuthorization()
.MapDynamicPropertyEndpoints();Auto-Discovery
When you provide DomainAssembly, the system automatically scans for classes implementing IHasDynamicProperties and converts their PascalCase names to snake_case aggregate type identifiers.
| Class Name | Aggregate Type |
|---|---|
| Contact | contact |
| Company | company |
| CompanyAccount | company_account |
Domain Model
The system uses two aggregate roots: PropertyDefinition describes a single custom field, and PropertyGroup organizes definitions into logical sections for UI display.
PropertyDefinition
| Field | Type | Description |
|---|---|---|
| Id | PropertyId | Unique identifier |
| AggregateType | PropertyAggregateType | Target aggregate (e.g., 'contact', 'company') |
| GroupId | PropertyGroupId | Assigned PropertyGroup for UI grouping |
| DisplayName | string | User-facing field label (max 200 chars) |
| JsonName | PropertyJsonName | Immutable snake_case storage key (unique per aggregate type) |
| FieldType | PropertyType | One of 14 PropertyType values |
| FieldConfiguration | PropertyFieldConfiguration | Type-specific options and input rules (JSONB) |
| IsUnique | bool | Enforces value uniqueness across instances |
| IsDefault | bool | Built-in property — cannot be deleted |
| IsDeleted | bool | Soft-delete flag via ISoftDeletable |
PropertyGroup
PropertyGroup has Id, AggregateType, Name, Description, and IsDeleted. Groups cannot be deleted if they still have active property definitions assigned.
Immutable JsonName
The JsonName of a PropertyDefinition cannot be changed after creation. It serves as the dictionary key in the JSONB storage. Allowing renames would break all existing stored values.
Soft-Delete
Both PropertyDefinition and PropertyGroup implement ISoftDeletable. Deleted items are excluded from queries by default via EF Core global query filters. Use includeDeleted=true to see them. Built-in properties (IsDefault=true) cannot be deleted.
API Reference
All endpoints are mapped under the configured admin route group and require authorization.
Property Definitions
| Method | Path | Description |
|---|---|---|
| GET | /property-definitions | List definitions (filters: aggregateType, groupId, includeDeleted) |
| GET | /property-definitions/{id} | Get a single definition by ID |
| POST | /property-definitions | Create a new property definition |
| PUT | /property-definitions/{id} | Update display name, description, group, field type, uniqueness |
| DELETE | /property-definitions/{id} | Soft-delete a definition (fails for IsDefault) |
| POST | /property-definitions/{id}/restore | Restore a soft-deleted definition |
Property Groups
| Method | Path | Description |
|---|---|---|
| GET | /property-groups | List groups (filters: aggregateType, includeDeleted) |
| GET | /property-groups/{id} | Get a single group by ID |
| POST | /property-groups | Create a new property group |
| PUT | /property-groups/{id} | Update name and description |
| DELETE | /property-groups/{id} | Soft-delete a group (fails if definitions assigned) |
| POST | /property-groups/{id}/restore | Restore a soft-deleted group |
Authorization Policies
| Policy | Protects |
|---|---|
| DefinitionsRead | GET /property-definitions |
| DefinitionsCreate | POST /property-definitions |
| DefinitionsUpdate | PUT /property-definitions/{id} |
| DefinitionsDelete | DELETE /property-definitions/{id} |
| GroupsRead | GET /property-groups |
| GroupsCreate | POST /property-groups |
| GroupsUpdate | PUT /property-groups/{id} |
| GroupsDelete | DELETE /property-groups/{id} |
UI Components
Three Blazor components cover the full property lifecycle: displaying fields, showing grouped panels, and administering definitions.
| Component | Purpose | Key Parameters |
|---|---|---|
| DynamicPropertyField | Renders a single property input based on PropertyType | PropertyType, DisplayName, Value, ValueChanged, Options, PositiveNumbersOnly |
| DynamicPropertyPanel | Renders all properties grouped by PropertyGroup | Definitions, Groups, Values, OnValueChanged, TwoColumns |
| PropertyEditor | Admin UI for managing definitions and groups | AggregateTypes (from IDynamicPropertyAggregateTypeProvider) |
Panel Usage
Embed DynamicPropertyPanel in your entity edit page to show all custom fields.
Show code
Copy code
<DynamicPropertyPanel
Definitions="@_definitions"
Groups="@_groups"
Values="@_entity.DynamicProperties"
OnValueChanged="HandlePropertyChanged"
TwoColumns="true"
Variant="Variant.Outlined"
Color="Color.Light" />
@code {
private void HandlePropertyChanged(PropertyValueChange change)
{
_entity.SetProperty(change.JsonName, change.Value);
}
}Query Filtering
When querying entities with dynamic properties via API, the system distinguishes property filters from built-in parameters by naming convention.
| Convention | Example | Meaning |
|---|---|---|
| snake_case | ?phone_number=+49... | Filters by dynamic property value |
| camelCase | ?page=1&pageSize=25 | Built-in pagination/sorting parameter |
Mixed Filtering
Combine property filters with built-in parameters in a single request.
Show code
Copy code
// Filter contacts by dynamic property "phone_number"
// and built-in pagination parameters
GET /api/contacts?phone_number=+49123&city=berlin&page=1&pageSize=25
// snake_case params → property filters (phone_number, city)
// camelCase params → built-in (page, pageSize)Automatic Type Conversion
PropertyValueConverter automatically converts stored values when a PropertyDefinition's FieldType changes. For example, if a 'priority' field changes from SingleLineText to Number, existing string values are converted to numeric format where possible.