Skip to content

Developing Middleware#

Here are some of the tips we've picked up while developing middleware and other extensibility components for CommandDotNet.

Diagnostics tools are your friend#

Take advantage the debug, parse & command logger features.

TestTools#

The test tools were developed specifically to make it easier to test middleware. Checkout testing middleware for some tips.

Descriptive method names#

When registering a middlware delegate via .UseMiddleware(delegate, step), use a method instead of a delegate and give the method an informative name so it's clear when it shows up in an exception stacktrace or command logger output

  MiddlewarePipeline:
    TokenizerPipeline.TokenizeInputMiddleware
    ClassModelingMiddleware.CreateRootCommand
    CommandParser.ParseInputMiddleware
    ClassModelingMiddleware.AssembleInvocationPipelineMiddleware
    HelpMiddleware.DisplayHelp
    BindValuesMiddleware.BindValues
    ResolveCommandClassesMiddleware.ResolveCommandClassInstances
    AppRunnerTestExtensions.InjectTestCaptures
    CommandLoggerMiddleware.CommandLogger
    ClassModelingMiddleware.InvokeInvocationPipelineMiddleware

Specifying middleware order#

Understanding Middleware Stages

The middleware pipeline is divided into 8 distinct stages (4 core, 4 extensibility). Understanding these stages is critical for registering middleware correctly.

See the Middleware Pipeline documentation for the complete diagram and explanation of when each stage runs and what CommandContext properties are available at each stage.

Middleware Pipeline

You can register middleware using appRunner.Configure(c => c.UseMiddleware(MyMethod, MiddlewareStages.PreTokenize)). Middleware will be added in the order you provide for each stage.

appRunner.Configure(c => 
    c
        .UseMiddleware(MiddlwareA, MiddlewareStages.PreTokenize)
        .UseMiddleware(MiddlwareB, MiddlewareStages.PostParseInputPreBindValues)
        .UseMiddleware(MiddlwareC, MiddlewareStages.PreTokenize)
    ))

In this example, MiddlewareC will run immediately after MiddlewareA in the PreTokenize stage and MiddlewareB will run afterwards in the PostParseInputPreBindValues stage.

Specifying order relative to a framework middleware#

If you need to ensure your middleware needs to run immediately before or after a framework middleware, you can use the static class MiddlewareSteps to get the order for the given middleware.

For example, if you need middleware to run before MiddlewareSteps.DebugDirective...

appRunner.Configure(c => 
    c
        .UseMiddleware(MiddlwareA, MiddlewareSteps.DebugDirective - 1)
    ))

These values of the order can change between releases always use the MiddlwareSteps value.

The steps are generally separated by at least a value of 1000 and relative to zero, short.Min or short.Max.

Middleware Config#

It's convenient to use a delegate for middleware when you have additional parameters to pass in. The pattern we've adopted in CommandDotNet uses a private Config class to keep the parameters.

public static FluentValidationMiddleware
{
    public static AppRunner UseFluentValidation(this AppRunner appRunner, bool showHelpOnError = false)
    {
        return appRunner.Configure(c =>
        {
            c.UseMiddleware(ValidateModels, MiddlewareSteps.FluentValidation);
            c.Services.Add(new Config(showHelpOnError));
        });
    }

    private class Config
    {
        public bool ShowHelpOnError { get; }
        public Config(bool showHelpOnError) => 
            ShowHelpOnError = showHelpOnError;
    }

    private static Task<int> ValidateModels(CommandContext ctx, ExecutionDelegate next)
    {
        var showHelpOnError = ctx.AppConfig.Services.GetOrThrow<Config>().ShowHelpOnError;
        ... 
    }
}

This example is from our FluentValidation middleware. Notice UseMiddleware is called before adding the config. This is intentional because the UseMiddleware method will throws an informative error message when a middleware is registered multiple times. Adding a duplicate service throws duplicate key exception and is less actionable.

UseTokenTransformation and UseParameterResolver also throw informative error messages so use one of those three methods first to take advantage of the duplicate registration checks.

Registering middleware multiple times#

By default, an exception will be thrown if a middleware delegate is registered more than once. Override this with the option allowMultipleRegistrations parameter in the UseMiddleware registration method. UseMiddleware(MyMethod, step, allowMultipleRegistrations: true)

Error messages#

When possible, middleware should handle it's own errors, printing a message to the console and returning an exit code.

If help should be shown, set commandContext.ShowHelpOnExit=true.

Common Patterns#

Each pattern below shows the recommended MiddlewareStages for registration. Refer to the Middleware Pipeline diagram to understand when each stage executes and what data is available.

