Skip to content

Sample Cli Walkthrough

Mark Lauter edited this page Jun 14, 2026 · 6 revisions

Sample.Cli walkthrough

Sample.Cli is a small CLI in the Plumber repository. It reads text from stdin or command-line arguments, runs it through a four-step pipeline (validate, normalize, tokenize, report), and prints a structured TextReport. This page walks through every file and explains why the application is shaped the way it is.

If you'd rather build a similar app from scratch, the Tutorial does that with a log-line summarizer instead, introducing concepts one at a time. This page assumes you've read the Tutorial (or are comfortable enough with Plumber to skip it).

What Sample.Cli does

End to end:

  1. Read input from args (joined by spaces) if any are present, otherwise from stdin.
  2. Run the input through a pipeline:
    • Validate — short-circuits with an error report if the input is empty/whitespace.
    • Normalize — lowercases the text, stores the result in context.Data.
    • Tokenize — splits the normalized text on whitespace using a configurable ITokenizer, stores the tokens in context.Data.
    • Report — assembles a TextReport record from the values in context.Data and assigns it to context.Response.
  3. A timing wrapper around the whole chain measures elapsed time and rewrites the response with the elapsed value.
  4. Print the report to stdout, or print the error message to stderr.
  5. Exit with 0 on success, 1 if no response was produced, 2 if the report contained an error message.

Run it:

echo "Hello, World! FOO" | dotnet run --project Sample.Cli
# original:   Hello, World! FOO
# normalized: hello, world! foo
# tokens:     [hello,, world!, foo]
# words:      3
# elapsed:    1.84ms

The shape is slightly more elaborate than the minimum needed to demonstrate Plumber. The reason is testability — Sample.Cli is also the demonstration app for PlumberApplicationFactory, which lets tests register service overrides between the un-built builder and the built handler. The pipeline is split into a CreateBuilder half and a Configure half so the factory can plug into the gap between them. See How the shape supports testing below.

Project layout

The relevant files in Sample.Cli/:

File Role
Pipeline.cs The CreateBuilder / Configure / Build split that produces a configured handler
Program.cs Reads input, invokes the pipeline, prints the report, returns an exit code
ValidationMiddleware.cs Short-circuits with an error report when the input is empty or whitespace
NormalizeMiddleware.cs Lowercases the input and stores it in context.Data
TokenizeMiddleware.cs Splits the normalized text into tokens using an injected ITokenizer
ReportMiddleware.cs Terminal step: builds the TextReport from collected data and sets context.Response
ITokenizer.cs Tiny interface — string[] Tokenize(string input)
WhitespaceTokenizer.cs Default ITokenizer implementation, configured by TokenizerOptions
TokenizerOptions.cs POCO bound from configuration; ships with sensible Defaults
TextReport.cs The pipeline's TResponse record
DataKeys.cs String constants used as keys in context.Data

The companion test project Sample.Cli.Tests lives at the repo root alongside Sample.Cli.

Pipeline.cs — the build/configure split

Pipeline.cs produces a fully wired RequestHandler<string, TextReport> in three small static methods.

