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 typesEmail, Phone, Date, ProperNoun, FullAddress, etc.
  • Validation rulesRequired, 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

  1. DataGraph asks IModelProvider for the Customer entity metadata — types, labels, and validation rules for every property in the graph.
  2. If CustomerGuid has a value, DataGraph calls DataSet.LoadAsync(guid) which sends a GET request with the OData $select/$expand query generated from the graph. If CustomerGuid is null, it calls DataSet.CreateAsync() to POST a new Customer.
  3. Each PropertyComponent reads its property's metadata from the cascaded context. It finds, for example, that PrimaryContact.PrimaryEmail has data type Email and label "Primary email". It resolves the appropriate editor component (StringPropertyEdit in edit mode) and passes it the label and validation config.
  4. When the user changes a value, PropertyComponent calls DataSet.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