Read-Only Middleware#

Middleware that only reads from CommandContext and doesn't modify state, with its registration extension method:

private static Task<int> LogCommandMiddleware(CommandContext ctx, ExecutionDelegate next)
{
    // Read from context
    var command = ctx.ParseResult?.TargetCommand?.Name ?? "unknown";
    Console.WriteLine($"Executing: {command}");

    // Continue pipeline
    return next(ctx);
}

public static AppRunner UseCommandLogging(this AppRunner appRunner)
{
    return appRunner.Configure(c => c.UseMiddleware(
        LogCommandMiddleware, 
        MiddlewareStages.PostParseInputPreBindValues));
}
snippet source | anchor

Use cases: Logging, diagnostics, monitoring

Enrichment Middleware#

Middleware that adds data to CommandContext or Services, with its registration extension method:

internal class Database { public Database(string connectionString) { } }

private static Task<int> InjectDatabaseMiddleware(CommandContext ctx, ExecutionDelegate next)
{
    // Add service for commands to use
    var connectionString = ctx.AppConfig.Services.GetOrThrow<Config>().ConnectionString;
    var db = new Database(connectionString);
    ctx.Services.Add(db);

    return next(ctx);
}

public static AppRunner UseDatabaseInjection(this AppRunner appRunner, string connectionString)
{
    return appRunner.Configure(c =>
    {
        c.Services.Add(new Config(connectionString));
        c.UseMiddleware(
            InjectDatabaseMiddleware,
            MiddlewareStages.PostBindValuesPreInvoke);
    });
}
snippet source | anchor

Use cases: Dependency injection, service initialization, context enrichment

Validation Middleware#

Middleware that validates state and short-circuits on failure, with its registration extension method:

private static Task<int> ValidateArgsMiddleware(CommandContext ctx, ExecutionDelegate next)
{
    var parseResult = ctx.ParseResult;
    if (parseResult == null)
    {
        return Task.FromResult(ExitCodes.Error);
    }

    // Perform validation
    var errors = ValidateArguments(parseResult);
    if (errors.Any())
    {
        foreach (var error in errors)
        {
            ctx.Console.Error.WriteLine(error);
        }
        ctx.ShowHelpOnExit = true;
        return Task.FromResult(ExitCodes.ValidationError);
    }

    // Validation passed, continue
    return next(ctx);
}

private static string[] ValidateArguments(ParseResult parseResult) => Array.Empty<string>();

public static AppRunner UseCustomValidation(this AppRunner appRunner)
{
    return appRunner.Configure(c => c.UseMiddleware(
        ValidateArgsMiddleware,
        MiddlewareStages.PostParseInputPreBindValues));
}
snippet source | anchor

Use cases: Argument validation, permission checks, precondition verification

Wrapper Middleware#

Middleware that performs actions before and after command execution, with its registration extension method:

private static async Task<int> TransactionMiddleware(CommandContext ctx, ExecutionDelegate next)
{
    var config = ctx.AppConfig.Services.GetOrThrow<Config>();
    var db = ctx.Services.GetOrThrow<Database>();

    using var transaction = db.BeginTransaction();
    try
    {
        // Execute command
        var exitCode = await next(ctx);

        // Commit or rollback based on result and dryrun setting
        if (exitCode == 0 && !config.DryRun)
        {
            transaction.Commit();
            ctx.Console.WriteLine("Transaction committed");
        }
        else
        {
            transaction.Rollback();
            ctx.Console.WriteLine("Transaction rolled back");
        }

        return exitCode;
    }
    catch
    {
        transaction.Rollback();
        throw;
    }
}

public static AppRunner UseTransactions(this AppRunner appRunner, string connectionString, bool dryRun = false)
{
    return appRunner.Configure(c =>
    {
        c.Services.Add(new Config(connectionString) { DryRun = dryRun });
        c.UseMiddleware(
            TransactionMiddleware,
            MiddlewareStages.PostBindValuesPreInvoke);
    });
}
snippet source | anchor

Use cases: Transactions, timing, resource management, exception handling

Anti-Patterns to Avoid#

❌ Modifying Arguments After Binding#

Don't modify argument values after the BindValues stage. See the Middleware Pipeline to understand when BindValues runs.

// BAD - modifying after binding
private static Task<int> BadMiddleware_ModifyingAfterBinding(CommandContext ctx, ExecutionDelegate next)
{
    // This happens too late - values are already bound to method parameters
    // This middleware is registered in PostBindValuesPreInvoke but tries to modify argument values
    var arg = ctx.ParseResult?.TargetCommand?.Operands.FirstOrDefault();
    if (arg is not null)
    {
        arg.Value = "modified";  // Too late! Already bound to parameters
    }
    return next(ctx);
}