internal static class Pipeline
{
    public static RequestHandlerBuilder<string, TextReport> CreateBuilder(string[] args) =>
        RequestHandlerBuilder.Create<string, TextReport>(args)
            .ConfigureConfiguration((config, _) => config.AddInMemoryCollection([
                new($"{TokenizerOptions.SectionName}:{nameof(TokenizerOptions.Separators)}", TokenizerOptions.Defaults.Separators),
                new($"{TokenizerOptions.SectionName}:{nameof(TokenizerOptions.RemoveEmptyEntries)}", TokenizerOptions.Defaults.RemoveEmptyEntries.ToString()),
                new($"{TokenizerOptions.SectionName}:{nameof(TokenizerOptions.TrimEntries)}", TokenizerOptions.Defaults.TrimEntries.ToString()),
            ]))
            .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Information))
            .ConfigureServices((services, configuration) =>
            {
                var options = configuration.GetSection(TokenizerOptions.SectionName).Get<TokenizerOptions>()
                    ?? TokenizerOptions.Defaults;
                _ = services
                    .AddSerilogRequestLogging<string, TextReport>(logger => logger.WriteTo.Console(new CompactJsonFormatter()))
                    .AddPlumberDiagnostics<string, TextReport>()
                    .AddSingleton(options)
                    .AddSingleton<ITokenizer, WhitespaceTokenizer>();
            });

    public static RequestHandler<string, TextReport> Configure(RequestHandler<string, TextReport> handler) =>
        handler
            .UseRequestDiagnostics()
            .UseSerilogRequestLogging()
            .Use(async (context, next) =>
            {
                var start = DateTime.UtcNow;
                await next(context);
                if (context.Response is { } response)
                {
                    context.Response = response with { Elapsed = DateTime.UtcNow - start };
                }
            })
            .Use<ValidationMiddleware>()
            .Use<NormalizeMiddleware>()
            .Use<TokenizeMiddleware>()
            .Use<ReportMiddleware>();

    public static RequestHandler<string, TextReport> Build(string[] args) =>
        Configure(CreateBuilder(args).Build());
}

Three observations:

CreateBuilder returns the un-built builder. Configuration sources, logging, and services are queued on the builder via the Configure* callbacks. None have run yet — those callbacks fire lazily when Build() is called. Returning the builder (instead of immediately calling .Build()) is what gives PlumberApplicationFactory a place to inject its own service overrides.

Configure takes a built handler and returns it. The middleware chain is registered with .Use(...) calls. It returns the same handler instance — .Use() is fluent. The [SuppressMessage] attribute acknowledges an IDisposable-analyzer warning: the returned handler "looks like" a new instance to the analyzer, but it isn't.

Build composes the two halvesConfigure(CreateBuilder(args).Build()). This is what Program.cs calls. The split is invisible to a normal run; it pays for itself only at test time.

Serilog request logging

Pipeline wires in Serilog request logging across both halves: AddSerilogRequestLogging<string, TextReport>(...) in CreateBuilder registers the logger and options, and UseSerilogRequestLogging() in Configure adds the middleware near the outer edge, just inside the diagnostics middleware. Every run emits one structured event per request, carrying the request id, the elapsed time, and the current trace ids, through the CompactJsonFormatter console sink. The Serilog Extensions page covers the options surface.

ConfigureLogging sets only the minimum level — no console provider. Registering Serilog through AddSerilogRequestLogging is what gives ILogger<T> (the logger NormalizeMiddleware injects) a home; the structured request log is Serilog's job, so a second Microsoft.Extensions.Logging console provider would only duplicate it.

Diagnostics

Pipeline also wires in OpenTelemetry tracing and metrics: AddPlumberDiagnostics<string, TextReport>() in CreateBuilder registers the options, and UseRequestDiagnostics() in Configure adds the tracing and metrics middleware as the outermost layer — so the span and the request metrics wrap the whole pipeline, Serilog logging included. The middleware emit through the BCL ActivitySource and Meter; Program.cs builds the host-free OpenTelemetry SDK providers (Telemetry.CreateTracerProvider(spans) / CreateMeterProvider(metrics)) that subscribe to them and collect into in-memory lists, then prints a one-line Telemetry.Summarize(...) confirmation rather than the full exporter dump — so the demo console stays legible. The Diagnostics page covers the collection and options surface.

A note on the configuration callback

Configuration is wired via ConfigureConfiguration((config, _) => config.AddInMemoryCollection(...)) rather than AddInMemoryCollection(...) directly on the builder. Both work and produce the same result. The callback form is shown here to demonstrate the escape hatch for ad-hoc cases — any extension method on IConfigurationBuilder is reachable from inside the callback.

The seeded values (TokenizerOptions.Defaults.Separators etc.) are intentionally redundant with TokenizerOptions.Defaults. The seed exists so configuration.GetSection(...).Get<TokenizerOptions>() inside ConfigureServices always returns a non-null POCO even before the user supplies an appsettings.json. The ?? TokenizerOptions.Defaults fallback is belt-and-suspenders.

