-
Notifications
You must be signed in to change notification settings - Fork 0
Tutorial
This tutorial builds a small CLI from scratch — dotnet new console to a working pipeline with tests — and introduces one Plumber concept per section. By the end you will have a real, runnable program you understand well enough to adapt to your own work.
The CLI is a log-line summarizer: it reads log lines from stdin (or a file path passed as an argument), parses each line into a level and a message, and prints a one-screen summary (count per level, first and last timestamp, sample errors). It is intentionally a different shape from Sample.Cli, which the next page covers in depth, so the two pages reinforce each other rather than overlap.
If you have not yet read Concepts, read it first. This tutorial assumes you know what a request, a response, a middleware, and a context are.
Input — one log line per row, e.g. from a journalctl dump or an app log:
2026-05-08T10:14:00Z INFO starting worker
2026-05-08T10:14:01Z INFO loaded 12 jobs
2026-05-08T10:14:03Z WARN job 7 retried
2026-05-08T10:14:05Z ERROR job 7 failed: connection refused
2026-05-08T10:14:09Z INFO worker stopped
Output:
log summary
-----------
range: 2026-05-08T10:14:00Z .. 2026-05-08T10:14:09Z
counts: INFO=3 WARN=1 ERROR=1
errors:
2026-05-08T10:14:05Z job 7 failed: connection refused
elapsed: 1.84ms
The pipeline will be built up in this order, one section at a time:
- Project setup
- A "Hello, World" pipeline with a single delegate middleware
- Converting a delegate into a class middleware
- Adding a parser service via DI and method injection
- Loading configuration from a JSON file
- Adding logging
- Sharing data between middleware via
context.Data - Validating input and short-circuiting on bad input
- Adding a test using
PlumberApplicationFactory
Every code block compiles against Plumber 3.0.0 and .NET 10. The reader is assumed to be comfortable with C#, async/await, the Microsoft DI container, and IConfiguration. No prior ASP.NET Core middleware experience is required — see Concepts for the mental model.
Create a new console project and add the Plumber package:
dotnet new console -n LogSummary
cd LogSummary
dotnet add package MSL.Plumber.PipelinePlumber targets .NET 10; the project template generated by dotnet new console already targets net10.0 when you have the .NET 10 SDK installed.
Open Program.cs and replace its contents — we will rebuild it from the smallest possible pipeline.
The smallest useful Plumber program looks like this:
using Plumber;
using var handler = RequestHandlerBuilder
.Create<string, string>()
.Build();
handler.Use((context, next) =>
{
context.Response = $"received {context.Request.Length} chars";
return next(context);
});
var input = await Console.In.ReadToEndAsync();
var summary = await handler.InvokeAsync(input);
Console.WriteLine(summary);Three pieces are doing the work:
-
RequestHandlerBuilder.Create<string, string>()returns a builder typed to(TRequest=string, TResponse=string)..Build()produces aRequestHandler<string, string>. -
handler.Use(...)registers a delegate middleware. It receives theRequestContext<string, string>and anextdelegate; callingnext(context)continues the pipeline. (At the end of the chain Plumber installs a built-in terminal middleware that simply returns, sonext(context)is always safe to call.) -
handler.InvokeAsync(input)runs the chain and returns whatever any middleware assigned tocontext.Response.
RequestHandler<TRequest, TResponse> is IDisposable — wrap it in a using declaration so the service provider it owns is cleaned up when the program exits.
Run it:
echo "hello" | dotnet run
# received 6 chars(The trailing newline counts.) That is the entire shape of a Plumber pipeline: a typed builder, a built handler, a chain of middleware, and a call to invoke. Everything else in this tutorial layers more behavior onto this skeleton.
Returning a string is fine for "hello world" but our real output is structured. Replace string with a record:
namespace LogSummary;
public sealed record LogSummary(
DateTimeOffset First,
DateTimeOffset Last,
int InfoCount,
int WarnCount,
int ErrorCount,
IReadOnlyList<string> SampleErrors,
TimeSpan Elapsed,
string? ErrorMessage);A record works well for responses: it's immutable, easy to enrich with with, and compares by value in tests. Mark it sealed because nothing should subclass it.
Update the builder type parameter to use the new response and adjust the delegate:
using LogSummary;
using Plumber;
using var handler = RequestHandlerBuilder
.Create<string, LogSummary>()
.Build();
handler.Use((context, next) =>
{
context.Response = new LogSummary(
First: DateTimeOffset.MinValue,
Last: DateTimeOffset.MinValue,
InfoCount: 0,
WarnCount: 0,
ErrorCount: 0,
SampleErrors: [],
Elapsed: TimeSpan.Zero,
ErrorMessage: "not implemented yet");
return next(context);
});
var input = await Console.In.ReadToEndAsync();
var summary = await handler.InvokeAsync(input);
Console.WriteLine(summary);The pipeline still does nothing useful — but the types are right. From here we add behavior one middleware at a time.
Inline delegates are fine for one-off transformations. For anything with dependencies — a logger, a parser, a configuration option — write a class.
Plumber recognizes a middleware class by convention:
- The constructor's first parameter must be
RequestMiddleware<TRequest, TResponse> next. - The class must declare a
public Task InvokeAsync(...)method whose first parameter isRequestContext<TRequest, TResponse>.
Both rules are positional — only the order of these first parameters matters; the names are free to be whatever you like.
Move the placeholder logic from the delegate into a class:
using Plumber;
namespace LogSummary;
internal sealed class SummarizeMiddleware(RequestMiddleware<string, LogSummary> next)
{
public Task InvokeAsync(RequestContext<string, LogSummary> context)
{
context.Response = new LogSummary(
First: DateTimeOffset.MinValue,
Last: DateTimeOffset.MinValue,
InfoCount: 0,
WarnCount: 0,
ErrorCount: 0,
SampleErrors: [],
Elapsed: TimeSpan.Zero,
ErrorMessage: "not implemented yet");
return next(context);
}
}Register it in Program.cs with the generic Use<T>() overload:
using LogSummary;
using Plumber;
using var handler = RequestHandlerBuilder
.Create<string, LogSummary>()
.Build();
handler.Use<SummarizeMiddleware>();
var input = await Console.In.ReadToEndAsync();
var summary = await handler.InvokeAsync(input);
Console.WriteLine(summary);Plumber compiles a one-time expression-tree dispatcher for the class at registration — there is no per-invocation reflection cost.
A real summarizer needs to parse each log line. Define a parser interface and a default implementation:
namespace LogSummary;
public enum LogLevel { Info, Warn, Error, Unknown }
public sealed record LogEntry(DateTimeOffset Timestamp, LogLevel Level, string Message);
public interface ILogParser
{
LogEntry? Parse(string line);
}using System.Globalization;
namespace LogSummary;
internal sealed class WhitespaceLogParser : ILogParser
{
public LogEntry? Parse(string line)
{
var span = line.AsSpan().Trim();
if (span.IsEmpty)
{
return null;
}
// expect: <iso-timestamp> <LEVEL> <message...>
var firstSpace = span.IndexOf(' ');
if (firstSpace < 0)
{
return null;
}
if (!DateTimeOffset.TryParse(
span[..firstSpace],
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal,
out var timestamp))
{
return null;
}
var rest = span[(firstSpace + 1)..].TrimStart();
var secondSpace = rest.IndexOf(' ');
if (secondSpace < 0)
{
return null;
}
var level = rest[..secondSpace] switch
{
"INFO" => LogLevel.Info,
"WARN" => LogLevel.Warn,
"ERROR" => LogLevel.Error,
_ => LogLevel.Unknown,
};
var message = rest[(secondSpace + 1)..].ToString();
return new LogEntry(timestamp, level, message);
}
}Now register ILogParser with the builder via ConfigureServices:
using var handler = RequestHandlerBuilder
.Create<string, LogSummary>()
.ConfigureServices((services, _) =>
services.AddSingleton<ILogParser, WhitespaceLogParser>())
.Build();ConfigureServices takes a callback (IServiceCollection, IConfiguration). The callback runs at Build() time; the configuration argument is the built IConfiguration (more on that in the next section).
To use the parser inside the middleware, declare it as an extra parameter on InvokeAsync. Plumber resolves additional InvokeAsync parameters from the per-request DI scope every time the pipeline runs. This is method injection, and it is the recommended pattern for any service you want resolved per request:
using Plumber;
namespace LogSummary;
internal sealed class SummarizeMiddleware(RequestMiddleware<string, LogSummary> next)
{
public Task InvokeAsync(
RequestContext<string, LogSummary> context, // first param is always the context
ILogParser parser) // resolved fresh on every request
{
context.ThrowIfCanceled();
var entries = ParseAll(context.Request, parser);
context.Response = Summarize(entries);
return next(context);
}
private static List<LogEntry> ParseAll(string input, ILogParser parser)
{
var entries = new List<LogEntry>();
foreach (var line in input.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
if (parser.Parse(line) is { } entry)
{
entries.Add(entry);
}
}
return entries;
}
private static LogSummary Summarize(IReadOnlyList<LogEntry> entries)
{
if (entries.Count == 0)
{
return new LogSummary(
First: DateTimeOffset.MinValue,
Last: DateTimeOffset.MinValue,
InfoCount: 0,
WarnCount: 0,
ErrorCount: 0,
SampleErrors: [],
Elapsed: TimeSpan.Zero,
ErrorMessage: "no parseable lines");
}
var info = 0;
var warn = 0;
var err = 0;
var sampleErrors = new List<string>();
foreach (var e in entries)
{
switch (e.Level)
{
case LogLevel.Info: info++; break;
case LogLevel.Warn: warn++; break;
case LogLevel.Error:
err++;
if (sampleErrors.Count < 3)
{
sampleErrors.Add($"{e.Timestamp:O} {e.Message}");
}
break;
}
}
return new LogSummary(
First: entries[0].Timestamp,
Last: entries[^1].Timestamp,
InfoCount: info,
WarnCount: warn,
ErrorCount: err,
SampleErrors: sampleErrors,
Elapsed: TimeSpan.Zero,
ErrorMessage: null);
}
}Two notes on the shape:
-
context.ThrowIfCanceled()checks the per-request cancellation token and throwsOperationCanceledExceptionif the caller cancelled. The terminal middleware at the end of the pipeline already does this before invoking, so the explicit call here is defense-in-depth — useful in middleware that does meaningful work before deferring tonext. - Resolving
ILogParservia method injection works regardless of how it is registered (AddSingleton,AddScoped,AddTransient). Each call tohandler.InvokeAsyncopens a new DI scope, runs the pipeline against that scope, and disposes it on the way out.
We will rewrite the Summarize step into a smaller, dedicated middleware once we have configuration and a sample-error limit to drive — but right now the pipeline is end-to-end working.
The "show three sample errors" rule should not be hard-coded. Add an appsettings.json:
{
"Summary": {
"SampleErrorLimit": 3
}
}Mark it as content so it's copied to the output directory. Add the following to LogSummary.csproj:
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>Define a POCO for the bound section:
namespace LogSummary;
public sealed record SummaryOptions(int SampleErrorLimit)
{
public const string SectionName = "Summary";
public static SummaryOptions Defaults { get; } = new(SampleErrorLimit: 3);
}Configuration in v3 is opt-in — Plumber loads nothing automatically except the command-line args you pass to Create. Add the JSON file to the builder, then register the bound POCO inside ConfigureServices so it sees the built IConfiguration:
using LogSummary;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Plumber;
using var handler = RequestHandlerBuilder
.Create<string, LogSummary>(args)
.AddJsonFile("appsettings.json", optional: true)
.ConfigureServices((services, configuration) =>
{
var options = configuration.GetSection(SummaryOptions.SectionName).Get<SummaryOptions>()
?? SummaryOptions.Defaults;
_ = services
.AddSingleton(options)
.AddSingleton<ILogParser, WhitespaceLogParser>();
})
.Build();
handler.Use<SummarizeMiddleware>();
var input = args.Length > 0
? await File.ReadAllTextAsync(args[0])
: await Console.In.ReadToEndAsync();
var summary = await handler.InvokeAsync(input);
Console.WriteLine(Render(summary));
return 0;
static string Render(LogSummary? s)
{
if (s is null) return "no summary";
if (s.ErrorMessage is { } e) return $"error: {e}";
var lines = new List<string>
{
"log summary",
"-----------",
$"range: {s.First:O} .. {s.Last:O}",
$"counts: INFO={s.InfoCount} WARN={s.WarnCount} ERROR={s.ErrorCount}",
};
if (s.SampleErrors.Count > 0)
{
lines.Add("errors:");
foreach (var err in s.SampleErrors) lines.Add($" {err}");
}
lines.Add($"elapsed: {s.Elapsed.TotalMilliseconds:F2}ms");
return string.Join(Environment.NewLine, lines);
}Then update SummarizeMiddleware to inject the bound options and respect the limit:
public Task InvokeAsync(
RequestContext<string, LogSummary> context,
ILogParser parser,
SummaryOptions options)
{
context.ThrowIfCanceled();
var entries = ParseAll(context.Request, parser);
context.Response = Summarize(entries, options.SampleErrorLimit);
return next(context);
}The Get<T>()-and-fall-back-to-defaults pattern is idiomatic for Plumber — configuration is opt-in, so the application keeps working when the JSON file is missing.
If you want the conventional set of sources (appsettings.json, appsettings.{env}.json, DOTNET_* and unprefixed environment variables), one call replaces several:
RequestHandlerBuilder.Create<string, LogSummary>(args)
.AddDefaultConfigurationSources();User secrets are intentionally excluded from AddDefaultConfigurationSources(). To include them, call AddUserSecrets<T>() explicitly with a type from your assembly.
Logging is opt-in too. Calling ConfigureLogging registers the logging infrastructure; leave it out and constructor parameters typed ILogger<T> will fail to resolve.
Turn it on:
using Microsoft.Extensions.Logging;
// inside the builder chain:
.ConfigureLogging(logging => logging
.SetMinimumLevel(LogLevel.Information)
.AddSimpleConsole(o =>
{
o.SingleLine = true;
o.IncludeScopes = false;
}))ILogger<T> is a singleton, which makes it a safe constructor dependency — its lifetime matches the middleware's own (Plumber constructs the middleware once at registration and reuses that instance for every request). Wire a logger into SummarizeMiddleware by adding it to the constructor:
using Microsoft.Extensions.Logging;
using Plumber;
namespace LogSummary;
internal sealed class SummarizeMiddleware(
RequestMiddleware<string, LogSummary> next,
ILogger<SummarizeMiddleware> logger)
{
public Task InvokeAsync(
RequestContext<string, LogSummary> context,
ILogParser parser,
SummaryOptions options)
{
context.ThrowIfCanceled();
var entries = ParseAll(context.Request, parser);
logger.LogInformation("parsed {Count} entries for {Id}", entries.Count, context.Id);
context.Response = Summarize(entries, options.SampleErrorLimit);
return next(context);
}
// ParseAll and Summarize unchanged...
}The rule for picking constructor vs method injection is straightforward: use constructor injection for singletons (loggers, options, TimeProvider); use method injection on InvokeAsync for anything that should be resolved per request (DbContext, HttpClient, scoped services, transient services). The middleware itself is constructed once and reused; constructor parameters are captured for the lifetime of the handler.
Right now SummarizeMiddleware does two jobs: parsing and summarizing. Split them so that each step is a single, focused unit. They will need to share intermediate state — that is what RequestContext.Data is for.
A small constants class keeps the string keys honest:
namespace LogSummary;
internal static class DataKeys
{
public const string Entries = "log.entries";
}The parse middleware:
using Microsoft.Extensions.Logging;
using Plumber;
namespace LogSummary;
internal sealed class ParseMiddleware(
RequestMiddleware<string, LogSummary> next,
ILogger<ParseMiddleware> logger)
{
public Task InvokeAsync(
RequestContext<string, LogSummary> context,
ILogParser parser)
{
context.ThrowIfCanceled();
var entries = new List<LogEntry>();
foreach (var line in context.Request.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
if (parser.Parse(line) is { } entry)
{
entries.Add(entry);
}
}
context.Data[DataKeys.Entries] = entries;
logger.LogInformation("parsed {Count} entries", entries.Count);
return next(context);
}
}The summarize middleware reads what the parse step wrote:
using Plumber;
namespace LogSummary;
internal sealed class SummarizeMiddleware(RequestMiddleware<string, LogSummary> next)
{
public Task InvokeAsync(
RequestContext<string, LogSummary> context,
SummaryOptions options)
{
context.ThrowIfCanceled();
if (!context.TryGetValue<List<LogEntry>>(DataKeys.Entries, out var entries) || entries.Count == 0)
{
context.Response = new LogSummary(
First: DateTimeOffset.MinValue,
Last: DateTimeOffset.MinValue,
InfoCount: 0,
WarnCount: 0,
ErrorCount: 0,
SampleErrors: [],
Elapsed: TimeSpan.Zero,
ErrorMessage: "no parseable lines");
return next(context);
}
var info = 0;
var warn = 0;
var err = 0;
var sampleErrors = new List<string>(options.SampleErrorLimit);
foreach (var e in entries)
{
switch (e.Level)
{
case LogLevel.Info: info++; break;
case LogLevel.Warn: warn++; break;
case LogLevel.Error:
err++;
if (sampleErrors.Count < options.SampleErrorLimit)
{
sampleErrors.Add($"{e.Timestamp:O} {e.Message}");
}
break;
}
}
context.Response = new LogSummary(
First: entries[0].Timestamp,
Last: entries[^1].Timestamp,
InfoCount: info,
WarnCount: warn,
ErrorCount: err,
SampleErrors: sampleErrors,
Elapsed: TimeSpan.Zero,
ErrorMessage: null);
return next(context);
}
}Register them in order — the same order they will execute:
handler
.Use<ParseMiddleware>()
.Use<SummarizeMiddleware>();A few details about TryGetValue<T>:
- It returns
falsefor missing keys, null values, and type mismatches. You only gettruewhen the dictionary holds a non-nullTat the key. - For value types, the check is
value is T— so the default value of a value type still counts as "present." If you stored0for anintkey,TryGetValue<int>("k", out var v)returnstruewithv == 0. Plan key names so a missing key and a present-but-default value mean the same thing, or use a reference-typed wrapper. - The dictionary is allocated lazily on first access; pipelines that share no data pay no allocation cost.
A middleware that does not call next skips the rest of the pipeline. That is the canonical pattern for validation, caching, and authorization: assign context.Response to whatever the rest of the chain would have produced, then return without calling next.
Add a validation middleware at the front of the pipeline:
using Plumber;
namespace LogSummary;
internal sealed class ValidationMiddleware(RequestMiddleware<string, LogSummary> next)
{
public Task InvokeAsync(RequestContext<string, LogSummary> context)
{
context.ThrowIfCanceled();
if (string.IsNullOrWhiteSpace(context.Request))
{
context.Response = new LogSummary(
First: DateTimeOffset.MinValue,
Last: DateTimeOffset.MinValue,
InfoCount: 0,
WarnCount: 0,
ErrorCount: 0,
SampleErrors: [],
Elapsed: TimeSpan.Zero,
ErrorMessage: "input must be non-empty");
return Task.CompletedTask; // short-circuit: no next() call
}
return next(context);
}
}Register it first:
handler
.Use<ValidationMiddleware>()
.Use<ParseMiddleware>()
.Use<SummarizeMiddleware>();Middleware registered earlier than ValidationMiddleware would still observe the short-circuit on the way out: code after their own await next(context) runs normally with context.Response already populated.
A timing wrapper is a nice illustration of the onion shape — start a clock before next, stop it after, and use the response record's with expression to enrich the response in-place. Add it as a delegate before any class middleware so it wraps the whole chain:
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<ParseMiddleware>()
.Use<SummarizeMiddleware>();Because the wrapper sits at the outermost layer of the onion, its Elapsed measurement covers everything inside — including the validation short-circuit. That is usually what you want from a timing middleware.
Up to this point Program.cs builds the handler inline. To keep the program shape clean and to make the pipeline straightforward to test, split the build into two halves:
-
CreateBuilder(args)— returns the un-builtRequestHandlerBuilder<TReq, TRes>with all configuration sources, services, and logging registered. -
Configure(handler)— adds middleware to a built handler and returns it.
A wrapper Build calls both:
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Plumber;
using System.Diagnostics.CodeAnalysis;
namespace LogSummary;
internal static class Pipeline
{
public static RequestHandlerBuilder<string, LogSummary> CreateBuilder(string[] args) =>
RequestHandlerBuilder.Create<string, LogSummary>(args)
.AddJsonFile("appsettings.json", optional: true)
.ConfigureLogging(logging => logging
.SetMinimumLevel(LogLevel.Information)
.AddSimpleConsole(o =>
{
o.SingleLine = true;
o.IncludeScopes = false;
}))
.ConfigureServices((services, configuration) =>
{
var options = configuration.GetSection(SummaryOptions.SectionName).Get<SummaryOptions>()
?? SummaryOptions.Defaults;
_ = services
.AddSingleton(options)
.AddSingleton<ILogParser, WhitespaceLogParser>();
});
[SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP004:Don't ignore created IDisposable",
Justification = "fluent .Use() returns the same handler instance; caller disposes")]
public static RequestHandler<string, LogSummary> Configure(RequestHandler<string, LogSummary> 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<ParseMiddleware>()
.Use<SummarizeMiddleware>();
[SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP004:Don't ignore created IDisposable",
Justification = "handler ownership transfers to caller via return value")]
public static RequestHandler<string, LogSummary> Build(string[] args) =>
Configure(CreateBuilder(args).Build());
}Program.cs shrinks to a few lines, with proper exit codes:
using LogSummary;
var input = args.Length > 0
? await File.ReadAllTextAsync(args[0])
: await Console.In.ReadToEndAsync();
using var handler = Pipeline.Build(args);
var summary = await handler.InvokeAsync(input);
if (summary is null)
{
await Console.Error.WriteLineAsync("pipeline returned no response");
return 1;
}
if (summary.ErrorMessage is { } error)
{
await Console.Error.WriteLineAsync($"error: {error}");
return 2;
}
Console.WriteLine(Render(summary));
return 0;
static string Render(LogSummary s)
{
var lines = new List<string>
{
"log summary",
"-----------",
$"range: {s.First:O} .. {s.Last:O}",
$"counts: INFO={s.InfoCount} WARN={s.WarnCount} ERROR={s.ErrorCount}",
};
if (s.SampleErrors.Count > 0)
{
lines.Add("errors:");
foreach (var err in s.SampleErrors) lines.Add($" {err}");
}
lines.Add($"elapsed: {s.Elapsed.TotalMilliseconds:F2}ms");
return string.Join(Environment.NewLine, lines);
}Run it end-to-end:
cat sample.log | dotnet runThe split between CreateBuilder and Configure is the move that unlocks testing. The next section explains why.
Create a sibling test project:
dotnet new xunit3 -n LogSummary.Tests
cd LogSummary.Tests
dotnet add reference ../LogSummary/LogSummary.csprojReference Plumber.Testing directly from the source repo (it is in preview and not yet on NuGet at the time of writing — see Testing for the current state). Once the package is published, swap the <ProjectReference> for dotnet add package MSL.Plumber.Testing.
A direct end-to-end test against the built handler is the simplest possible thing:
using LogSummary;
namespace LogSummary.Tests;
public sealed class PipelineTests
{
[Fact]
public async Task ValidInputProducesSummaryAsync()
{
using var handler = Pipeline.Build([]);
var input = string.Join('\n',
"2026-05-08T10:14:00Z INFO starting",
"2026-05-08T10:14:01Z WARN slow",
"2026-05-08T10:14:02Z ERROR boom");
var summary = await handler.InvokeAsync(input, TestContext.Current.CancellationToken);
Assert.NotNull(summary);
Assert.Null(summary.ErrorMessage);
Assert.Equal(1, summary.InfoCount);
Assert.Equal(1, summary.WarnCount);
Assert.Equal(1, summary.ErrorCount);
Assert.True(summary.Elapsed > TimeSpan.Zero);
}
[Fact]
public async Task EmptyInputShortCircuitsAsync()
{
using var handler = Pipeline.Build([]);
var summary = await handler.InvokeAsync(string.Empty, TestContext.Current.CancellationToken);
Assert.NotNull(summary);
Assert.Equal("input must be non-empty", summary.ErrorMessage);
}
}PipelineTests runs the real pipeline with all real services, which is ideal for end-to-end coverage. For tests that need to swap a service — say, replacing ILogParser with a stub that always returns a known entry — PlumberApplicationFactory<TReq, TRes> is the hook:
using LogSummary;
using Plumber.Testing;
namespace LogSummary.Tests;
public sealed class FactoryTests
{
private static PlumberApplicationFactory<string, LogSummary> CreateFactory() =>
new(Pipeline.CreateBuilder, Pipeline.Configure);
[Fact]
public async Task FactoryRunsRealPipelineAsync()
{
using var factory = CreateFactory();
var summary = await factory.InvokeAsync(
"2026-05-08T10:14:00Z INFO ok",
TestContext.Current.CancellationToken);
Assert.NotNull(summary);
Assert.Equal(1, summary.InfoCount);
}
[Fact]
public async Task SwapParserWithStubAsync()
{
using var factory = CreateFactory()
.WithServices(services => services.AddSingleton<ILogParser>(new StubParser(
new LogEntry(DateTimeOffset.UnixEpoch, LogLevel.Error, "stubbed"))));
var summary = await factory.InvokeAsync(
"any input",
TestContext.Current.CancellationToken);
Assert.NotNull(summary);
Assert.Equal(1, summary.ErrorCount);
Assert.Single(summary.SampleErrors);
}
private sealed class StubParser(LogEntry entry) : ILogParser
{
public LogEntry? Parse(string line) => entry;
}
}Two things to notice:
-
PlumberApplicationFactory<TReq, TRes>takesPipeline.CreateBuilderandPipeline.Configuredirectly. That is why the pipeline split matters: the factory needs to insert itsWithServices/WithBuilderhooks between the two halves — after the builder is created, before it is built — and the only way to do that cleanly is to keep them as separate methods. -
WithServicesregisters the stub at the end of the service collection, so it overrides the productionILogParserregistration. The factory isIDisposable; one factory per test, wrapped inusing.
CreateHandler() is idempotent: calling it twice returns the same handler. Adding a WithServices/WithBuilder hook after CreateHandler() (or InvokeAsync) has been called throws InvalidOperationException — the builder is frozen.
Run the tests:
dotnet testYou should see all green. You now have a real, working pipeline with end-to-end tests, factory-based tests, and a clean split between building and configuring that supports both styles.
- Sample.Cli walkthrough — guided tour of the realistic sample app in the repo. Same shape, different problem (text tokenization).
-
Building a Pipeline — full reference for
RequestHandlerBuilder<TReq, TRes>: every configuration source, every callback, everyBuild()overload. - Middleware — delegate vs class middleware, method injection, constructor injection lifetime semantics, generic middleware.
-
Request lifecycle — the full
RequestContextsurface, short-circuit semantics,Unit, timeouts and cancellation, error handling. -
Testing — testing strategy in depth, including direct invocation vs
PlumberApplicationFactoryand when to use each.
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