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