dotnet-cli-architecture
Structuring CLI app layers. Command/handler/service separation, clig.dev principles, exit codes.
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 handlRelated in General
modeling-omnistudio-epc-catalog
IncludedSalesforce Industries CME EPC product-modeling skill for Product2-based catalog creation. Use when creating EPC products, configuring product attributes, building offer bundles with Product Child Items, or reviewing EPC DataPack JSON metadata for product catalog changes. TRIGGER when: user creates or updates Product2 EPC records, AttributeAssignment payloads, AttributeMetadata/AttributeDefaultValues, Offer bundles, or ProductChildItem relationships. DO NOT TRIGGER when: designing OmniScripts/FlexCards/Integration Procedures (use building-omnistudio-omniscript, building-omnistudio-flexcard, or building-omnistudio-integration-procedure), implementing Apex business logic (use generating-apex), or troubleshooting deployment pipelines (use deploying-metadata).
relationship-science-coach
IncludedUse this skill for direct, practical adult relationship coaching: couples conflict, repair, trust, marriage, dating, flirting, attachment patterns, emotional connection, sex, desire differences, eroticism, kink negotiation, affection, love languages, breakups, and long-term passion. Draw on Gottman, EFT and Hold Me Tight, attachment science, modern sex research, Perel, Nagoski, Kerner, Schnarch, Love and Stosny, and flexible love-language tools. Be concrete and low-hedge. Redirect only for imminent danger, abuse, coercive control, minors, non-consent, self-harm, stalking, or medical/legal/psychiatric decisions.
building-sf-integrations
IncludedSalesforce integration architecture and runtime plumbing with 120-point scoring. Use this skill to set up Named Credentials, External Credentials, External Services, REST/SOAP callout patterns, Platform Events, and Change Data Capture. TRIGGER when: user sets up Named Credentials, External Services, REST/SOAP callouts, Platform Events, CDC, or touches .namedCredential-meta.xml files. DO NOT TRIGGER when: Connected App/OAuth config (use configuring-connected-apps), Apex-only logic (use generating-apex), or data import/export (use handling-sf-data).
venue-templates
IncludedAccess comprehensive LaTeX templates, formatting requirements, and submission guidelines for major scientific publication venues (Nature, Science, PLOS, IEEE, ACM), academic conferences (NeurIPS, ICML, CVPR, CHI), research posters, and grant proposals (NSF, NIH, DOE, DARPA). This skill should be used when preparing manuscripts for journal submission, conference papers, research posters, or grant proposals and need venue-specific formatting requirements and templates.
let-fate-decide
IncludedDraws the 12 Houses of the Zodiac Tarot spread to inject entropy into planning when prompts are vague, ambiguous, or casually delegated. Interprets the spread to guide next steps. Use when the user says 'let fate decide', 'YOLO', 'whatever', 'idk', or other nonchalant phrases, makes Yu-Gi-Oh references, or when you are about to arbitrarily pick between multiple reasonable approaches. Prefer over ask-questions-if-underspecified when the user's tone is casual or playful rather than precision-seeking.
net-ops
IncludedCross-platform network troubleshooting (Windows, macOS, Linux) via local or remote shell. Use for: DNS broken, can't resolve hostnames, nslookup/dig works but apps fail, NRPT, WFP, scutil, /etc/resolver, systemd-resolved, /etc/resolv.conf, NetworkManager, VPN DNS leak residue (ProtonVPN/Mullvad/WireGuard/AnyConnect), AV/firewall blocking DNS or DoH, Tailscale DNS interaction, intermittent connectivity, remote diagnostics over SSH.