Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

The Shared Kernel -- Blazor WASM + Server

The shared kernel compiles to both ASP.NET (server) and Blazor WebAssembly (browser), eliminating frontend-backend type drift.

Layer Shared Kernel Server Only Client Only
Types DTOs, validation, enums EF Core, repositories Blazor rendering
Content Block definitions, widget contracts Command handlers, indexing Page layout engine
Infrastructure Background jobs, media processing WASM interop
Diagram
A single MyStore.Shared assembly compiles into both the ASP.NET server and the Blazor WebAssembly client, so DTOs, validation and block contracts cannot drift between the two.

Project Layout

A cmf new MyStore solution lays out seven projects, with Shared sitting in the middle:

MyStore.sln
├── MyStore.Lib                    (M1 declarations: attributed partial classes)
├── MyStore.Lib.Generators         (Roslyn IIncrementalGenerator host)
├── MyStore.Abstractions           (interfaces consumed by Lib + Server + Client)
├── MyStore.Shared                 (THE KERNEL — see below)
├── MyStore.Server                 (ASP.NET host, EF Core, command bus, workers)
├── MyStore.Client                 (Blazor WebAssembly SPA)
├── MyStore.Infrastructure.Postgres (EF Core provider, migrations)
└── MyStore.Lib.Testing            (test fixtures + in-memory repositories)

MyStore.Shared has two non-negotiable constraints: it targets net10.0 (not net10.0-windows, not net10.0-android), and its csproj enables <IsTrimmable>true</IsTrimmable> plus <EnableTrimAnalyzer>true</EnableTrimAnalyzer> so the linker can prune unused server-side code paths from the WASM bundle.

What Lives in Shared

The kernel contains exactly the types that must mean the same thing on both sides of the wire:

Category Lives in Shared Example
Identity types Yes OrderId, CustomerId, ProductId — generated record struct value objects
Enums Yes OrderStatus, RequirementLifecycleState, WorkflowStage
DTOs Yes OrderDto, ProductDto, CreateOrderCommand (the wire contract)
Validation Yes OrderValidator : AbstractValidator<CreateOrderCommand> (FluentValidation rules)
Result types Yes Result<T>, Result<T,TError>, ValidationFailure
Block contracts Yes IContentBlock, HeroBlock, RichTextBlock (definitions, not renderers)
Widget configuration Yes ProductListWidgetConfig (the params, not the rendering logic)
Errors Yes DomainError, ProblemDetails extensions, error code constants
Time/clock contracts Yes IClock interface (no DateTime.UtcNow calls in Shared)
EF entities NO Order (the entity) lives in MyStore.Lib; only OrderDto crosses to Shared
Repositories NO Server-only
Command/query handlers NO Server-only
HttpClient code NO Client-only
Background workers NO Server-only

The rule of thumb: if the type appears in both an HTTP request body and a Blazor render tree, it lives in Shared. Anything that touches a database, a file system, a queue, or JSInterop does not.

Compilation Targets

MyStore.Shared.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <IsTrimmable>true</IsTrimmable>
    <EnableTrimAnalyzer>true</EnableTrimAnalyzer>
    <IsAotCompatible>true</IsAotCompatible>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="FluentValidation" Version="11.*" />
    <!-- intentionally NO Microsoft.EntityFrameworkCore -->
    <!-- intentionally NO Microsoft.AspNetCore.* -->
  </ItemGroup>
</Project>

A Directory.Build.targets in the solution root forbids server-only packages from leaking in:

<Target Name="ForbidServerOnlyPackagesInShared"
        BeforeTargets="Build"
        Condition="'$(MSBuildProjectName)' == 'MyStore.Shared'">
  <Error Text="Microsoft.EntityFrameworkCore is not allowed in MyStore.Shared"
         Condition="@(PackageReference->AnyHaveMetadataValue('Identity', 'Microsoft.EntityFrameworkCore'))" />
  <Error Text="Microsoft.AspNetCore.* is not allowed in MyStore.Shared"
         Condition="@(PackageReference->WithMetadataValue('Identity', 'Microsoft.AspNetCore.App'))" />
</Target>

This catches drift at build time. Combined with the CMF301 analyzer (which forbids using Microsoft.EntityFrameworkCore; inside any file under MyStore.Shared/), the kernel cannot accidentally absorb server concerns.

Validation: One Rule, Two Hosts

A single FluentValidation rule defined in Shared runs in both places:

