-
Notifications
You must be signed in to change notification settings - Fork 0
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).
End to end:
- Read input from
args(joined by spaces) if any are present, otherwise from stdin. - 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 incontext.Data. -
Report — assembles a
TextReportrecord from the values incontext.Dataand assigns it tocontext.Response.
- A timing wrapper around the whole chain measures elapsed time and rewrites the response with the elapsed value.
- Print the report to stdout, or print the error message to stderr.
- Exit with
0on success,1if no response was produced,2if 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.84msThe 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.
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 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 halves — Configure(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.
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.
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.
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 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.
Every class middleware in Sample.Cli follows the same convention:
- The constructor's first parameter is
RequestMiddleware<string, TextReport> next. -
InvokeAsyncis declared aspublic 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).
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.
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.
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
InvokeAsyncis 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.
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.
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.
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.
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.
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.
Sample.Cli.Tests shows two complementary testing styles, both enabled by the CreateBuilder / Configure split.
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);
}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.
- 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 lifecycle —
RequestContextsurface,Data,TryGetValue, short-circuit,Unit, timeouts.
Documents Plumber v4.x · Repository · MIT License · Report an issue
Getting Started
Pipeline (core)
Testing
Serilog Extensions
Diagnostics
Recipes
- AWS Lambda — API Gateway
- AWS Lambda — SQS
- Azure Functions — HTTP
- SQS polling console
- ASP.NET Core integration
- BackgroundService worker
- Webhook receiver
- Multi-command CLI
- File watcher
- Configuration reload
Repo · NuGet · NuGet — Testing · NuGet — Serilog · NuGet — Diagnostics