Program.cs — wiring input to the pipeline

Program.cs is the entry point. It is short on purpose:

using OpenTelemetry.Metrics;
using Sample.Cli;
using System.Diagnostics;

var input = args.Length > 0
    ? string.Join(' ', args)
    : await Console.In.ReadToEndAsync();

List<Activity> spans = [];
List<Metric> metrics = [];
using var tracerProvider = Telemetry.CreateTracerProvider(spans);
using var meterProvider = Telemetry.CreateMeterProvider(metrics);
using var handler = Pipeline.Build(args);
var report = await handler.InvokeAsync(input);

if (report is null)
{
    await Console.Error.WriteLineAsync("pipeline returned no response");
    return 1;
}

if (report.ErrorMessage is { } error)
{
    await Console.Error.WriteLineAsync($"error: {error}");
    return 2;
}

Console.WriteLine($"original:   {report.Original}");
Console.WriteLine($"normalized: {report.Normalized}");
Console.WriteLine($"tokens:     [{string.Join(", ", report.Tokens)}]");
Console.WriteLine($"words:      {report.WordCount}");
Console.WriteLine($"elapsed:    {report.Elapsed.TotalMilliseconds:F2}ms");

_ = meterProvider.ForceFlush();
Console.WriteLine(Telemetry.Summarize(spans, metrics));
return 0;

The exit-code contract is part of the CLI surface:

Exit code Meaning
0 The pipeline produced a TextReport with no error message
1 The pipeline returned null (no middleware assigned context.Response)
2 The pipeline returned a TextReport whose ErrorMessage is set

InvokeAsync returns Task<TResponse?>. The response is nullable because nothing forces a middleware to set context.Response. Code 1 covers the case where the pipeline ran to completion but no middleware populated the response slot. In this app ReportMiddleware always sets context.Response, so code 1 is unreachable in practice — but the check is cheap and the program is honest about the type.

The using var handler = Pipeline.Build(args); line matters. RequestHandler<TReq, TRes> is IDisposable and owns the service provider Build() constructed. Letting the handler go out of scope without disposing it leaks the provider, the IConfiguration root, and any IDisposable services.

The four middleware classes

Every class middleware in Sample.Cli follows the same convention:

  • The constructor's first parameter is RequestMiddleware<string, TextReport> next.
  • InvokeAsync is declared as public Task InvokeAsync(RequestContext<string, TextReport> context, ...) — the context is always first.

Anything after those positional first parameters is resolved by Plumber — constructor parameters from the root service provider (suitable for singletons), InvokeAsync parameters from the per-request scope (suitable for everything else).

ValidationMiddleware — short-circuiting on empty input

Returns an error report and skips the rest of the pipeline when the input is blank.

internal sealed class ValidationMiddleware(RequestMiddleware<string, TextReport> next)
{
    public Task InvokeAsync(RequestContext<string, TextReport> context)
    {
        context.ThrowIfCanceled();

        if (string.IsNullOrWhiteSpace(context.Request))
        {
            context.Response = new TextReport(
                Original: context.Request ?? string.Empty,
                Normalized: string.Empty,
                Tokens: [],
                WordCount: 0,
                Elapsed: TimeSpan.Zero,
                ErrorMessage: "input must be non-empty");
            return Task.CompletedTask;
        }

        return next(context);
    }
}

The pattern: assign context.Response to a fully-populated record, then return Task.CompletedTask without calling next(context). The rest of the pipeline does not run. The timing wrapper that wraps the whole chain still observes the response on the way out and rewrites its Elapsed field — that is the symmetry of the onion model in action.

Elapsed: TimeSpan.Zero here is a placeholder; the timing wrapper at the outer layer overwrites it. Sample.Cli's tests verify that — ShortCircuitStillRecordsElapsedAsync asserts that even the validation short-circuit comes back with a non-zero Elapsed, because the timing wrapper sits outside ValidationMiddleware.

NormalizeMiddleware — writing to context.Data, with a logger

