-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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 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.
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 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 halves — Configure(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 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.
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).
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.
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.
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.
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 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.
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.
Sample.Cli.Tests shows two complementary testing styles, both enabled by the CreateBuilder / Configure split.
The first style is 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);
}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.
- 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