Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part 03: Thin CLI, Fat Lib — One Verb, One Method Call

"If you can't unit-test your CLI without spawning a process, you've put logic in the wrong place."


Why

Part 02 argued that the CLI is the only test surface that exists. If you take that seriously, you immediately face a question: what should be in the CLI project, and what should be in the lib project? It is the most consequential boundary in the codebase, and it is the one that almost every C# tool gets wrong.

Look at any random dotnet tool on GitHub. Open the .Cli project. Count the lines that aren't System.CommandLine plumbing. You will usually find:

  • File reading and writing (File.ReadAllText, File.WriteAllText)
  • Path manipulation (Path.Combine, Path.GetFullPath)
  • Process invocation (Process.Start("docker", ...))
  • JSON / YAML serialisation
  • Configuration merging
  • Validation logic
  • Error handling
  • Retry loops
  • Progress reporting that decides what to report based on business rules

All of those are business logic. None of them belong in the CLI project. Every line of business logic in .Cli is a line you cannot test without spawning a process, cannot reuse from a sister tool, cannot expose through a different presentation layer, and cannot refactor without re-running the whole test suite end to end.

The thesis of this part is the inverse: the CLI project contains zero business logic. The lib contains all of it. Every verb in the CLI is a five-step pattern: parse arguments, construct a typed request, call one method on the lib, render the result, set the exit code. Nothing else. If you find yourself reaching for File.WriteAllText in a verb handler, the verb handler is wrong.


The shape

A thin verb has exactly five concerns. Here is the canonical template every verb in HomeLab follows:

[Injectable(ServiceLifetime.Singleton)]
public sealed class TlsInitCommand : IHomeLabVerbCommand
{
    private readonly IHomeLabPipeline _pipeline;
    private readonly IHomeLabConsole _console;

    public TlsInitCommand(IHomeLabPipeline pipeline, IHomeLabConsole console)
    {
        _pipeline = pipeline;
        _console = console;
    }

    public Command Build()
    {
        // 1. Declare arguments and options (System.CommandLine plumbing)
        var provider = new Option<string>("--provider", () => "native");
        var domain   = new Option<string>("--domain",   () => "frenchexdev.lab");
        var caName   = new Option<string>("--ca-name",  () => "HomeLab CA");
        var output   = new Option<DirectoryInfo>("--output", () => new("./data/certs"));

        var cmd = new Command("init", "Generate a self-signed CA and a wildcard cert");
        cmd.AddOption(provider);
        cmd.AddOption(domain);
        cmd.AddOption(caName);
        cmd.AddOption(output);

        cmd.SetHandler(async (string p, string d, string c, DirectoryInfo o) =>
        {
            // 2. Construct a typed request (no business logic)
            var request = new TlsInitRequest(
                Provider: p,
                Domain:   d,
                CaName:   c,
                OutputDir: o);

            // 3. Call exactly one method on the lib
            var result = await _pipeline.RunAsync(request);

            // 4. Render the result via IHomeLabConsole
            _console.Render(result);

            // 5. Set the exit code
            Environment.ExitCode = result.IsSuccess ? 0 : 1;
        }, provider, domain, caName, output);

        return cmd;
    }
}

That is the entire tls init verb. Read it again. The five steps are visible and they are the only things happening:

  1. Declare arguments — pure System.CommandLine API. No logic.
  2. Construct a typed request — a single record type, one constructor call.
  3. Call the lib — one await _pipeline.RunAsync(request). The pipeline returns Result<TlsInitResponse>.
  4. Render_console.Render(result) knows how to print a Result<T>. Not the verb.
  5. ExitEnvironment.ExitCode = result.IsSuccess ? 0 : 1. One line.

There is no File.WriteAllText. No if (provider == "native") { ... } else { ... }. No retry loop. No path manipulation. No serialisation. No anything. If you want to know how tls init actually works, you do not look in the CLI project. You look at TlsInitRequestHandler in the lib.

The TlsInitRequestHandler lives in the lib and looks like this:

[Injectable(ServiceLifetime.Singleton)]
public sealed class TlsInitRequestHandler : IHomeLabRequestHandler<TlsInitRequest, TlsInitResponse>
{
    private readonly IEnumerable<ITlsCertificateProvider> _providers;
    private readonly IHomeLabEventBus _events;
    private readonly IClock _clock;

    public TlsInitRequestHandler(
        IEnumerable<ITlsCertificateProvider> providers,
        IHomeLabEventBus events,
        IClock clock)
    {
        _providers = providers;
        _events = events;
        _clock = clock;
    }

