본문으로 건너뛰기
cccskills
무료GitHub에 공개

dotnet-cli-architecture

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

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

설치 방법 보기

포함된 파일(1)

  • SKILL.md19.4 KB

SKILL.md(원문)

설치하기 전에 에이전트에게 전달되는 지시 내용을 확인할 수 있습니다.

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.

Scope

  • Layered command/handler/service architecture for CLI apps
  • clig.dev principles for .NET (stdout/stderr, exit codes, NO_COLOR)
  • Configuration precedence (appsettings, env vars, CLI args)
  • Structured logging in CLI context
  • Stdin/stdout/stderr patterns and machine-readable output
  • Testing CLI applications via in-process invocation

Out of scope

  • System.CommandLine API details (RootCommand, Option<T>, SetAction) -- see [skill:dotnet-system-commandline]
  • Native AOT compilation and publish pipeline -- see [skill:dotnet-native-aot]
  • CLI distribution and packaging -- 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 provide language-agnostic principles for well-behaved CLI tools. These translate directly to .NET patterns.

Core Principles

PrincipleImplementation
Human-first output by defaultUse Console.Out for data, Console.Error for diagnostics
Machine-readable output with --jsonAdd a --json global option that switches output format
Stderr for status/diagnosticsLogging, progress bars, and prompts go to stderr
Stdout for data onlyPiped output (mycli list | jq .) must not contain log noise
Non-zero exit on failureReturn specific exit codes (see conventions below)
Fail early, fail loudlyValidate inputs before doing work
Respect NO_COLORCheck Environment.GetEnvironmentVariable("NO_COLOR")
Support --verbose and --quietGlobal options controlling output verbosity

Stdout vs Stderr in .NET


// 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);

```text

---

## Layered Command → Handler → Service Architecture

Separate CLI concerns into three layers:

```bash

┌─────────────────────────────────────┐
│  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     │
└─────────────────────────────────────┘

```text

### 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

```text

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

```csharp

### 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;
    }
}

```bash

### 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;
    }
}

```text

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

```text

---

## 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 handler binding
    })
    .UseDefaults()
    .Build();

```bash

### User-Level Configuration

Many CLI tools support user-level config (e.g., `~/.mycli/config.json`, `~/.config/mycli/config.yaml`). Follow platform
conventions:

| Platform      | Location                          |
| ------------- | --------------------------------- |
| Linux/macOS   | `~/.config/mycli/` or `~/.mycli/` |
| Windows       | `%APPDATA%\mycli\`                |
| XDG-compliant | `$XDG_CONFIG_HOME/mycli/`         |

---

## Structured Logging in CLI Context

### Configuring Logging for CLI

CLI tools need different logging than web apps: logs go to stderr, and verbosity is controlled by flags.

```csharp

host.ConfigureLogging((ctx, logging) =>
{
    logging.ClearProviders();
    logging.AddConsole(options =>
    {
        // Write to stderr, not stdout
        options.LogToStandardErrorThreshold = LogLevel.Trace;
    });
});

```text

### Verbosity Mapping

Map `--verbose`/`--quiet` flags to log levels:

```csharp

public static class VerbosityMapping
{
    public static LogLevel ToLogLevel(bool verbose, bool quiet) => (verbose, quiet) switch
    {
        (true, _) => LogLevel.Debug,
        (_, true) => LogLevel.Warning,
        _ => LogLevel.Information  // default
    };
}

// In host configuration
host.ConfigureLogging((ctx, logging) =>
{
    var level = VerbosityMapping.ToLogLevel(verbose, quiet);
    logging.SetMinimumLevel(level);
});

```text

---

## Exit Code Conventions

### Standard Exit Codes

```csharp

public static class ExitCodes
{
    public const int Success = 0;
    public const int GeneralError = 1;
    public const int InvalidUsage = 2;    // Bad arguments or options
    public const int IoError = 3;         // File not found, permission denied
    public const int NetworkError = 4;    // Connection failed, timeout
    public const int AuthError = 5;       // Authentication/authorization failure

    // Tool-specific codes start at 10+
    public const int SyncFailed = 10;
    public const int ValidationFailed = 11;
}

```text

### Guidelines

- **0** = success (always)
- **1** = general/unspecified error
- **2** = invalid usage (bad arguments) -- System.CommandLine returns this for parse errors automatically
- **3-9** = reserved for common categories
- **10+** = tool-specific error codes
- Never use exit codes > 125 (reserved by shells; 126 = not executable, 127 = not found, 128+N = killed by signal N)

### Propagating Exit Codes

```csharp

public async Task<int> InvokeAsync(InvocationContext context)
{
    try
    {
        await _service.ProcessAsync(context.GetCancellationToken());
        return ExitCodes.Success;
    }
    catch (HttpRequestException ex)
    {
        _logger.LogError(ex, "Network error");
        context.Console.Error.Write($"Error: {ex.Message}\n");
        return ExitCodes.NetworkError;
    }
    catch (UnauthorizedAccessException ex)
    {
        context.Console.Error.Write($"Permission denied: {ex.Message}\n");
        return ExitCodes.IoError;
    }
}

