From CLI Strings to Compiled Infrastructure
4 packages. 97 scraped CLI versions. 32 JSON Schema versions. ~500 generated files. Zero hand-written YAML. Zero string concatenation. Zero flag typos discovered at 3am.
This series documents how I turned Docker and Docker Compose into fully typed C# APIs -- from the CLI commands themselves through the compose specification to composable service definitions -- using the BinaryWrapper pattern, Roslyn source generators, and a firm belief that infrastructure deserves the same type safety as domain models.
Before and After
| Scenario | Before (raw .NET) | After (typed API) |
|---|---|---|
| Run a container | Process.Start("docker", $"run -d --name {name} {image}") |
Docker.Container.Run(b => b.WithDetach(true).WithName(name).WithImage(image)) |
| Check a flag exists | Hope. grep the changelog. | [SinceVersion("19.03.0")] on WithPlatform() -- OptionNotSupportedException if version is too old |
Parse docker ps output |
Regex.Match(stdout, @"(\S+)\s+(\S+)\s+...") |
await executor.ExecuteAsync<ContainerListEvent, ContainerListResult>(...) |
| Define a compose service | Hand-write YAML, discover typos when docker compose up fails |
new ComposeFileBuilder().WithService("db", s => s.WithImage("postgres:16").WithHealthcheck(...)) |
| Compose a full stack | Copy-paste YAML blocks, merge manually, pray | contributors.ForEach(c => c.Contribute(composeFile)) -- each service is a self-contained, testable contributor |
The Four Layers
Layer 1 wraps the Docker CLI binary -- every docker command and flag across 40+ versions, generated from scraped --help output. Layer 2 does the same for Docker Compose -- 37 commands across 57 versions. Layer 3 types the Docker Compose specification itself -- 32 JSON Schema versions merged into one unified C# type system with [SinceVersion]/[UntilVersion] on every property. Layer 4 provides composable service contributors that build typed ComposeFile objects, render them to YAML, and execute them through the typed Compose CLI.
Each layer is triggered by a single attribute: [BinaryWrapper("docker")], [BinaryWrapper("docker-compose")], [ComposeBundle]. The Roslyn source generators do the rest.
How to Read This Series
Infrastructure engineers should start with Part I (the pain), Part II (the architecture), then skip to Part XIII (the contributor pattern) and Part XIV (the full stack) for practical usage.
Source generation enthusiasts should focus on Part V (parsing), Part VI (the Roslyn generator), Part X (JSON Schema reading), and Part XI (version merging).
.NET developers should read linearly -- the series builds from problem to solution, layer by layer, with code at every step.
Architects should read Part II, Part IX, Part XIV, and Part XVI for the design philosophy and trade-offs.
Part I: The Problem with Docker from .NET
47 Process.Start() calls, string-concatenated flags, regex-parsed stdout, hand-written YAML. Four categories of pain that motivated turning Docker into a typed API. Before-and-after code for each failure mode.
Part II: The Four Layers of Typed Docker
Four NuGet packages, three source generators, one attribute each. How the Docker CLI wrapper, Compose CLI wrapper, Compose Bundle, and service contributors stack together. Project structure, dependency graph, and the three-phase pipeline mapped per layer.
Part III: Design Time -- Scraping 40+ Docker Versions
Collecting GitHub tags, building Alpine containers with specific Docker versions, and recursively scraping --help output with CobraHelpParser. 40 versions, 180+ commands, 2,400+ flags serialized to JSON. Edge cases: aliases, moved commands, experimental flags, plugin commands.
Part IV: Design Time -- Scraping 57 Docker Compose Versions
57 versions of Docker Compose from v2.0.0 to v5.1.0. Binary downloads instead of package installs, flat command structure versus Docker's nested groups, and the version churn in flags like --wait, --watch, and --dry-run.
Part V: CobraHelpParser -- Parsing Go CLI Help Output
One parser for Docker, Docker Compose, Podman, and any cobra-based CLI. The state machine that detects section headers, extracts commands and flags, and maps Go types to C# types. Real scraped output annotated line by line. Malformed help handling and the reparse workflow.
Part VI: Build Time -- The Source Generator for CLI Commands
The Roslyn incremental generator that reads 40+ JSON files and emits ~200 .g.cs files in under 2 seconds. VersionDiffer.Merge() deep dive: aligning command trees, computing version bounds, producing the unified command tree. Three emitters: commands, builders, client.
Part VII: The Generated Docker API -- A Tour
DockerContainerRunCommand with 54 typed properties. Fluent builders with VersionGuard. The nested client: Docker.Container.Run(), Docker.Image.Build(), Docker.Network.Create(). Version-aware behavior: WithPlatform() against Docker 18.09 throws before the process starts.
Part VIII: The Generated Docker Compose API
37 commands, 57 versions, ~150 generated files. The flat client structure, global flags that propagate to every command, and the version timeline showing the API surface grow. Docker and DockerCompose clients working together in deployment workflows.
Part IX: Runtime -- Execution, Parsing, and Events
CommandExecutor bridges typed commands to processes. IOutputParser<TEvent> transforms stdout into typed domain events. IResultCollector<TEvent, TResult> accumulates them into results. Docker-specific parsers for build output, container listings, and compose orchestration events. BinaryBinding resolution and version detection.
Part X: The Compose Bundle -- Downloading and Reading 32 Schemas
The design-time download pipeline for compose-spec/compose-go JSON Schemas. SchemaReader deep dive: $ref resolution, oneOf flattening, inline type naming. The six union type patterns and how each maps to C#. This is the deep dive that Docker Compose Bundle deferred.
Part XI: Schema Version Merging -- 32 to 1
SchemaVersionMerger takes 32 parsed schemas and produces one unified type system. The merge algorithm, the five tricky cases (build, ports, depends_on, volumes, deploy), version metadata emission, and the full ComposeService property tree with 67 properties and their version ranges.
Part XII: From ComposeFile to docker-compose.yml
YAML serialization via YamlDotNet with custom type converters for union types. Round-trip testing against 32 schema versions. The developer loop: change a C# property, rebuild, diff the generated YAML. Generated versus hand-written YAML for a 5-service stack.
Part XIII: The Contributor Pattern -- Composable Service Definitions
IComposeFileContributor: one interface, one method, one service. Building real contributors for PostgreSQL, Redis, Traefik, and application services. Contributor composition via DI, service dependency management, and the full flow from contributors to running containers.
Part XIV: End to End -- A Complete Typed Docker Stack
Zero YAML in the repository. A .NET web app with PostgreSQL, Redis, Traefik, and a background worker -- deployed entirely from typed C#. The change-compile-deploy loop. Downstream consumers in the monorepo: the homelab stack, the GitLab stack.
Part XV: Testing Strategy -- From Parser to Deployment
Five testing layers, 400+ tests, zero Docker daemon required for 95% of them. Parser fixtures, generator snapshots, builder assertions, output parser sequences, and FakeProcessRunner integration tests. Compose Bundle round-trip validation.
Part XVI: Philosophy, Comparison, and What Comes Next
Three principles: the binary is the source of truth, version evolution is data, the compiler is the enforcement layer. Comparison with Docker.DotNet, Nuke, Pulumi, and raw ProcessStartInfo. The ecosystem vision: 7 wrapped binaries, 2 schema bundles, one pattern.
Prerequisites
- C# 12+ / .NET 10
- Familiarity with Docker and Docker Compose
- Roslyn source generators (explained from first principles in Part VI)
- The BinaryWrapper pattern (the foundation this series builds on)
- The Builder pattern (used throughout for fluent APIs)
This series is part of the FrenchExDev.Net ecosystem. Each package is production code, not a proof of concept. The numbers cited are from the current monorepo build.