    public async Task<Result<TlsInitResponse>> HandleAsync(TlsInitRequest req, CancellationToken ct)
    {
        var provider = _providers.SingleOrDefault(p => p.Name == req.Provider);
        if (provider is null)
            return Result.Failure<TlsInitResponse>($"Unknown TLS provider '{req.Provider}'");

        await _events.PublishAsync(new TlsCaGenerationStarted(req.CaName, _clock.UtcNow), ct);

        var caResult = await provider.GenerateCaAsync(req.CaName, ct);
        if (caResult.IsFailure)
            return caResult.Map<TlsInitResponse>();

        var certResult = await provider.GenerateCertAsync(
            ca: caResult.Value,
            domain: req.Domain,
            sans: new[] { req.Domain, $"*.{req.Domain}" },
            ct: ct);
        if (certResult.IsFailure)
            return certResult.Map<TlsInitResponse>();

        await _events.PublishAsync(
            new TlsCaGenerated(req.CaName, caResult.Value.CertificatePath!, _clock.UtcNow), ct);

        return Result.Success(new TlsInitResponse(
            CaPath:   caResult.Value.CertificatePath!,
            CertPath: certResult.Value.CertificatePath!,
            KeyPath:  certResult.Value.PrivateKeyPath!));
    }
}

The handler does the real work: it picks the provider (a plugin contract — see Part 10), generates the CA, generates the cert, publishes the events, returns the result. It uses IClock so the test fixture can freeze time. It uses ITlsCertificateProvider so the test fixture can swap in a fake provider. It uses IHomeLabEventBus so the test can assert on the events. None of those things are in the CLI project.

This separation is what lets us write the test in Part 02 and a faster unit test that goes directly through the handler:

[Fact]
public async Task tls_init_with_native_provider_emits_ca_generated_event()
{
    var clock = new FakeClock(DateTimeOffset.Parse("2026-04-08T12:00:00Z"));
    var bus = new RecordingEventBus();
    var provider = new FakeTlsProvider("native");
    var handler = new TlsInitRequestHandler(new[] { provider }, bus, clock);

    var result = await handler.HandleAsync(
        new TlsInitRequest("native", "test.lab", "Test CA", new DirectoryInfo("./out")),
        CancellationToken.None);

    result.IsSuccess.Should().BeTrue();
    bus.Recorded.Should().ContainSingle(e => e is TlsCaGenerated);
}

Three milliseconds. No process spawned. No file written. The verb test (from Part 02) covers the wiring; the handler test (here) covers the logic. They cover different things, and they cover them at appropriate speed.

If TlsInitCommand had any logic in it — say, a fallback that picks a provider when --provider is not specified — that fallback would only be testable through the verb test. The verb test takes 3 seconds. The handler test takes 3 milliseconds. A bug in the fallback would cost a thousand times more to find and fix. Hence the rule.


The wiring

The CLI assembly references the lib assembly. The lib assembly does not reference the CLI assembly. There are no exceptions. If the CLI needs a type, it imports it from the lib. If the lib needs to print something, it does so by returning a Result<T> whose data the CLI's IHomeLabConsole knows how to render. If the lib needs progress reporting, it does so via IHomeLabEventBus, and the CLI subscribes to the bus and prints progress in its own way.

The dependency graph looks like this:

HomeLab.Cli ──► HomeLab ──► Ops.Dsl ──► Dsl
     ▲              │             │       │
     │              ▼             ▼       ▼
    System.CommandLine        FrenchExDev.Net.{Result, Builder, Injectable, Clock, ...}

Note that System.CommandLine lives only in the HomeLab.Cli arrow. It does not appear anywhere in the lib. The lib does not know about commands, options, arguments, parsers, help text, or exit codes. It only knows about IHomeLabRequestHandler<TRequest, TResponse> and Result<T>.

This has practical consequences. If we ever decide to ship an HTTP API for HomeLab — say, for IDE integration or for Tauri-based desktop tooling — we add a new assembly called HomeLab.Http that references HomeLab and exposes the same handlers as REST endpoints. The lib does not change. The CLI does not change. The two surfaces share zero code other than the lib. They cannot drift, because the only contract they share is the typed request / response pair.

