-
Notifications
You must be signed in to change notification settings - Fork 0
13 01 Headless UI Pattern
The FractalDataWorks UI layer uses a headless architecture that separates data logic from visual rendering. This guide explains the pattern, its components, and how they connect.
+-------------------------------------------------+
| UI Framework (MudBlazor / Tailwind / Custom) |
| Pure rendering - HTML, CSS, layout |
+-------------------------------------------------+
| RenderFragment<T>
v
+-------------------------------------------------+
| Protocol Components (Headless Logic) |
| State management, loading, error handling |
| Package: FractalDataWorks.UI.Components.Blazor |
+-------------------------------------------------+
| Injected via [Inject]
v
+-------------------------------------------------+
| API Clients |
| HTTP calls, serialization, base URL routing |
| Per-Domain .Clients packages |
+-------------------------------------------------+
| HTTP (REST)
v
+-------------------------------------------------+
| Per-Domain Endpoints (Tier 2/3) |
| FastEndpoints, authorization, validation |
| 15 per-domain .Endpoints packages |
+-------------------------------------------------+
| IOptionsMonitor / IConfigurationWriter
v
+-------------------------------------------------+
| ConfigurationDb |
| SQL Server, version-on-write |
+-------------------------------------------------+
A headless component contains all the logic for a feature (data fetching, state management, filtering, CRUD operations) but renders zero HTML. Instead, it passes its state and methods to child components via Blazor's RenderFragment<T> pattern.
This means the same logic component works with:
- MudBlazor (ManagementUI) -- Material Design components
- Tailwind CSS (ManagementUI-Tailwind) -- Utility-first CSS
- WebAssembly (ManagementUI-WASM) -- Client-side standalone
- Custom CSS -- Any other visual framework
The visual layer becomes a thin "skin" that only handles layout and styling.
Each Protocol component follows this structure:
@* No HTML output -- logic only *@
@if (ChildContent != null)
{
@ChildContent(this)
}
@code {
[Parameter] public RenderFragment<ConnectionProvider>? ChildContent { get; set; }
// Published state
public List<ConnectionDto> Connections { get; private set; } = new();
public bool IsLoading { get; private set; }
public string? ErrorMessage { get; private set; }
// Injected API clients
[Inject] private ConnectionApiClient ConnectionApi { get; set; } = default!;
// Public methods for child components
public async Task LoadData(CancellationToken ct = default) { ... }
public async Task<bool> DeleteConnection(string name, CancellationToken ct = default) { ... }
}<ConnectionProvider @ref="_provider">
<ChildContent Context="logic">
@if (logic.IsLoading)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<MudTable Items="@logic.FilteredConnections">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Type</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Name</MudTd>
<MudTd>@context.ConnectionType</MudTd>
</RowTemplate>
</MudTable>
}
</ChildContent>
</ConnectionProvider>The same ConnectionProvider with Tailwind CSS:
<ConnectionProvider @ref="_provider">
<ChildContent Context="logic">
@if (logic.IsLoading)
{
<div class="animate-pulse h-2 bg-blue-500 rounded"></div>
}
else
{
<table class="min-w-full divide-y divide-gray-700">
@foreach (var conn in logic.FilteredConnections)
{
<tr>
<td class="px-4 py-2">@conn.Name</td>
<td class="px-4 py-2">@conn.ConnectionType</td>
</tr>
}
</table>
}
</ChildContent>
</ConnectionProvider>| Protocol | API Client(s) | Responsibility |
|---|---|---|
ConnectionProvider |
ConnectionApiClient, ConfigurationApiClient
|
Connection CRUD, type browsing, connectivity testing |
DataStoreProvider |
DataStoreApiClient |
DataStore CRUD, introspection, container listing |
DataSetProvider |
DataSetApiClient |
DataSet CRUD, field/source management |
SchemaProvider |
SchemaApiClient |
Schema discovery, graph visualization |
PipelineProvider |
IPipelineClient, IPipelineJobClient
|
Pipeline CRUD, job execution, status monitoring |
ScheduleProvider |
ScheduleApiClient |
Schedule management and status |
UserProvider |
UserApiClient |
User CRUD, role synchronization, search |
RoleProvider |
RoleApiClient |
Role definitions, permission matrix |
ThemeProvider |
ThemeApiClient |
Theme CRUD, default theme management |
AnalyticsProvider |
AnalyticsApiClient |
Dashboard metrics, activity feeds |
DataflowProvider |
DataflowApiClient |
Dataflow graph, impact analysis |
LineageProvider |
LineageApiClient |
Data lineage traversal |
CalculationProvider |
CalculationApiClient |
Calculation chain operations |
ConfigurationProvider |
ConfigurationApiClient |
Configuration metadata browsing |
DashboardProvider |
Multiple clients | Aggregated system metrics |
FractalDataWorks.Web.Clients.Abstractions
ApiClientBase.cs -- shared HTTP plumbing, auth headers, error handling
ClientLog.cs -- structured logging (EventIds for all client operations)
Contracts/ -- cross-domain DTO interfaces (see below)
FractalDataWorks.UI.Components.Blazor
Protocols/
ConnectionProvider.razor -- injects ConnectionApiClient
DataStoreProvider.razor -- injects DataStoreApiClient
...
Per-Domain .Clients Packages (15 packages)
Services.Connections.Clients/
ConnectionApiClient.cs -- HTTP calls to /connections/*
Services.Data.Clients/
DataStoreApiClient.cs -- HTTP calls to /datastores/*
DataSetApiClient.cs -- HTTP calls to /datasets/*
Schema.Clients/
SchemaApiClient.cs -- HTTP calls to /schema/*
Operations.Clients/
DataflowApiClient.cs -- HTTP calls to /dataflow/*
Web.Calculations.Clients/
CalculationApiClient.cs -- HTTP calls to /calculations/*
... (10 more per-domain .Clients packages)
Per-Domain Endpoint Packages (13 packages)
Services.Connections.Endpoints/
ListConnectionsEndpointBase.cs -- generic base for GET /connections
GetConnectionEndpointBase.cs -- generic base for GET /connections/{name}
...
Services.Data.Endpoints/
ListDataStoresEndpointBase.cs -- generic base for GET /datastores
...
Consumer Closures (Reference.Api/Endpoints/)
ConnectionsEndpoint.cs -- thin closures closing generic types
DataStoreEndpoints.cs
...
The Web.Clients.Abstractions/Contracts/ directory contains interface abstractions for DTOs that span multiple domain boundaries. This enables dependency inversion -- consumers depend on the interface contract rather than a specific domain's concrete DTO class.
| Contract Interface | Used By Domains | Purpose |
|---|---|---|
IColumnSchema |
Schema, Data | Column metadata (name, type, nullable, primary key) |
IFieldDiscovery |
Schema, Data | Discovered field metadata from introspection |
IContainerDiscovery |
Schema, Data | Discovered container with its fields |
IDataPreviewRequest |
Schema, Data | Parameters for data preview queries |
IDataPreviewResponse |
Schema, Data | Preview results with columns and rows |
IDataSetField |
Data, Calculations | DataSet field metadata across domains |
IEnvironmentInfo |
Operations, Analytics | Environment identification |
ICalculationTypeStats |
Calculations, Analytics | Calculation execution statistics |
Per-domain .Clients packages implement these interfaces on their concrete DTOs. For example, Schema.Clients defines ColumnSchemaDto : IColumnSchema and Data.Clients defines its own ColumnSchemaDto : IColumnSchema. Protocol providers and consuming code can work with the interface without knowing which domain produced the DTO.
Data flows down, events flow up:
- Protocol calls API client method (e.g.,
ConnectionApi.GetConnections()) - API client makes HTTP request to consumer endpoint (thin closure)
- Consumer endpoint delegates to generic base from the
.Endpointspackage - Base endpoint reads from
IOptionsMonitoror writes viaIConfigurationWriter - Response flows back through the chain
- Protocol updates its state and calls
StateHasChanged() - Blazor re-renders the visual child components
- Write once, render anywhere -- Core logic is written once in the Protocol and reused across all three reference UI implementations
- Consistent validation -- Business rules (duplicate checking, required fields) live in the Protocol, not scattered across UI pages
- Testable -- Protocols can be tested independently by mocking API clients
- State management -- Each Protocol manages its own loading state, error state, and data cache, keeping visual components simple
- UI.Components.Blazor README -- Full component documentation
- Web.Clients.Abstractions README -- API client base, shared logging, and contract interfaces
- 13-02 Creating Consumer Packages -- Building consumer endpoints from per-domain packages
- 11-01 Management UI Overview -- Three reference UI implementations
- 10-01 ReferenceSolutions Architecture -- Thin-client pattern and measured LOC ratios
Step-by-step guide to creating a new Protocol provider for a custom domain.
Create a .Clients package or add to an existing one:
public class CustomApiClient : ApiClientBase
{
public CustomApiClient(HttpClient httpClient, ILogger<CustomApiClient> logger)
: base(httpClient, logger) { }
public async Task<IGenericResult<List<CustomDto>>> GetItems(CancellationToken ct = default)
=> await GetAsync<List<CustomDto>>("api/custom", ct);
}In UI.Components.Blazor/Protocols/:
@code {
[Parameter] public RenderFragment<CustomProvider>? ChildContent { get; set; }
public List<CustomDto> Items { get; private set; } = [];
public bool IsLoading { get; private set; }
public string? ErrorMessage { get; private set; }
[Inject] private CustomApiClient Api { get; set; } = default!;
public async Task LoadData()
{
IsLoading = true;
ErrorMessage = null;
try
{
var result = await Api.GetItems();
if (result.IsSuccess) { Items = result.Value ?? []; }
else { ErrorMessage = result.Messages.FirstOrDefault()?.Text; }
}
finally
{
IsLoading = false;
StateHasChanged();
}
}
}<CustomProvider @ref="_provider">
<ChildContent Context="provider">
@if (provider.IsLoading)
{
<LoadingSpinner />
}
else if (provider.ErrorMessage is not null)
{
<ErrorAlert Message="@provider.ErrorMessage" />
}
else
{
@foreach (var item in provider.Items)
{
<div>@item.Name</div>
}
}
</ChildContent>
</CustomProvider>
@code {
private CustomProvider? _provider;
protected override async Task OnInitializedAsync()
{
if (_provider is not null)
await _provider.LoadData();
}
}