Benevia Core Client & Blazor Library
The Core Idea: Metadata-Driven UI
Every [ApiEntity] in the Benevia platform exposes its structure through a metadata endpoint (/api/entitymetadata). This metadata includes:
- Labels — derived from
[Property<T>("Primary Email")]on the server model - Data types —
Email,Phone,Date,ProperNoun,FullAddress, etc. - Validation rules —
Required,MaxLength,MinLength - Navigation relationships — references and collections to other entities
- Read-only flags — properties that cannot be edited
The Benevia.Core.Client library loads and caches this metadata. Benevia.Core.Blazor uses it to automatically select the right control, label, and validation for every property — so you never hand-pick a text field vs date picker vs email input.
Traditional approach
@* You pick the control, write the label, configure validation manually *@
<MudTextField Label="Primary Email" @bind-Value="customer.PrimaryEmail"
InputType="InputType.Email" MaxLength="100" />
<MudTextField Label="Primary Phone" @bind-Value="customer.PrimaryPhone" />
<MudDatePicker Label="Birth Date" @bind-Date="customer.BirthDate" />
Every page repeats these decisions. Labels drift. Controls are inconsistent. Validation rules live in two places.
Metadata-driven approach
<PropertyComponent Path="PrimaryContact.PrimaryEmail" />
<PropertyComponent Path="PrimaryContact.PrimaryPhone" />
<PropertyComponent Path="BirthDate" />
The framework reads the server metadata and knows that PrimaryEmail has data type Email, label "Primary Email", and a max length. It renders the correct editor in edit mode and a clickable mailto link in view mode. Labels and validation are defined once on the server model and flow everywhere automatically.
Quick Example: A Customer Page
Here is a minimal Blazor page that edits a Customer entity using the metadata-driven approach.
CustomerPage.razor
@page "/customers/{CustomerGuid:guid}"
@page "/customers/new"
@using Benevia.Core.Blazor
@using Benevia.Core.Blazor.DynamicComponents
@using Benevia.Core.Client.DataSets
@using Benevia.Core.Client.GraphDefinitions
@using Benevia.Core.Client.GraphDefinitions.Builders
@using Benevia.Core.Client.OData
@using MudBlazor
@inject IODataClient OData
<DataGraph Definition="@Graph" DataSet="@_dataSet" EntityGuid="@CustomerGuid">
<MudStack Spacing="4">
@* Profile *@
<MudPaper Class="pa-4 rounded-lg" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-4">Profile</MudText>
<MudGrid Spacing="3">
<MudItem xs="12" sm="6">
<PropertyComponent Path="Id" />
</MudItem>
<MudItem xs="12" sm="6">
<PropertyComponent Path="PrimaryContact.FirstName" />
</MudItem>
<MudItem xs="12" sm="6">
<PropertyComponent Path="PrimaryContact.LastName" />
</MudItem>
<MudItem xs="12" sm="6">
<PropertyComponent Path="BillingCustomer" />
</MudItem>
</MudGrid>
</MudPaper>
@* Contact *@
<MudPaper Class="pa-4 rounded-lg" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-4">Contact</MudText>
<MudGrid Spacing="3">
<MudItem xs="12" sm="6">
<PropertyComponent Path="PrimaryContact.PrimaryEmail" />
</MudItem>
<MudItem xs="12" sm="6">
<PropertyComponent Path="PrimaryContact.PrimaryPhone" />
</MudItem>
</MudGrid>
</MudPaper>
@* Mailing Address *@
<MudPaper Class="pa-4 rounded-lg" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-4">Mailing Address</MudText>
<MudGrid Spacing="3">
<MudItem xs="12">
<PropertyComponent Path="PrimaryContact.MailingAddress.Street" />
</MudItem>
<MudItem xs="12" sm="6">
<PropertyComponent Path="PrimaryContact.MailingAddress.City" />
</MudItem>
<MudItem xs="12" sm="3">
<PropertyComponent Path="PrimaryContact.MailingAddress.State" />
</MudItem>
<MudItem xs="12" sm="3">
<PropertyComponent Path="PrimaryContact.MailingAddress.PostalCode" />
</MudItem>
<MudItem xs="12" sm="6">
<PropertyComponent Path="PrimaryContact.MailingAddress.Country" />
</MudItem>
</MudGrid>
</MudPaper>
</MudStack>
</DataGraph>
@code {
[Parameter] public Guid? CustomerGuid { get; set; }
private DataSet _dataSet = null!;
private static readonly GraphDefinition Graph = new GraphBuilder("Customer")
.Property("Id")
.Reference("BillingCustomer")
.Navigation("PrimaryContact", pc => pc
.Properties(["FirstName","LastName","PrimaryEmail"])
.Property("PrimaryPhone")
.Navigation("MailingAddress", ma => ma
.Property("Street")
.Property("City")
.Property("State")
.Property("PostalCode")
.Reference("Country")))
.Build();
protected override void OnInitialized()
=> _dataSet = new DataSet(OData, Graph);
}
What happens at runtime
DataGraphasksIModelProviderfor theCustomerentity metadata — types, labels, and validation rules for every property in the graph.- If
CustomerGuidhas a value,DataGraphcallsDataSet.LoadAsync(guid)which sends a GET request with the OData$select/$expandquery generated from the graph. IfCustomerGuidis null, it callsDataSet.CreateAsync()to POST a new Customer. - Each
PropertyComponentreads its property's metadata from the cascaded context. It finds, for example, thatPrimaryContact.PrimaryEmailhas data typeEmailand label"Primary email". It resolves the appropriate editor component (StringPropertyEditin edit mode) and passes it the label and validation config. - When the user changes a value,
PropertyComponentcallsDataSet.SetAsync("PrimaryContact.PrimaryEmail", newValue). The DataSet optimistically updates the local state, sends a PATCH to the server, and replaces local state with the server's response — including any computed values, read-only status changes, and validation messages.
Why this matters
| Benefit | How |
|---|---|
| No label duplication | Labels come from the server model's [Property<T>("Primary Email")] attribute |
| Correct control automatically | Email → email input, Phone → phone input, Date → date picker, bool → checkbox |
| Server-driven validation | Required, MaxLength, read-only status flow from server metadata |
| Consistent UI | Every page that shows PrimaryEmail renders identically |
| View/Edit modes | The same PropertyComponent renders a read-only display or an editor based on DisplayMode |
Where the Metadata Comes From
The metadata that drives PropertyComponent originates from the server-side entity model and logic classes. Here is a simplified version of what the Customer entity looks like on the server:
Entity definition
[ApiEntity]
[NaturalKey(nameof(Id))]
public partial class Customer : IContactAccount
{
[Required]
[Property<DataTypes.IdText>("Id")]
public partial string Id { get; set; }
[Property<DataTypes.ProperNoun>("Full name")]
public partial string FullName { get; }
[ReferenceProperty("Billing customer", DeleteAction.SetNull)]
public virtual partial Customer? BillingCustomer { get; set; }
[Method("Get destination contacts", MethodType.Read)]
public partial IQueryable<Contact> GetDestinationContacts();
}
Each attribute drives the client metadata. [Property<DataTypes.IdText>("Id")] tells the client this is a text property labeled "Id". [Required] makes the client show a validation error if blanked. [ReferenceProperty] tells the client to render an entity selector. [Method] exposes a callable action on the entity's API endpoint.
The IContactAccount interface adds a PrimaryContact navigation — this is the shared contact model that gives Customer its email, phone, and address fields.
Logic class
Business logic is defined separately from the entity in [Logic] classes. For example, FullName is a computed property — its value is derived automatically:
[Logic]
public class CustomerBL(Customer.Logic customer)
{
[RegisterLogic]
public void ComputeFullName()
{
customer.Compute(c => c.FullName)
.From(c => c.PrimaryContact != null
? $"{c.PrimaryContact.FirstName} {c.PrimaryContact.LastName}".Trim()
: string.Empty)
.DirtyWithRelation(c => c.PrimaryContact)
.DirtyBy(c => new { c.FirstName, c.LastName });
}
[RegisterLogic]
public void ValidateId()
{
customer.Validate(c => c.Id)
.RejectIf(id => string.IsNullOrWhiteSpace(id))
.WithMessage("Customer ID is required.");
}
}
When the user edits PrimaryContact.FirstName on the Blazor page, SetAsync sends the change to the server. The server's event engine runs ComputeFullName, recomputes FullName, and returns the updated value in the PATCH response. The client's DataSet picks up the new FullName automatically — no extra code on the page.
Validation works the same way: ValidateId runs server-side and its error message flows back through the response as a UserPrompt, which PropertyComponent renders as helper text on the Id field.
Packages
| Package | Purpose |
|---|---|
Benevia.Core.Client |
UI-agnostic foundation: OData client, graph definitions, datasets, model metadata |
Benevia.Core.Blazor |
Blazor UI layer: DataGraph, PropertyComponent, reference pickers, collection grids |
Benevia.Core.Client can be used without Blazor (e.g., in a MAUI app or test harness). Benevia.Core.Blazor depends on Benevia.Core.Client and adds the Blazor-specific rendering.
Service Registration
using Benevia.Core.Blazor;
using Blazored.LocalStorage;
builder.Services.AddHttpClient("Api", client =>
{
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!);
});
builder.Services.AddScoped(sp =>
sp.GetRequiredService<IHttpClientFactory>().CreateClient("Api"));
builder.Services.AddBlazoredLocalStorage();
builder.Services.AddCoreBlazor(); // Registers Client + Blazor services + MudBlazor + component mappings
Learn More
- Graph Definitions — declaring what data a page needs
- DataSets — reading, writing, and tracking entity state
- PropertyComponent — how controls are resolved from metadata
- Building a Complete Page — full Customer page walkthrough