The same pattern applies to plugins. A plugin that adds a new TLS provider implements ITlsCertificateProvider (defined in the lib), declares [Injectable(ServiceLifetime.Singleton)] on its concrete class, and ships as a NuGet that the CLI loads. The plugin never sees System.CommandLine. The plugin never knows whether it is being called from the CLI, from an HTTP API, from a TUI, or from a unit test. The plugin only knows about its contract — GenerateCaAsync, GenerateCertAsync — and that contract is in the lib.


The test

We already saw the verb test (slow, end-to-end) and the handler test (fast, unit). The test that proves thin CLI, fat lib as a structural rule is different: it is an architecture test that runs at unit-test speed and asserts the dependency graph.

[Fact]
public void homelab_cli_assembly_must_not_contain_business_logic()
{
    var cliAssembly = typeof(HomeLabRootCommand).Assembly;
    var libAssembly = typeof(IHomeLabPipeline).Assembly;

    // Architecture rule 1: every type in HomeLab.Cli that isn't a verb command
    //                      must be infrastructure (renderers, formatters, etc.)
    var nonVerbTypes = cliAssembly.GetTypes()
        .Where(t => t.IsClass && !t.IsAbstract)
        .Where(t => !typeof(IHomeLabVerbCommand).IsAssignableFrom(t))
        .Where(t => !typeof(IHomeLabConsole).IsAssignableFrom(t))
        .Where(t => t.Namespace?.StartsWith("FrenchExDev.Net.HomeLab.Cli") == true)
        .ToList();

    nonVerbTypes.Should().OnlyContain(t =>
        t.Name.EndsWith("Renderer") ||
        t.Name.EndsWith("Formatter") ||
        t.Name.EndsWith("Console") ||
        t.Name == "HomeLabRootCommand" ||
        t.Name == "Program");

    // Architecture rule 2: no verb command may use File, Process, or Path APIs.
    foreach (var verbType in cliAssembly.GetTypes()
        .Where(t => typeof(IHomeLabVerbCommand).IsAssignableFrom(t) && !t.IsInterface))
    {
        var forbiddenCalls = MethodCallScanner.Scan(verbType, new[]
        {
            typeof(File),
            typeof(Directory),
            typeof(Process),
            typeof(Path),
            typeof(JsonSerializer),
        });
        forbiddenCalls.Should().BeEmpty(
            $"{verbType.Name} must not contain business logic — use the lib instead");
    }

    // Architecture rule 3: HomeLab (lib) must not reference System.CommandLine
    libAssembly.GetReferencedAssemblies()
        .Should().NotContain(a => a.Name == "System.CommandLine");
}

That test runs in milliseconds and prevents the entire class of "oh I'll just put this one bit of logic in the verb" slippage. We will see more of these architecture tests in Part 14 — they are the cheapest, fastest, highest-value tests in the whole suite.

The MethodCallScanner is ~50 lines that uses Mono.Cecil to walk the IL of a type and report any call to a method on a forbidden type. It is the kind of utility that pays for itself the first time someone (probably me) tries to add a "quick fix" to a verb at 11pm.


What this gives you that bash doesn't

A bash script is its own logic. There is no separation between "the parser" and "the work". If you write homelab.sh, every line is part of homelab.sh. You cannot replace one part without touching the rest. You cannot swap the renderer without rewriting the parser. You cannot test the parser without running the work. You cannot expose the work to a different presentation layer without copying the script and editing it.

Thin CLI, fat lib gives you, for the same surface area:

  • A reusable lib that any future tool — a TUI, a web dashboard, an HTTP API, an MCP server, a Tauri app — can consume directly
  • Architecture tests that prevent business logic from leaking into the wrong project, enforced at unit-test speed
  • Two test surfaces at two speeds: fast handler tests for logic, slower verb tests for wiring
  • A clean dependency graph where the lib has no idea what presentation layer is calling it, and the presentation layer has no idea what the lib is doing
  • Plugins that consume the lib's contracts without ever seeing System.CommandLine

The bargain pays back the moment you ship a second presentation layer — even if that second layer is just the test harness. Because your test harness is a second presentation layer, whether you admit it or not. CLI-first only works if the CLI is thin enough that the test harness can call the lib directly without re-implementing the verbs.


What's next

Part 04 addresses the next ingredient: the configuration file. If the lib is the contract, what does the lib take as input? It takes a typed request — but where does that request come from? In production, from a YAML file the user edits in VSCode. And that YAML file had better be schema-validated, intellisense-completed, and type-checked at edit time, or we have just moved the drift from bash to YAML and called it progress.


⬇ Download