// BAD - Wrong stage for modifying argument values
public static AppRunner UseBadArgumentModifier(this AppRunner appRunner)
{
    return appRunner.Configure(c => c.UseMiddleware(
        BadMiddleware_ModifyingAfterBinding,
        MiddlewareStages.PostBindValuesPreInvoke));  // Too late! Use PostParseInputPreBindValues instead
}
snippet source | anchor

Why: Values have already been bound to method parameters in BindValues stage. To modify argument values, register in MiddlewareStages.PostParseInputPreBindValues or earlier.

❌ Catching and Hiding Exceptions#

Don't swallow exceptions without proper handling:

// BAD - hiding errors
private static async Task<int> BadMiddleware_HidingExceptions(CommandContext ctx, ExecutionDelegate next)
{
    try
    {
        return await next(ctx);
    }
    catch (Exception)
    {
        return 0;  // Pretending everything is fine - BAD!
    }
}
snippet source | anchor

Why: Makes debugging impossible. Either handle specifically or let it propagate.

❌ Registering in Wrong Stage#

Don't register middleware in a stage where required data isn't available. Review the Middleware Pipeline diagram to understand what CommandContext properties are populated at each stage.

// BAD - checking ParseResult in PreTokenize stage
private static Task<int> BadMiddleware_WrongStage(CommandContext ctx, ExecutionDelegate next)
{
    var result = ctx.ParseResult;  // null in PreTokenize!
    if (result?.TargetCommand is null)
    {
        return Task.FromResult(ExitCodes.Error);
    }
    return next(ctx);
}

// BAD - Registered in wrong stage
public static AppRunner UseBadParseResultChecker(this AppRunner appRunner)
{
    return appRunner.Configure(c => c.UseMiddleware(
        BadMiddleware_WrongStage,
        MiddlewareStages.PreTokenize));  // ParseResult not available yet!
}
snippet source | anchor

Fix: Register in MiddlewareStages.PostParseInputPreBindValues or later where ParseResult is available.

❌ Stateful Middleware#

Don't store state in middleware class fields:

// BAD - shared state between invocations
public class BadMiddleware_Stateful
{
    private static int _callCount = 0;  // Shared between runs - BAD!

    public static Task<int> Execute(CommandContext ctx, ExecutionDelegate next)
    {
        _callCount++;  // Race conditions in parallel tests!
        Console.WriteLine($"Call count: {_callCount}");
        return next(ctx);
    }
}
snippet source | anchor

Why: Breaks parallel test execution and causes race conditions. Use CommandContext.Services or CommandContext properties instead.

Testing Your Middleware#

Unit Testing#

Test middleware in isolation using TestTools:

public class MyApp
{
    public void Command(string arg) { }
}

private static Task<int> MyMiddleware(CommandContext ctx, ExecutionDelegate next)
{
    if (ctx.ParseResult?.TargetCommand?.Name == "command")
    {
        var arg = ctx.ParseResult.TargetCommand.Operands.FirstOrDefault();
        if (arg?.Value?.ToString() == "--invalid-arg")
        {
            ctx.Console.Error.WriteLine("invalid");
            return Task.FromResult(1);
        }
    }
    return next(ctx);
}
snippet source | anchor

Integration Testing#

Test middleware with full pipeline:

private class TestDatabase
{
    public List<Transaction> Transactions { get; } = new();

    public Transaction BeginTransaction()
    {
        var tx = new Transaction();
        Transactions.Add(tx);
        return tx;
    }
}
snippet source | anchor

Capturing State#

Use the Capture State feature to inspect middleware behavior:

public static void CaptureState_Example()
{
    var result = new AppRunner<MyApp>()
        .Configure(c => c.UseMiddleware(MyMiddleware, MiddlewareStages.PostParseInputPreBindValues))
        .RunInMem("command test");

    // Verify middleware execution and state
    // result.ExitCode.Should().Be(0);
    // result.Console.Out.Should().Contain("expected output");
}
snippet source | anchor

See Testing Middleware for more details.

Checklist for New Middleware#

  • Named with descriptive method name
  • Registered in appropriate stage (review Middleware Pipeline diagram)
  • Verified required CommandContext properties are available at chosen stage
  • Config class for parameters (if needed)
  • Handles errors gracefully
  • Returns appropriate exit codes
  • Doesn't store state in static fields
  • Has unit tests
  • Has integration tests
  • Documented in code and/or user docs