Skip to content

Tutorial

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

Tutorial

You'll build a log-line summarizer CLI: 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. Each section adds one Plumber concept. By the end you have a runnable program with tests.

The example is intentionally a different shape from Sample.Cli, so the two pages reinforce each other rather than overlap.

What we will build

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

Contents

  1. Project setup
  2. A first pipeline
  3. Switching the response type to something useful
  4. Class middleware
  5. Adding a parser service via DI
  6. Adding configuration
  7. Adding logging
  8. Splitting parse and summarize, sharing data between them
  9. Validating input and short-circuiting
  10. Splitting the pipeline build for testability
  11. Adding a test
  12. Where to next

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.

If you have not yet read Concepts, start there — it covers the mental model (request, response, middleware, context, onion execution) that this tutorial assumes.

Project setup

Create a new console project and add the Plumber package:

dotnet new console -n LogSummary
cd LogSummary
dotnet add package MSL.Plumber.Pipeline

Plumber targets .NET 10. The dotnet new console template already targets net10.0 when you have the .NET 10 SDK installed.

Open Program.cs and replace its contents — the next section rebuilds it from the smallest possible pipeline.

A first pipeline

The smallest useful Plumber program:

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:

  1. RequestHandlerBuilder.Create<string, string>() returns a builder typed to (TRequest=string, TResponse=string). .Build() produces a RequestHandler<string, string>.
  2. handler.Use(...) registers a delegate middleware. It receives the RequestContext<string, string> and a next delegate; calling next(context) continues the pipeline. (At the end of the chain Plumber installs a built-in terminal middleware that simply returns, so next(context) is always safe to call.)
  3. handler.InvokeAsync(input) runs the chain and returns whatever any middleware assigned to context.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.

Switching the response type to something useful

The real output is structured, so 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 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, behavior gets added one middleware at a time.

Class middleware

Inline delegates handle one-off transformations. Anything with dependencies — a logger, a parser, a configuration option — is 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 is RequestContext<TRequest, TResponse>.

Both rules are positional — only the order of these first parameters matters; the names can 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.

Adding a parser service via DI

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

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 — see the Adding configuration section below for how it gets populated.

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 — 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 throws OperationCanceledException if 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 to next.
  • Resolving ILogParser via method injection works regardless of how it is registered (AddSingleton, AddScoped, AddTransient). Each call to handler.InvokeAsync opens a new DI scope, runs the pipeline against that scope, and disposes it on the way out.

The pipeline is end-to-end working. The next section pulls the sample-error limit out of code into configuration.

Adding configuration

The "show three sample errors" rule belongs in configuration, not code. 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 passed 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);
}

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.

For 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.

Adding logging

Logging is opt-in. Without ConfigureLogging, constructor parameters typed ILogger<T> 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:

  • Constructor injection for singletons — loggers, options, TimeProvider.
  • Method injection on InvokeAsync for anything resolved per request — DbContext, HttpClient, scoped or transient services.

The middleware itself is constructed once and reused; constructor parameters are captured for the lifetime of the handler.

Splitting parse and summarize, sharing data between them

SummarizeMiddleware currently does two jobs: parsing and summarizing. Split them so each step is a single, focused unit. They share intermediate state through RequestContext.Data.

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 false for missing keys, null values, and type mismatches. You only get true when the dictionary holds a non-null T at 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 stored 0 for an int key, TryGetValue<int>("k", out var v) returns true with v == 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.

Validating input and short-circuiting

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 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 around the whole chain

A timing wrapper illustrates 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.

Splitting the pipeline build for testability

Split the inline Program.cs build into two halves so tests can swap services between them:

  1. CreateBuilder(args) — returns the un-built RequestHandlerBuilder<TReq, TRes> with all configuration sources, services, and logging registered.
  2. 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 run

The split between CreateBuilder and Configure is the move that unlocks testing. The next section shows why.

Adding a test

Create a sibling test project:

dotnet new xunit3 -n LogSummary.Tests
cd LogSummary.Tests
dotnet add reference ../LogSummary/LogSummary.csproj

Reference 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 and the broader testing strategy. 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 — 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> takes Pipeline.CreateBuilder and Pipeline.Configure directly. That is why the pipeline split matters: the factory inserts its WithServices / WithBuilder hooks 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.
  • WithServices registers the stub at the end of the service collection, so it overrides the production ILogParser registration. The factory is IDisposable; one factory per test, wrapped in using.

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 test

All green. You now have a working pipeline with end-to-end tests, factory-based tests, and a clean split between building and configuring that supports both styles.

Where to next

  • 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, every Build() overload.
  • Middleware — delegate vs class middleware, method injection, constructor injection lifetime semantics, generic middleware.
  • Request lifecycle — the full RequestContext surface, short-circuit semantics, Unit, timeouts and cancellation, error handling.
  • Testing — testing strategy in depth, including direct invocation vs PlumberApplicationFactory and when to use each.

Clone this wiki locally