Wiring up services, configuration, logging, and validation

The home page covers defining a command and running it. This article covers the four integration points that turn a single command into a real application: dependency injection, configuration, logging, and validation.

Dependency injection

AppBuilder builds on Microsoft.Extensions.Hosting, so commands are resolved from the same container as everything else. The host exists for that container, configuration and logging; it is built once a run reaches command resolution, and the library never starts it: a CLI command is the unit of work, so the library neither starts nor stops an IHostedService you register, and a host that was built is disposed when the run ends (starting the host around the command is a possible 1.x feature, tracked as PLO-785). Register your services with ConfigureServices and take them as constructor parameters:

using var appBuilder = AppBuilder.Create(args)
                                 .ConfigureServices(services =>
                                 {
                                     services.AddSingleton<IUserService, UserService>();
                                     services.AddTransient<CreateProjectUseCase>();
                                 });

var executor = appBuilder.ConfigureCommandApp(config => config.AddCommand<UserAddCommand>("add"));

If you already package registrations as a ServicesBundle from Ploch.Common.DependencyInjection, register the bundle instead:

.AddServicesBundle<MyFeatureBundle>()

Configuration

appsettings.json, appsettings.{Environment}.json, user secrets (in the Development environment only), environment variables and the command-line arguments are already loaded, in that ascending order of precedence, exactly as they are in a web host. ConfigureAppConfiguration exposes the standard IConfigurationBuilder for anything you want to add on top of them:

.ConfigureAppConfiguration(configuration => configuration.AddJsonFile("machine-overrides.json", optional: true))

Sources are ordered and the last one added wins, so a source added here overrides every default — the command line included. That is why you should not add appsettings.json a second time to make it load from the deployment directory rather than the current working directory: it would rank the file above the command-line arguments it is meant to yield to. Move the content root instead, which anchors the host's own appsettings.json and appsettings.{Environment}.json lookups without touching the precedence order:

.ConfigureHost(host => host.UseContentRoot(AppContext.BaseDirectory))

Bind the result wherever you need it — IConfiguration is available from the container, as are options types registered with services.Configure<T>(...).

Logging with Serilog

Serilog is opt-in. The core package registers no logging provider, so ILogger<T> resolves from the host's defaults and the core package creates no application log file; the host's providers write only where they are configured to. To log through Serilog — configured from the application's Serilog section, with the Spectre.Console console sink and two rolling log files — add Ploch.CommandLine.Spectre.Serilog and call UseSerilog on the builder, once. The files are named after the process unless you choose the log name and directory; the output template and the culture the files are written with are optional too:

using Ploch.CommandLine.Spectre;
using Ploch.CommandLine.Spectre.Serilog;

using var appBuilder = AppBuilder.Create(args)
                                 .UseSerilog(logName: "sample",
                                             logPath: Path.Combine(AppContext.BaseDirectory, "logs"));

Anchor logPath to the application's directory, as above. A relative path such as "logs" resolves against the working directory the CLI was launched from, which can be anywhere.

Commands then take ILogger<T> as a constructor parameter in the usual way.

Note

Keep log output and user-facing output separate. Write anything the user is meant to read through IOutput, and use ILogger<T> for diagnostics. Mixing the two makes a CLI hard to pipe and hard to script against.

Validating settings with FluentValidation

Add the Ploch.CommandLine.Spectre.FluentValidation package and register your validators by assembly scan:

.ConfigureServices(services =>
    services.AddCommandLineSettingsFluentValidation(builder =>
        builder.AddAssembly(typeof(Program).Assembly)))

Then write a validator per settings type:

public class UserAddSettingsValidator : AbstractValidator<UserAddSettings>
{
    public UserAddSettingsValidator()
    {
        RuleFor(settings => settings.Name).NotEmpty();
        RuleFor(settings => settings.Email).NotEmpty().EmailAddress();
    }
}

Validation runs before DoExecute/DoExecuteAsync. A failure short-circuits the command before your code runs: Spectre.Console.Cli reports the errors and the application exits with ExitCode.InvalidInput (2) — the same code your command returns for input that parses and validates but which it rejects itself — see exit codes in the Getting Started guide.

Composing sub-commands

Spectre's configurator supports branches, so a multi-level CLI is a matter of nesting:

.ConfigureCommandApp(config =>
{
    config.SetApplicationName("sample");

    config.AddBranch("user", user =>
    {
        user.SetDescription("Manage user accounts.");
        user.AddCommand<UserAddCommand>("add").WithExample("user", "add", "Alice");
        user.AddCommand<UserListCommand>("list");
    });
});

WithDescription and WithExample feed the generated --help output, so filling them in is what makes the CLI self-documenting.

Next steps