If you've been building CLI applications with System.CommandLine and found yourself searching for "System.CommandLine with Dependency Injection," you're not alone. While System.CommandLine is a powerful library, integrating it with a proper DI container for enterprise-grade applications requires significant boilerplate and manual wiring.
Albatross.CommandLine solves this problem by combining System.CommandLine with Microsoft.Extensions.Hosting, automatic code generation, and some genuinely elegant patterns for building maintainable CLI applications.
The Problem
Here's what typically happens when you try to add dependency injection to a System.CommandLine project:
- You need to manually wire up
IServiceCollection - You have to figure out how to resolve services in your command handlers
- Async handlers with cancellation tokens require extra work
- Sharing code between commands (like common options) becomes repetitive
- Pre-validating inputs against external services (databases, APIs) is awkward
Let's see how Albatross.CommandLine addresses each of these.
Getting Started: DI Out of the Box
Install the package:
dotnet add package Albatross.CommandLine
Here's a complete, working CLI application with full DI support:
using Albatross.CommandLine;
using Albatross.CommandLine.Annotations;
using Microsoft.Extensions.DependencyInjection;
using System.CommandLine;
// Entry point
await using var host = new CommandHost("MyApp")
.RegisterServices(RegisterServices)
.AddCommands() // Generated by source generator
.Parse(args)
.Build();
return await host.InvokeAsync();
static void RegisterServices(ParseResult result, IServiceCollection services) {
services.RegisterCommands(); // Generated by source generator
services.AddSingleton<IMyService, MyService>();
}
That's it. The CommandHost manages the DI container lifecycle, and the source generator creates all the wiring code.
Defining Commands: Attributes + Source Generation
Commands are defined using simple classes and attributes:
[Verb<GreetHandler>("greet", Description = "Greet a user")]
public class GreetParams {
[Option(Description = "Name of the person to greet")]
public required string Name { get; init; }
[Option("f", Description = "Use formal greeting")]
public bool Formal { get; init; }
}
public class GreetHandler : BaseHandler<GreetParams> {
private readonly IMyService _service;
public GreetHandler(ParseResult result, GreetParams parameters, IMyService service)
: base(result, parameters) {
_service = service; // Fully injected!
}
public override Task<int> InvokeAsync(CancellationToken cancellationToken) {
var greeting = parameters.Formal ? "Good day" : "Hey";
Writer.WriteLine($"{greeting}, {parameters.Name}!");
return Task.FromResult(0);
}
}
The source generator creates a GreetCommand class, registers everything with DI, and wires up the option parsing. You just write your business logic.
Beyond Basics: Reusable Parameters
Here's where it gets interesting. In any real CLI application, you'll have common options that appear across multiple commands: input directories, output formats, API keys, verbosity flags, etc.
Instead of duplicating these, create reusable parameter classes:
[DefaultNameAliases("--input", "-i")]
public class InputFileOption : Option<FileInfo> {
public InputFileOption(string name, params string[] aliases) : base(name, aliases) {
Description = "Input file path";
this.AddValidator(result => {
var file = result.GetValueForOption(this);
if (file != null && !file.Exists) {
result.ErrorMessage = $"File not found: {file.FullName}";
}
});
}
}
Now use it in any command:
[Verb<ProcessHandler>("process")]
public class ProcessParams {
[UseOption<InputFileOption>]
public required FileInfo Input { get; init; }
[Option(Description = "Output directory")]
public DirectoryInfo? Output { get; init; }
}
The validation logic, description, and aliases are encapsulated and reusable. The library even ships with common ones like InputDirectoryOption, OutputFileOption, and FormatExpressionOption in the Albatross.CommandLine.Inputs package.
Advanced: Pre-Processing with DI
Sometimes you need to validate an input against an external service before your command runs. For example, verifying that an instrument Id exists in your database.
This is where option handlers shine:
// Define the option with a handler
[DefaultNameAliases("--instrument", "-i")]
[OptionHandler<InstrumentOption, VerifyInstrumentId>]
public class InstrumentOption : Option<int> {
public InstrumentOption(string name, params string[] aliases) : base(name, aliases) {
Description = "Instrument Id";
}
}
// The handler has full DI support
public class VerifyInstrumentId : IAsyncOptionHandler<InstrumentOption> {
private readonly IInstrumentService _service;
private readonly ICommandContext _context;
public VerifyInstrumentId(IInstrumentService service, ICommandContext context) {
_service = service;
_context = context;
}
public async Task InvokeAsync(InstrumentOption option, ParseResult result, CancellationToken token) {
var id = result.GetValue(option);
if(id != 0) {
var valid = await _service.IsValidInstrument(id, token);
if (!valid) {
// Short-circuit: command handler won't execute
_context.SetInputActionStatus(new OptionHandlerStatus(option.Name, false, $"Instrument {id} not found"));
}
}
}
}
The handler runs before your command. If validation fails, the command never executes. This keeps your command handlers focused on business logic, not input validation.
The Magic: Input Transformation
Here's the most powerful pattern. What if you don't want your command handler to receive an int instrument Id, but the full InstrumentSummary object?
// Add a third generic argument: the output type
[DefaultNameAliases("--instrument", "-i")]
[OptionHandler<InstrumentOption, InstrumentTransformer, InstrumentSummary>]
public class InstrumentOption : Option<string> {
public InstrumentOption(string name, params string[] aliases) : base(name, aliases) {
Description = "Instrument identifier";
}
}
// The transformer fetches and returns the full object
public class InstrumentTransformer : IAsyncOptionHandler<InstrumentOption, InstrumentSummary> {
private readonly IInstrumentService _service;
public InstrumentTransformer(IInstrumentService service) {
_service = service;
}
public async Task<OptionHandlerResult<InstrumentSummary>> InvokeAsync(
InstrumentOption option, ParseResult result, CancellationToken token) {
var identifier = result.GetValue(option);
if (string.IsNullOrEmpty(identifier)) {
return new OptionHandlerResult<InstrumentSummary>();
}
var summary = await _service.GetSummary(identifier, token);
return new OptionHandlerResult<InstrumentSummary>(summary);
}
}
Now your params class receives the transformed type:
[Verb<GetPriceHandler>("price")]
public class GetPriceParams {
// Property type is InstrumentSummary, not string!
[UseOption<InstrumentOption>]
public required InstrumentSummary Instrument { get; init; }
}
public class GetPriceHandler : BaseHandler<GetPriceParams> {
public override Task<int> InvokeAsync(CancellationToken token) {
// Direct access to the full object
Writer.WriteLine($"Price for {parameters.Instrument.Name}: ${parameters.Instrument.Price}");
return Task.FromResult(0);
}
}
The user types --instrument AAPL, but your handler receives a fully-hydrated InstrumentSummary object. The transformation is completely transparent.
Why This Matters
These patterns enable a clean separation of concerns:
| Layer | Responsibility |
|---|---|
| Reusable Options | Validation rules, descriptions, aliases |
| Option Handlers | External validation, data fetching |
| Command Handlers | Pure business logic |
Your command handlers become simple and testable. The infrastructure handles the messy parts.
Summary
If you're building CLI applications with .NET and need:
- First-class dependency injection
- Async handlers with cancellation support
- Reusable, validated parameters
- Pre-processing with external services
- Input transformation
Check out Albatross.CommandLine. It's built on System.CommandLine and Microsoft.Extensions.Hosting, so you get the full power of both ecosystems with none of the boilerplate.
Links:
Top comments (0)