Claude
Skills
Sign in
Back

dotnet-cli-architecture

Included with Lifetime
$97 forever

Structuring CLI app layers. Command/handler/service separation, clig.dev principles, exit codes.

General

What this skill does


# dotnet-cli-architecture

Layered CLI application architecture for .NET: command/handler/service separation following clig.dev principles, configuration precedence (appsettings → environment variables → CLI arguments), structured logging in CLI context, exit code conventions, stdin/stdout/stderr patterns, and testing CLI applications via in-process invocation with output capture.

**Version assumptions:** .NET 8.0+ baseline. Patterns apply to CLI tools built with System.CommandLine 2.0 and generic host.

**Out of scope:** System.CommandLine API details (RootCommand, Option<T>, middleware, hosting setup) -- see [skill:dotnet-system-commandline]. Native AOT compilation and publish pipeline -- see [skill:dotnet-native-aot]. CLI distribution, packaging, and release automation -- see [skill:dotnet-cli-distribution] and [skill:dotnet-cli-packaging]. General CI/CD patterns -- see [skill:dotnet-gha-patterns] and [skill:dotnet-ado-patterns]. DI container internals -- see [skill:dotnet-csharp-dependency-injection]. General testing strategies -- see [skill:dotnet-testing-strategy].

Cross-references: [skill:dotnet-system-commandline] for System.CommandLine 2.0 API, [skill:dotnet-native-aot] for AOT publishing CLI tools, [skill:dotnet-csharp-dependency-injection] for DI patterns, [skill:dotnet-csharp-configuration] for configuration integration, [skill:dotnet-testing-strategy] for general testing patterns.

---

## clig.dev Principles for .NET CLI Tools

