$npx -y skills add wshaddix/dotnet-skills --skill dotnet-cli-architectureStructuring CLI app layers. Command/handler/service separation, clig.dev principles, exit codes.
| 1 | # dotnet-cli-architecture |
| 2 | |
| 3 | 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. |
| 4 | |
| 5 | **Version assumptions:** .NET 8.0+ baseline. Patterns apply to CLI tools built with System.CommandLine 2.0 and generic host. |
| 6 | |
| 7 | **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]. |
| 8 | |
| 9 | 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. |
| 10 | |
| 11 | --- |
| 12 | |
| 13 | ## clig.dev Principles for .NET CLI Tools |
| 14 | |
| 15 | The [Command Line Interface Guidelines](https://clig.dev/) provide language-agnostic principles for well-behaved CLI tools. These translate directly to .NET patterns. |
| 16 | |
| 17 | ### Core Principles |
| 18 | |
| 19 | | Principle | Implementation | |
| 20 | |-----------|---------------| |
| 21 | | Human-first output by default | Use `Console.Out` for data, `Console.Error` for diagnostics | |
| 22 | | Machine-readable output with `--json` | Add a `--json` global option that switches output format | |
| 23 | | Stderr for status/diagnostics | Logging, progress bars, and prompts go to stderr | |
| 24 | | Stdout for data only | Piped output (`mycli list \| jq .`) must not contain log noise | |
| 25 | | Non-zero exit on failure | Return specific exit codes (see conventions below) | |
| 26 | | Fail early, fail loudly | Validate inputs before doing work | |
| 27 | | Respect `NO_COLOR` | Check `Environment.GetEnvironmentVariable("NO_COLOR")` | |
| 28 | | Support `--verbose` and `--quiet` | Global options controlling output verbosity | |
| 29 | |
| 30 | ### Stdout vs Stderr in .NET |
| 31 | |
| 32 | ```csharp |
| 33 | // Data output -- goes to stdout (can be piped) |
| 34 | Console.Out.WriteLine(JsonSerializer.Serialize(result, jsonContext.Options)); |
| 35 | |
| 36 | // Status/diagnostic output -- goes to stderr (user sees it, pipe ignores it) |
| 37 | Console.Error.WriteLine("Processing 42 files..."); |
| 38 | |
| 39 | // With ILogger (when using hosting) |
| 40 | // ILogger writes to stderr via console provider by default |
| 41 | logger.LogInformation("Connected to {Endpoint}", endpoint); |
| 42 | ``` |
| 43 | |
| 44 | --- |
| 45 | |
| 46 | ## Layered Command → Handler → Service Architecture |
| 47 | |
| 48 | Separate CLI concerns into three layers: |
| 49 | |
| 50 | ``` |
| 51 | ┌─────────────────────────────────────┐ |
| 52 | │ Commands (System.CommandLine) │ Parse args, wire options |
| 53 | │ ─ RootCommand, Command, Option<T> │ |
| 54 | ├─────────────────────────────────────┤ |
| 55 | │ Handlers (orchestration) │ Coordinate services, format output |
| 56 | │ ─ ICommandHandler implementations │ |
| 57 | ├─────────────────────────────────────┤ |
| 58 | │ Services (business logic) │ Pure logic, no CLI concerns |
| 59 | │ ─ Interfaces + implementations │ |
| 60 | └─────────────────────────────────────┘ |
| 61 | ``` |
| 62 | |
| 63 | ### Why Three Layers |
| 64 | |
| 65 | - **Commands** know about CLI syntax (options, arguments, subcommands) but not business logic |
| 66 | - **Handlers** bridge CLI inputs to service calls and format results for output |
| 67 | - **Services** contain domain logic and are reusable outside the CLI (tests, libraries, APIs) |
| 68 | |
| 69 | ### Example Structure |
| 70 | |
| 71 | ``` |
| 72 | src/ |
| 73 | MyCli/ |
| 74 | MyCli.csproj |
| 75 | Program.cs # RootCommand + CommandLineBuilder |
| 76 | Commands/ |
| 77 | SyncCommandDefinition.cs # Command, options, arguments |
| 78 | Handlers/ |
| 79 | SyncHandler.cs # ICommandHandler, orchestrates services |
| 80 | Services/ |
| 81 | ISyncService.cs # Business logic interface |
| 82 | SyncService.cs # Implementation (no CLI awareness) |
| 83 | Output/ |
| 84 | ConsoleFormatter.cs # Table/JSON output formatting |
| 85 | ``` |
| 86 | |
| 87 | ### Command Definition Layer |
| 88 | |
| 89 | ```csharp |
| 90 | // Commands/SyncCommandDefinition.cs |
| 91 | public static class SyncCommandDefinition |
| 92 | { |
| 93 | public static readonly Option<Uri> SourceOption = new( |
| 94 | "--source", "Source endpoint URL") { IsRequired = true }; |
| 95 | |
| 96 | public static readonly Option<bool> DryRunOption = new( |
| 97 | "--dry-run", "Preview changes without applying"); |
| 98 | |
| 99 | public static Command Create() |
| 100 | { |
| 101 | var command = new Command("sync", "Synchronize data from source"); |
| 102 | command.AddOption(SourceOption); |
| 103 | command.AddOption(DryRunOption); |
| 104 | retur |