Lowercases the input and stores the result in context.Data for downstream middleware. Demonstrates constructor injection of a logger.

internal sealed class NormalizeMiddleware(
    RequestMiddleware<string, TextReport> next,
    ILogger<NormalizeMiddleware> logger)
{
    public Task InvokeAsync(RequestContext<string, TextReport> context)
    {
        context.ThrowIfCanceled();
        var normalized = context.Request.ToLowerInvariant();
        context.Data[DataKeys.Normalized] = normalized;
        logger.LogDebug("normalized {Length} chars", normalized.Length);
        return next(context);
    }
}

ILogger<T> is a singleton, so capturing it in the middleware's primary constructor is appropriate. Every request flowing through NormalizeMiddleware shares the same logger instance — which is what you want from a logger.

The middleware writes the normalized text to context.Data[DataKeys.Normalized] for downstream middleware to read. It does not modify context.Request (the request stays the original input) or assign context.Response (that happens later, in ReportMiddleware). Each step contributes one piece of intermediate state.

TokenizeMiddleware — method injection of a per-request service

Splits the normalized text into tokens using an injected ITokenizer. Demonstrates method injection on InvokeAsync.

internal sealed class TokenizeMiddleware(RequestMiddleware<string, TextReport> next)
{
    public Task InvokeAsync(RequestContext<string, TextReport> context, ITokenizer tokenizer)
    {
        context.ThrowIfCanceled();

        if (context.TryGetValue<string>(DataKeys.Normalized, out var normalized))
        {
            context.Data[DataKeys.Tokens] = tokenizer.Tokenize(normalized);
        }

        return next(context);
    }
}

ITokenizer is registered as a singleton in Sample.Cli, but it is injected via InvokeAsync rather than the constructor. That reflects the recommended pattern:

  • Constructor injection is for things guaranteed to be singletons forever — a logger, an options POCO, a TimeProvider.
  • Method injection on InvokeAsync is the safe default for services from the DI container, including singletons.

The cost of method injection is negligible — Plumber compiles the dispatcher into an expression tree once at registration. And it leaves room for the registration to change later: if ITokenizer becomes scoped or transient one day, the middleware is already correct.

context.TryGetValue<T> returns true only when the key is present and the value is non-null and the value is a T. The if guard defends against an upstream change in NormalizeMiddleware; in the current pipeline the value is always present.

ReportMiddleware — assembling the response

Reads the data accumulated upstream and produces the final TextReport. The terminal user-defined middleware in the chain.

internal sealed class ReportMiddleware(RequestMiddleware<string, TextReport> next)
{
    public Task InvokeAsync(RequestContext<string, TextReport> context)
    {
        context.ThrowIfCanceled();

        _ = context.TryGetValue<string>(DataKeys.Normalized, out var normalized);
        _ = context.TryGetValue<string[]>(DataKeys.Tokens, out var tokens);

        context.Response = new TextReport(
            Original: context.Request,
            Normalized: normalized ?? string.Empty,
            Tokens: tokens ?? [],
            WordCount: tokens?.Length ?? 0,
            Elapsed: TimeSpan.Zero,
            ErrorMessage: null);

        return next(context);
    }
}

It still calls next(context) even though it's the last user-defined middleware. Plumber installs a built-in terminal that simply returns; there is no harm and the symmetry is consistent.

Like ValidationMiddleware, it stamps Elapsed: TimeSpan.Zero and lets the timing wrapper at the outer layer overwrite it. The Tokens field is typed IReadOnlyList<string> on the record but string[] here — arrays satisfy IReadOnlyList<string> directly, so no conversion is needed.

The timing wrapper

A .Use(...) delegate, registered right after the Serilog logger, wraps the rest of the chain to measure elapsed time:

.Use(async (context, next) =>
{
    var start = DateTime.UtcNow;
    await next(context);
    if (context.Response is { } response)
    {
        context.Response = response with { Elapsed = DateTime.UtcNow - start };
    }
})

It demonstrates two things.

The onion model in one method