The [Command Line Interface Guidelines](https://clig.dev/) provide language-agnostic principles for well-behaved CLI tools. These translate directly to .NET patterns.

### Core Principles

| Principle | Implementation |
|-----------|---------------|
| Human-first output by default | Use `Console.Out` for data, `Console.Error` for diagnostics |
| Machine-readable output with `--json` | Add a `--json` global option that switches output format |
| Stderr for status/diagnostics | Logging, progress bars, and prompts go to stderr |
| Stdout for data only | Piped output (`mycli list \| jq .`) must not contain log noise |
| Non-zero exit on failure | Return specific exit codes (see conventions below) |
| Fail early, fail loudly | Validate inputs before doing work |
| Respect `NO_COLOR` | Check `Environment.GetEnvironmentVariable("NO_COLOR")` |
| Support `--verbose` and `--quiet` | Global options controlling output verbosity |

### Stdout vs Stderr in .NET

```csharp
// Data output -- goes to stdout (can be piped)
Console.Out.WriteLine(JsonSerializer.Serialize(result, jsonContext.Options));

// Status/diagnostic output -- goes to stderr (user sees it, pipe ignores it)
Console.Error.WriteLine("Processing 42 files...");

// With ILogger (when using hosting)
// ILogger writes to stderr via console provider by default
logger.LogInformation("Connected to {Endpoint}", endpoint);
```

---

## Layered Command → Handler → Service Architecture

Separate CLI concerns into three layers:

```
┌─────────────────────────────────────┐
│  Commands (System.CommandLine)      │  Parse args, wire options
│  ─ RootCommand, Command, Option<T>  │
├─────────────────────────────────────┤
│  Handlers (orchestration)           │  Coordinate services, format output
│  ─ ICommandHandler implementations  │
├─────────────────────────────────────┤
│  Services (business logic)          │  Pure logic, no CLI concerns
│  ─ Interfaces + implementations     │
└─────────────────────────────────────┘
```

### Why Three Layers

- **Commands** know about CLI syntax (options, arguments, subcommands) but not business logic
- **Handlers** bridge CLI inputs to service calls and format results for output
- **Services** contain domain logic and are reusable outside the CLI (tests, libraries, APIs)

### Example Structure

```
src/
  MyCli/
    MyCli.csproj
    Program.cs                    # RootCommand + CommandLineBuilder
    Commands/
      SyncCommandDefinition.cs    # Command, options, arguments
    Handlers/
      SyncHandler.cs              # ICommandHandler, orchestrates services
    Services/
      ISyncService.cs             # Business logic interface
      SyncService.cs              # Implementation (no CLI awareness)
    Output/
      ConsoleFormatter.cs         # Table/JSON output formatting
```

### Command Definition Layer

```csharp
// Commands/SyncCommandDefinition.cs
public static class SyncCommandDefinition
{
    public static readonly Option<Uri> SourceOption = new(
        "--source", "Source endpoint URL") { IsRequired = true };

    public static readonly Option<bool> DryRunOption = new(
        "--dry-run", "Preview changes without applying");

    public static Command Create()
    {
        var command = new Command("sync", "Synchronize data from source");
        command.AddOption(SourceOption);
        command.AddOption(DryRunOption);
        return command;
    }
}
```

### Handler Layer

```csharp
// Handlers/SyncHandler.cs
public class SyncHandler : ICommandHandler
{
    private readonly ISyncService _syncService;
    private readonly ILogger<SyncHandler> _logger;

    public SyncHandler(ISyncService syncService, ILogger<SyncHandler> logger)
    {
        _syncService = syncService;
        _logger = logger;
    }

    // Bound by naming convention from options
    public Uri Source { get; set; } = null!;
    public bool DryRun { get; set; }

    public int Invoke(InvocationContext context) =>
        InvokeAsync(context).GetAwaiter().GetResult();

    public async Task<int> InvokeAsync(InvocationContext context)
    {
        var ct = context.GetCancellationToken();

        _logger.LogInformation("Syncing from {Source}", Source);

        var result = await _syncService.SyncAsync(Source, DryRun, ct);

        if (result.HasErrors)
        {
            context.Console.Error.Write($"Sync failed: {result.ErrorMessage}\n");
            return ExitCodes.SyncFailed;
        }

        context.Console.Out.Write($"Synced {result.ItemCount} items.\n");
        return ExitCodes.Success;
    }
}
```

### Service Layer

```csharp
// Services/ISyncService.cs -- no CLI dependency
public interface ISyncService
{
    Task<SyncResult> SyncAsync(Uri source, bool dryRun, CancellationToken ct);
}

// Services/SyncService.cs
public class SyncService : ISyncService
{
    private readonly HttpClient _httpClient;

    public SyncService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<SyncResult> SyncAsync(
        Uri source, bool dryRun, CancellationToken ct)
    {
        // Pure business logic -- testable without CLI infrastructure
        var data = await _httpClient.GetFromJsonAsync<SyncData>(source, ct);
        // ...
        return new SyncResult(ItemCount: data.Items.Length);
    }
}
```

---

## Configuration Precedence

CLI tools use a specific configuration precedence (lowest to highest priority):

1. **Compiled defaults** -- hardcoded fallback values
2. **appsettings.json** -- shipped with the tool
3. **appsettings.{Environment}.json** -- environment-specific overrides
4. **Environment variables** -- set by shell or CI
5. **CLI arguments** -- explicit user input (highest priority)

### Implementation with Generic Host

```csharp
var builder = new CommandLineBuilder(rootCommand)
    .UseHost(_ => Host.CreateDefaultBuilder(args), host =>
    {
        host.ConfigureAppConfiguration((ctx, config) =>
        {
            // Layers 2-3 handled by CreateDefaultBuilder:
            //   appsettings.json, appsettings.{env}.json, env vars

            // Layer 4: User-specific config file
            var configPath = Path.Combine(
                Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
                ".mycli", "config.json");
            if (File.Exists(configPath))
            {
                config.AddJsonFile(configPath, optional: true);
            }
        });

        // Layer 5: CLI args override everything
        // System.CommandLine options take precedence via handl

Related in General