Skip to content

wiki bugs

Mark Lauter edited this page May 10, 2026 · 1 revision

Wiki bugs — Recipe-Aspnet-Host-Integration.md

Issues found while building the recipe under strict (TreatWarningsAsErrors=true, AnalysisMode=All, IDisposableAnalyzers) settings.

1. DispatchMiddleware is the terminal middleware but takes a next parameter it never calls

Severity: logic bug — the recipe still runs, but the parameter is dead and the shape misleads readers.

The wiki shows:

internal sealed class DispatchMiddleware(
    RequestMiddleware<ProcessRequest, ProcessResponse> next)
{
    public async Task InvokeAsync(
        RequestContext<ProcessRequest, ProcessResponse> context,
        AppDbContext db,
        IHttpClientFactory httpFactory)
    {
        var client = httpFactory.CreateClient("downstream");
        // ... do work ...
        await db.SaveChangesAsync(context.CancellationToken);
        context.Response = new ProcessResponse(context.Id.ToString(), "ok");
    }
}

The constructor takes next (it has to — Plumber's class-middleware convention requires RequestMiddleware<TReq,TRes> next as the first constructor parameter), but InvokeAsync never calls next(context). That's actually correct for a terminal middleware that owns the response, but the code stores the parameter via the primary-constructor capture and the field is never read. Under strict analyzers this surfaces as IDE0052 / IDE0060-style warnings depending on tooling.

The recipe also implicitly teaches a non-obvious rule — that the last Use<T>() in a chain can stop short of calling next — without saying so. A one-line callout would help.

Fix in this sample: added a _ = next; discard inside InvokeAsync and a comment explaining that this middleware is terminal by design. See DispatchMiddleware.cs.

2. IHttpClientFactory.CreateClient(...) return value triggers IDISP004

Severity: strict-build only.

The wiki snippet does var client = httpFactory.CreateClient("downstream");. The returned HttpClient is IDisposable, but the documented contract for IHttpClientFactory is that the factory owns the lifetime and you must NOT dispose it (disposing it returns the underlying handler to the pool early and breaks the whole point of HttpClientFactory).

Standard analyzers can't see that contract, so IDISP004 — Don't ignore created IDisposable fires on the assignment. Documenting the suppression in the recipe (or a parenthetical "the factory owns the client; do not using-wrap it") would save readers the discovery.

Fix in this sample: [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP004:...")] on DispatchMiddleware.InvokeAsync with a justification.

3. services.AddSingleton(sp => RequestHandler.Create(sp)) triggers IDISP004 in the registration extension

Severity: strict-build only.

PipelineRegistration.AddProcessingPipeline is the recipe-recommended way to register the handler. Inside the factory lambda the code creates an IDisposable (RequestHandler<,>) and hands it to AddSingleton, which captures the lifetime in the host's container. IDisposableAnalyzers doesn't model that ownership transfer and flags the lambda body with IDISP004.

The recipe's "Disposal semantics" section explains why this is safe (the host DI container disposes singletons on shutdown), but the snippet itself doesn't acknowledge that strict analyzers will need a local suppression.

Fix in this sample: member-level [SuppressMessage("...", "IDISP004:...")] on AddProcessingPipeline with a justification linking back to the disposal-semantics guarantees.

4. EnrichmentMiddleware injects TimeProvider but the recipe never registers it

Severity: blocking at runtime — pipeline activation throws InvalidOperationException on the first request.

The wiki shows:

internal sealed class EnrichmentMiddleware(
    RequestMiddleware<ProcessRequest, ProcessResponse> next,
    ILogger<EnrichmentMiddleware> logger,
    TimeProvider clock)
{
    ...
}

TimeProvider is not auto-registered by the .NET 10 host. Without an explicit builder.Services.AddSingleton(TimeProvider.System) in Program.cs, the first request crashes with:

System.InvalidOperationException: Unable to resolve service for type 'System.TimeProvider'
while attempting to activate 'EnrichmentMiddleware'.

The recipe's Program.cs snippet shows none of the lines that would supply it.

Fix in this sample: added builder.Services.AddSingleton(TimeProvider.System) in Program.cs with a comment pointing at this wiki gap. Worth fixing in the recipe text — or worth a one-line note that TimeProvider.System registration is the host's responsibility.

5. Missing types: IMyService / MyService / AppDbContext

Severity: non-blocking — these are illustrative types in the wiki, not promised to be defined.

The recipe references IMyService, MyService, and AppDbContext in the builder.Services.Add* snippets and in DispatchMiddleware, but never defines them. That's fine for prose, but a reader copy-pasting the recipe gets a compile error.

Fix in this sample: added trivial stand-in types:

  • IMyService / MyService — registered for completeness (no middleware actually injects them; the wiki doesn't either).
  • AppDbContext — a no-op stub with a SaveChangesAsync(CancellationToken) method so DispatchMiddleware compiles. We deliberately did NOT pull in Microsoft.EntityFrameworkCore — the sample's job is to demonstrate Plumber's host-mode wiring, not EF Core.

The stub's instance method triggers CA1822 (mark as static) — suppressed inline with a justification that real EF Core's SaveChangesAsync is genuinely instance-bound.

6. Records ProcessRequest / ProcessResponse declared public in a Web SDK app trip CA1515

Severity: strict-build only.

The wiki declares both records public sealed record. Under AnalysisMode=All, CA1515 — Make types internal fires because nothing outside the assembly references them. Either:

  • declare them internal (the modern guidance for app-only types), or
  • suppress CA1515.

Staying faithful to the recipe means keeping public. We suppressed CA1515 at the type level on both records.

7. Build-strictness adjustments (mirrors Recipe.FileWatcher footnote)

Standing project-wide suppressions inherited from Directory.Build.props: CA1308, CA2007, CA1812, CA1848. Same rationale as the file-watcher sample.

Per-call-site / per-member suppressions added for this recipe:

  • CA1515 on ProcessRequest, ProcessResponse — wiki declares them public.
  • CA1822 on AppDbContext.SaveChangesAsync — stand-in shape mimics EF Core.
  • IDISP004 on DispatchMiddleware.InvokeAsyncIHttpClientFactory owns the client.
  • IDISP004 on PipelineRegistration.AddProcessingPipeline — host DI owns the singleton handler.

dotnet format Recipe.AspNet.sln will reorder imports (system-first does not match the project's dotnet_sort_system_directives_first = false). Always run dotnet format Recipe.AspNet.sln (NOT bare dotnet format — both .sln and .csproj sit in the same folder, which makes argumentless dotnet format throw FileNotFoundException).

What this sample adds beyond the wiki (so it's observable)

The recipe demonstrates the registration shape but never shows the pipeline producing real output. To make the sample runnable end-to-end:

  • IMyService.Summarize(tenant, payload) — the wiki's IMyService is undefined. We gave it a real method that returns a string summary of the request, so the response reflects actual pipeline work.
  • DispatchMiddleware — the wiki shows httpFactory.CreateClient("downstream") and then // ... do work ... (which would require either a real downstream or a fake handler). We swapped IHttpClientFactory for IMyService (method-injected, scoped via Plumber's per-invocation scope). Same pedagogical point — scoped service consumption inside terminal middleware — without a network dependency.
  • Removed builder.Services.AddHttpClient("downstream") from Program.cs since nothing consumes it after the swap above.

POST {"tenant":"acme","payload":{"orderId":"ord-1","amount":42}} to /process{"correlationId":"...","status":"tenant=acme; payload-kind=Object; properties=2"}. POST {"tenant":"","payload":{...}}{"correlationId":"...","status":"rejected"}.

Skipped sections

  • "Testing" — sample doesn't include a test project; recipe's WebApplicationFactory guidance is well-formed and would compile if a test project were added.
  • The optional controller variant (ProcessController) — the minimal-API endpoint alone exercises the host-mode integration; adding a controller would require AddControllers/MapControllers plumbing without showing anything new about Plumber.

Tested against

  • .NET 10
  • ASP.NET Core 10 (Microsoft.NET.Sdk.Web, framework reference resolves transitively)
  • Plumber 3.x via ProjectReference to D:\plumber\Plumber\Plumber.csproj
  • IDisposableAnalyzers 4.0.8

Clone this wiki locally