Code before await next(context) runs going in (it captures start); code after runs coming back out (it computes Elapsed and rewrites the response). The wrapper is registered before the class middleware — just inside the Serilog logger — so it sits near the outer layer of the onion and sees every class middleware's contribution, including any short-circuit.

That is why ShortCircuitStillRecordsElapsedAsync in the test project passes: when ValidationMiddleware short-circuits, the timing wrapper still reaches its post-await block on the way out and stamps the elapsed time.

The record with enrichment pattern

TextReport is a record. Records support with expressions to produce a copy with selected fields changed. To rewrite only the Elapsed field, the wrapper does response with { Elapsed = ... } and assigns the new instance back to context.Response. The original record is left untouched; nothing mutates in place.

This pattern is broadly useful in any pipeline whose response is a record — wrap, observe, enrich.

The logic lives outside the class middleware on purpose. It is pre/post symmetry around the entire chain. Pulling it into a class middleware would work, but the delegate form makes the structure obvious in one place and avoids an extra file for what is genuinely a one-off transformation.

DataKeys — why the string keys live in a static class

internal static class DataKeys
{
    public const string Normalized = "text.normalized";
    public const string Tokens = "text.tokens";
}

context.Data is a Dictionary<string, object> (allocated lazily on first access). Inter-middleware sharing therefore happens through string keys. DataKeys exists so those strings live in one place rather than scattered across the middleware files.

A small benefit: when reading NormalizeMiddleware and seeing context.Data[DataKeys.Normalized] = normalized;, the constant name documents the intent, and Find All References on DataKeys.Normalized immediately surfaces every reader and every writer. If you later decide to namespace the keys ("text.normalized" vs "html.normalized") or migrate to typed feature objects, the change is one constant per key.

How the shape supports testing

Sample.Cli.Tests shows two complementary testing styles, both enabled by the CreateBuilder / Configure split.

Direct invocation against the built handler

PipelineTests constructs a real handler with Pipeline.Build([]) and calls InvokeAsync against it. This exercises the production pipeline end-to-end, including all real services. It is the right tool when the question is "does the whole pipeline work?":

[Fact]
public async Task ValidInputProducesFullReportAsync()
{
    using var handler = Pipeline.Build([]);

    var report = await handler.InvokeAsync("Hello, World! FOO", TestContext.Current.CancellationToken);

    Assert.NotNull(report);
    Assert.Equal("hello, world! foo", report.Normalized);
    Assert.Equal(3, report.WordCount);
}

PlumberApplicationFactory<TReq, TRes>

FactoryTests constructs the factory by passing the CreateBuilder and Configure methods directly:

private static PlumberApplicationFactory<string, TextReport> CreateFactory() =>
    new(Pipeline.CreateBuilder, Pipeline.Configure);

The factory holds references to both halves and only invokes them when CreateHandler() (or InvokeAsync(...)) is called. Between construction and that first call, tests register hooks — WithServices, WithBuilder, WithLogging, WithConfiguration, WithInMemorySettings — that the factory weaves into the builder before calling Build().

That is the moment the split pays off: the factory slips its hooks in between the un-built builder and the built handler, and the only way to do that without rebuilding the application's whole pipeline configuration is to keep the two halves as separate methods.

The full surface — every With* hook, the CreateHandler idempotency rule, the freeze-after-create semantics — is on the PlumberApplicationFactory page. The Testing page covers the broader strategy of when to reach for the factory versus direct invocation.

See also

  • Tutorial — build a similar app from scratch (a log-line summarizer), with one concept per section.
  • Testing — testing strategy in depth: when to use direct invocation vs the factory.
  • PlumberApplicationFactory — the full factory surface, hook list, and freeze-after-create semantics.
  • Building a Pipeline — the RequestHandlerBuilder<TReq, TRes> reference.
  • Middleware — class vs delegate middleware, method injection, constructor lifetime semantics.
  • Request lifecycleRequestContext surface, Data, TryGetValue, short-circuit, Unit, timeouts.

Clone this wiki locally