```text

---

## Stdin/Stdout/Stderr Patterns

### Reading from Stdin

Support piped input as an alternative to file arguments:

```csharp

public async Task<int> InvokeAsync(InvocationContext context)
{
    string input;

    if (InputFile is not null)
    {
        input = await File.ReadAllTextAsync(InputFile.FullName);
    }
    else if (Console.IsInputRedirected)
    {
        // Read from stdin: echo '{"data":1}' | mycli process
        input = await Console.In.ReadToEndAsync();
    }
    else
    {
        context.Console.Error.Write("Error: Provide input via --file or stdin.\n");
        return ExitCodes.InvalidUsage;
    }

    var result = _processor.Process(input);
    context.Console.Out.Write(JsonSerializer.Serialize(result));
    return ExitCodes.Success;
}

```json

### Machine-Readable Output

```csharp

// Global --json option for machine-readable output
var jsonOption = new Option<bool>("--json", "Output as JSON");
rootCommand.AddGlobalOption(jsonOption);

// In handler
if (useJson)
{
    Console.Out.WriteLine(JsonSerializer.Serialize(result, jsonContext.Options));
}
else
{
    // Human-friendly table format
    ConsoleFormatter.WriteTable(result, context.Console);
}

```text

### Progress to Stderr

```csharp

// Progress reporting goes to stderr (does not pollute piped stdout)
await foreach (var item in _service.StreamAsync(ct))
{
    Console.Error.Write($"\rProcessing {item.Index}/{total}...");
    Console.Out.WriteLine(item.ToJson());
}
Console.Error.WriteLine();  // Clear progress line

```json

---

## Testing CLI Applications

### In-Process Invocation with CommandLineBuilder

Test the full CLI pipeline without spawning a child process:

```csharp

public class CliTestHarness
{
    private readonly RootCommand _rootCommand;
    private readonly Action<IServiceCollection>? _configureServices;

    public CliTestHarness(Action<IServiceCollection>? configureServices = null)
    {
        _rootCommand = Program.BuildRootCommand();
        _configureServices = configureServices;
    }

    public async Task<(int ExitCode, string Stdout, string Stderr)> InvokeAsync(
        string commandLine)
    {
        var console = new TestConsole();

        var builder = new CommandLineBuilder(_rootCommand)
            .UseHost(_ => Host.CreateDefaultBuilder(), host =>
            {
                if (_configureServices is not null)
                {
                    host.ConfigureServices(_configureServices);
                }
            })
            .UseDefaults()
            .Build();

        var exitCode = await builder.InvokeAsync(commandLine, console);

        return (exitCode, console.Out.ToString()!, console.Error.ToString()!);
    }
}

```text

### Testing with Service Mocks

```csharp

[Fact]
public async Task Sync_WithValidSource_ReturnsZero()
{
    var fakeSyncService = new FakeSyncService(
        new SyncResult(ItemCount: 5));

    var harness = new CliTestHarness(services =>
    {
        services.AddSingleton<ISyncService>(fakeSyncService);
    });

    var (exitCode, stdout, stderr) = await harness.InvokeAsync(
        "sync --source https://api.example.com");

    Assert.Equal(0, exitCode);
    Assert.Contains("Synced 5 items", stdout);
}

[Fact]
public async Task Sync_WithMissingSource_ReturnsNonZero()
{
    var harness = new CliTestHarness();

    var (exitCode, _, stderr) = await harness.InvokeAsync("sync");

    Assert.NotEqual(0, exitCode);
    Assert.Contains("--source", stderr);  // Parse error mentions missing option
}

```text

### Exit Code Assertion

```csharp

[Theory]
[InlineData("sync --source https://valid.example.com", 0)]
[InlineData("sync", 2)]  // Missing required option
[InlineData("invalid-command", 1)]
public async Task ExitCode_MatchesExpected(string args, int expectedExitCode)
{
    var harness = new CliTestHarness();
    var (exitCode, _, _) = await harness.InvokeAsync(args);
    Assert.Equal(expectedExitCode, exitCode);
}

```text

### Testing Output Format

```csharp

[Fact]
public async Task List_WithJsonFlag_OutputsValidJson()
{
    var harness = new CliTestHarness(services =>
    {
        services.AddSingleton<IItemRepository>(
            new FakeItemRepository([new Item(1, "Widget")]));
    });

    var (exitCode, stdout, _) = await harness.InvokeAsync("list --json");

    Assert.Equal(0, exitCode);
    var items = JsonSerializer.Deserialize<Item[]>(stdout);
    Assert.NotNull(items);
    Assert.Single(items);
}

