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
- Browse the API reference for the full public surface.
- Read the sample application, which exercises every feature described here.