Skip to content

13 01 Headless UI Pattern

Cyberdyne Development edited this page Feb 18, 2026 · 3 revisions

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.

Architecture Overview

+-------------------------------------------------+
|  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                   |
+-------------------------------------------------+

What "Headless" Means

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.

The Protocol Pattern

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) { ... }
}

Usage in a Visual Component

<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 Components (15)

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

Relationship Between Packages

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
  ...

Interface Contracts for Cross-Domain DTOs

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:

  1. Protocol calls API client method (e.g., ConnectionApi.GetConnections())
  2. API client makes HTTP request to consumer endpoint (thin closure)
  3. Consumer endpoint delegates to generic base from the .Endpoints package
  4. Base endpoint reads from IOptionsMonitor or writes via IConfigurationWriter
  5. Response flows back through the chain
  6. Protocol updates its state and calls StateHasChanged()
  7. Blazor re-renders the visual child components

Benefits

  1. Write once, render anywhere -- Core logic is written once in the Protocol and reused across all three reference UI implementations
  2. Consistent validation -- Business rules (duplicate checking, required fields) live in the Protocol, not scattered across UI pages
  3. Testable -- Protocols can be tested independently by mocking API clients
  4. State management -- Each Protocol manages its own loading state, error state, and data cache, keeping visual components simple

See Also

Creating Custom Protocol Components

Step-by-step guide to creating a new Protocol provider for a custom domain.

Step 1: Create the API Client

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);
}

Step 2: Create the Protocol Provider

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();
        }
    }
}

Step 3: Consume in a Skin Page

<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();
    }
}

Clone this wiki locally