[Fact]
public async Task List_StderrContainsLogs_StdoutContainsDataOnly()
{
    var harness = new CliTestHarness();
    var (_, stdout, stderr) = await harness.InvokeAsync("list --json --verbose");

    // Stdout must be valid JSON (no log noise)
    // xUnit: just call it -- if it throws, the test fails
    var doc = JsonDocument.Parse(stdout);
    Assert.NotNull(doc);

    // Stderr contains diagnostic output
    Assert.Contains("Connected to", stderr);
}

```text

---

## Agent Gotchas

1. **Do not write diagnostic output to stdout.** Logs, progress, and errors go to stderr. Stdout is reserved for data
   output that can be piped. A CLI tool that mixes logs into stdout breaks shell pipelines.
2. **Do not hardcode exit code 1 for all errors.** Use distinct exit codes for different failure categories (I/O,
   network, auth, validation). Callers and scripts rely on exit codes to determine what went wrong.
3. **Do not put business logic in command handlers.** Handlers should orchestrate calls to injected services and format
   output. Business logic in handlers cannot be reused or unit-tested independently.
4. **Do not test CLI tools only via process spawning.** Use in-process invocation with `CommandLineBuilder` and
   `TestConsole` for fast, reliable tests. Reserve process-level tests for smoke testing the published binary.
5. **Do not ignore `Console.IsInputRedirected` when accepting stdin.** Without checking, the tool may hang waiting for
   input when invoked without piped data.
6. **Do not use exit codes above 125.** Codes 126-255 have special meanings in Unix shells (126 = not executable, 127 =
   not found, 128+N = killed by signal N). Tool-specific codes should be in the 1-125 range.

---



## Code Navigation (Serena MCP)

**Primary approach:** Use Serena symbol operations for efficient code navigation:

1. **Find definitions**: `serena_find_symbol` instead of text search
2. **Understand structure**: `serena_get_symbols_overview` for file organization
3. **Track references**: `serena_find_referencing_symbols` for impact analysis
4. **Precise edits**: `serena_replace_symbol_body` for clean modifications

**When to use Serena vs traditional tools:**
- ✅ **Use Serena**: Navigation, refactoring, dependency analysis, precise edits
- ✅ **Use Read/Grep**: Reading full files, pattern matching, simple text operations
- ✅ **Fallback**: If Serena unavailable, traditional tools work fine

**Example workflow:**
```text
# Instead of:
Read: src/Services/OrderService.cs
Grep: "public void ProcessOrder"

# Use:
serena_find_symbol: "OrderService/ProcessOrder"
serena_get_symbols_overview: "src/Services/OrderService.cs"
```
## References

- [Command Line Interface Guidelines (clig.dev)](https://clig.dev/)
- [System.CommandLine overview](https://learn.microsoft.com/en-us/dotnet/standard/commandline/)
- [12 Factor CLI Apps](https://medium.com/@jdxcode/12-factor-cli-apps-dd3c227a0e46)
- [Generic Host in .NET](https://learn.microsoft.com/en-us/dotnet/core/extensions/generic-host)
- [Console logging in .NET](https://learn.microsoft.com/en-us/dotnet/core/extensions/console-log-formatter)

리뷰

아직 리뷰가 없습니다. 사용해 본 소감을 남겨 주세요.

댓글

로그인 없이 쓸 수 있습니다. 사용 후기, 질문, 작동하지 않은 환경 등을 편하게 남겨 주세요.

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

보내면 바로 공개됩니다. 이메일 주소나 전화번호 같은 개인 정보는 쓰지 마세요. URL은 2개까지입니다.

0 / 1,000자

이 양식은 reCAPTCHA로 보호되며 Google 개인정보처리방침 및 서비스 약관이 적용됩니다.

같은 저장소의 스킬

Patterns and techniques for evaluating and improving AI agent outputs.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

rudironsoni/Synaxis22026년 3월 17일 업데이트

Comprehensive AI prompt engineering safety review and improvement prompt. Analyzes prompts for safety, bias, security vulnerabilities, and effectiveness while providing detailed improvement recommendations.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

rudironsoni/Synaxis22026년 3월 17일 업데이트

Use when user requests research requiring multiple sources, comprehensive analysis, or synthesis across topics - technical research, domain knowledge gathering, market analysis, or learning about complex subjects

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

rudironsoni/Synaxis22026년 3월 17일 업데이트

deep-wiki

무료

AI-powered wiki generation for code repositories with commands, agents, and skills

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

rudironsoni/Synaxis22026년 3월 17일 업데이트

Use when building .NET 10 or C# 14 applications; when using minimal APIs, modular monolith patterns, or feature folders; when implementing HTTP resilience, Options pattern, Channels, or validation; when seeing outdated patterns like old extension method syntax

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

rudironsoni/Synaxis22026년 3월 17일 업데이트

Implements accessible .NET UI. SemanticProperties, ARIA, AutomationPeer, testing per platform.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

rudironsoni/Synaxis22026년 3월 17일 업데이트

rudironsoni의 스킬 모두 보기

이 스킬의 문제 신고