Skip to content

Sample Cli Walkthrough

Mark Lauter edited this page May 10, 2026 · 6 revisions

Sample.Cli walkthrough

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

If you want to build a similar app from scratch, the Tutorial does that with a different problem (a log-line summarizer) and introduces concepts one at a time. This page assumes you have read the Tutorial (or are comfortable enough with Plumber to skip it) and walks through code that already exists.

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 of this app is slightly more elaborate than the absolute minimum needed to demonstrate Plumber. The reason is testability: Sample.Cli is also the demonstration app for PlumberApplicationFactory. 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 is the central file. Its job is to produce a fully wired RequestHandler<string, TextReport>, and it does that 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)
                .AddSimpleConsole(o =>
                {
                    o.SingleLine = true;
                    o.IncludeScopes = false;
                }))
            .ConfigureServices((services, configuration) =>
            {
                var options = configuration.GetSection(TokenizerOptions.SectionName).Get<TokenizerOptions>()
                    ?? TokenizerOptions.Defaults;
                _ = services
                    .AddSingleton(options)
                    .AddSingleton<ITokenizer, WhitespaceTokenizer>();
            });

    public static RequestHandler<string, TextReport> Configure(RequestHandler<string, TextReport> handler) =>
        handler
            .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 of them 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 in the source acknowledges the IDisposable-analyzer warning that the returned handler "looks like" a new instance to the analyzer; it isn't.

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

A small detail worth noting in CreateBuilder: configuration is wired via ConfigureConfiguration((config, _) => config.AddInMemoryCollection(...)) rather than AddInMemoryCollection(...) directly on the builder. Both work, both produce the same result. The ConfigureConfiguration callback form is shown here to demonstrate the escape hatch for ad-hoc cases — any extension method on IConfigurationBuilder is reachable from the callback.

The configuration values being seeded (TokenizerOptions.Defaults.Separators etc.) are intentionally redundant with what TokenizerOptions.Defaults would give you anyway. The seed exists so the configuration.GetSection(...).Get<TokenizerOptions>() call inside ConfigureServices always returns a non-null POCO, even before the user has supplied 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 Sample.Cli;

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

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");
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 that Build() constructed. Letting the handler go out of scope without disposing it leaks the provider, any file watchers configuration may have registered, 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.
  • The class declares public Task InvokeAsync(RequestContext<string, TextReport> context, ...) whose first parameter is the context.

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

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 is: 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 property: 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

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

This is the first middleware that takes a constructor dependency beyond next. ILogger<T> is a singleton; capturing it in the middleware's primary constructor is appropriate. Every request that flows 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 is intended to be 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

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 decision reflects the recommended pattern: constructor injection is for things that are 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 once into an expression tree 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 is defense against an upstream change in NormalizeMiddleware; in the current pipeline the value is always present.

ReportMiddleware — assembling the response

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

ReportMiddleware is the terminal middleware in the user-defined chain — it produces the final response by reading the data accumulated upstream. It still calls next(context) because 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

The first .Use(...) call in Pipeline.Configure is a delegate, not a class:

.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. The code before await next(context) runs going in (it captures start); the code after runs coming back out (it computes Elapsed and rewrites the response). The wrapper is registered first, so it sits at the outermost layer of the onion — it sees every other middleware's contribution, including any short-circuit. This is why ShortCircuitStillRecordsElapsedAsync in the test project passes: when ValidationMiddleware short-circuits, the timing wrapper still gets to 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. When the timing wrapper wants to rewrite only the Elapsed field, it 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.

This 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 pre/post 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 you read NormalizeMiddleware and see 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 to 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.

The first style is direct invocation against the built handlerPipelineTests 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);
}

The second style uses 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 can register hooks — WithServices, WithBuilder, WithLogging, WithConfiguration, WithInMemorySettings — that the factory weaves into the builder before calling Build(). That is the moment the split into CreateBuilder and Configure pays off: the factory needs to slip 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 PlumberApplicationFactory surface — every With* hook, the CreateHandler idempotency rule, the freeze-after-create semantics — is documented 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