// MyStore.Shared/Validation/CreateOrderCommandValidator.g.cs  (generated)
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(c => c.Lines).NotEmpty().WithMessage("ORD-001: at least one line is required");
        RuleForEach(c => c.Lines).SetValidator(new CreateOrderLineDtoValidator());
        RuleFor(c => c.ShippingAddress).NotNull().SetValidator(new ShippingAddressValidator());
    }
}

On the client, the Blazor form binds the validator to the <EditForm> so the user gets inline error messages without a round-trip. On the server, the same validator runs inside the command handler before any state mutation. The ORD-001 error code is shared, so the client can localize it and the server can log it under the same key.

This eliminates the most common source of "frontend says one thing, backend says another" bugs in custom CMS work.

Result Type and Error Codes

Result<T> and Result<T,TError> live in Shared because they serialize identically to JSON and can be deconstructed on either side:

public abstract record Result<T>
{
    public sealed record Success(T Value) : Result<T>;
    public sealed record Failure(string Code, string Message) : Result<T>;

    public TResult Match<TResult>(Func<T, TResult> success, Func<string, string, TResult> failure) =>
        this switch
        {
            Success s => success(s.Value),
            Failure f => failure(f.Code, f.Message),
            _ => throw new InvalidOperationException()
        };
}

Error codes use a stable scheme: <CONTEXT>-<NUMBER> where context is the bounded-context prefix (ORD, CAT, SHP). Codes are declared as const string in MyStore.Shared/ErrorCodes/*.cs so both client UI and server logs reference the same identifier. A cmf report errors command produces a markdown reference of every code, its meaning, and where it can fire.

Block and Widget Contracts

Content blocks are interesting because their definition is shared but their renderer is not. The block definition lives in Shared:

// MyStore.Shared/Content/Blocks/HeroBlock.cs
public sealed record HeroBlock(
    string Headline,
    string SubHeadline,
    Uri ImageUrl,
    string CallToActionLabel,
    Uri CallToActionUrl) : IContentBlock
{
    public string BlockType => "hero";
}

The Blazor renderer for it lives in MyStore.Client/Components/Blocks/Hero.razor, and the admin editor lives in MyStore.Server/Areas/Admin/Components/Blocks/HeroEditor.razor — but both consume the same HeroBlock record. When the admin saves a page, the HeroBlock is serialized to JSON and stored in the page tree; when the public site renders it, the same JSON is deserialized and passed to the WASM component. There is exactly one schema, defined once.

JSON Converters for Value Objects

Generated value objects ship with custom JsonConverter<T> implementations so they round-trip identically:

// MyStore.Shared/Json/OrderIdJsonConverter.g.cs (generated)
public sealed class OrderIdJsonConverter : JsonConverter<OrderId>
{
    public override OrderId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        => new OrderId(reader.GetGuid());

    public override void Write(Utf8JsonWriter writer, OrderId value, JsonSerializerOptions options)
        => writer.WriteStringValue(value.Value);
}

A single MyStoreJsonContext : JsonSerializerContext source-generated class registers every converter so AOT compilation in the WASM bundle finds them without reflection.

Anti-Patterns: What Stays Out

A short list of things tempting to put in Shared, and why each is wrong:

Anti-pattern Why it fails
EF entities (e.g., Order itself) Hauls the EF assembly into the WASM bundle (~8 MB), and the entity carries lazy-loading proxies that crash on the client
IServiceCollection extensions Forces the client to reference Microsoft.Extensions.DependencyInjection's full graph including hosting
HttpClient wrappers The client uses Blazor WASM HttpClient (browser-backed), the server uses IHttpClientFactory — they don't share lifetime semantics
Logging adapters ILogger<T> is fine in interfaces, but never bind concrete sinks (Serilog, Seq) in Shared
Authentication code Token validation is server-side; token parsing for display is client-side; the two must not converge
Anything with static DateTime.UtcNow Use IClock so tests can freeze time and the WASM client can fall back to DateTimeOffset.UtcNow once

These boundaries are enforced by the CMF302 analyzer, which scans MyStore.Shared/ for forbidden namespaces and emits errors at compile time.

Why This Matters

Most full-stack frameworks have a "shared types" project and quietly let it rot. The CMF treats Shared as a first-class compilation target with its own constraints, its own analyzers, and its own generated artifacts. The result is that the most expensive class of CMS bugs — frontend and backend disagreeing about the shape of the data — is eliminated by construction, not by discipline.

⬇ Download