System.CommandLine with Dependency Injection: A Complete Solution

Published: (January 11, 2026 at 08:38 PM EST)
5 min read
Source: Dev.to

Source: Dev.to

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

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

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("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
{
    private readonly IMyService _service;

    public GreetHandler(ParseResult result, GreetParams parameters, IMyService service)
        : base(result, parameters)
    {
        _service = service; // Fully injected!
    }

    public override Task 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

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.

Reusable option definition

[DefaultNameAliases("--input", "-i")]
public class InputFileOption : Option
{
    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}";
            }
        });
    }
}

Consume the reusable option

[Verb("process")]
public class ProcessParams
{
    [UseOption]
    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]
public class InstrumentOption : Option
{
    public InstrumentOption(string name, params string[] aliases) : base(name, aliases)
    {
        Description = "Instrument Id";
    }
}

The handler has full DI support

public class VerifyInstrumentId : IAsyncOptionHandler
{
    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(/* ... */);
                // You can also set an error message or exit code here
            }
        }
    }
}

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.

TL;DR

  • Albatross.CommandLine gives you DI‑ready System.CommandLine apps with almost no boilerplate.
  • Source generators create command classes, option parsers, and DI registrations automatically.
  • Reusable option classes keep your code DRY and maintainable.
  • Option handlers let you run pre‑validation logic with full DI support.
new OptionHandlerStatus(option.Name, false, $"Instrument {id} not found"));
            }
        }
    }
}

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]
public class InstrumentOption : Option {
    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 {
    private readonly IInstrumentService _service;

    public InstrumentTransformer(IInstrumentService service) {
        _service = service;
    }

    public async Task InvokeAsync(
        InstrumentOption option, ParseResult result, CancellationToken token) {
        var identifier = result.GetValue(option);
        if (string.IsNullOrEmpty(identifier)) {
            return new OptionHandlerResult();
        }
        var summary = await _service.GetSummary(identifier, token);
        return new OptionHandlerResult(summary);
    }
}

Now your params class receives the transformed type:

[Verb("price")]
public class GetPriceParams {
    // Property type is InstrumentSummary, not string!
    [UseOption]
    public required InstrumentSummary Instrument { get; init; }
}

public class GetPriceHandler : BaseHandler {
    public override Task 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:

LayerResponsibility
Reusable OptionsValidation rules, descriptions, aliases
Option HandlersExternal validation, data fetching
Command HandlersPure 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, giving you the full power of both ecosystems with none of the boilerplate.

Back to Blog

Related posts

Read more »

Stop Using IOptions Wrong in .NET!

IOptions vs IOptionsSnapshot vs IOptionsMonitor – Which One Should You Use? Ever got confused about which one to use in your .NET app? You're not alone! Let me...