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

Docker Compose Bundle: Unified Types from 32 Schema Versions

32 versions of the compose-spec, each adding properties, deprecating others, restructuring edge cases. Instead of generating 32 sets of types, what if you merged them all into ONE set of classes — and let the compiler remember exactly when each property appeared?

Wrapping the Docker Compose CLI is a solved problem — BinaryWrapper handles that with CobraHelpParser and version-aware command generation. But the Compose specification is a different beast. The compose-spec/compose-go repository publishes a JSON Schema that defines what goes inside a docker-compose.yml file. That schema evolves across releases, and treating it as a first-class type system is what the Docker Compose Bundle does.

The Problem

The Docker Compose specification is not static. Between version 1.0.9 and 2.10.1:

  • New properties appear: develop (1.19.0), provider (2.5.0), models (2.7.1), use_api_socket (2.6.5)
  • Properties get deprecated: version is marked obsolete
  • Union types proliferate: build accepts either a string or an object with 25 properties; ports accepts either short strings or structured objects; depends_on accepts either a list or a map with conditions
  • Inline objects nest arbitrarily: a service's volume config has sub-types for bind, volume, tmpfs, and image mounts

The naive approach — generate separate ComposeFile_v1_0_9, ComposeFile_v2_10_1, etc. — creates a combinatorial explosion. No shared API surface. No way to write code that works across versions without branching on every property access.

The alternative: treat version evolution as data, not separate codebases.

The Idea

The Docker Compose Bundle downloads all schema versions at design time, then at build time uses a Roslyn incremental source generator to merge all 32 schemas into a single unified type system. Each property carries [SinceVersion] / [UntilVersion] attributes recording exactly when it appeared or disappeared across the version history. The result: one ComposeFile class that is the superset of all versions, with version metadata baked into the generated code.

Diagram

Design time fetches schemas from GitHub with rate-limited parallel downloads — six concurrent requests, latest patch per minor version. Build time parses each JSON Schema, merges all versions into a unified superset, and emits C# classes. Output is ~80 generated files: model classes, fluent builders, and version metadata — all from a single [ComposeBundle] attribute.

What It Looks Like

1. Download the schemas

A design-time CLI collects releases from compose-spec/compose-go on GitHub, filters to the latest patch per major.minor, and downloads each schema in parallel:

var collector = new GitHubReleasesVersionCollector("compose-spec", "compose-go");
var allVersions = await collector.CollectVersionsAsync();

// Latest patch per major.minor → 32 schema files
var latestPerMinor = allVersions
    .Select(v => /* parse major.minor.patch */)
    .GroupBy(v => (v.Major, v.Minor))
    .Select(g => g.OrderByDescending(v => v.Patch).First())
    .ToList();

// Parallel download with semaphore (6 concurrent)
var semaphore = new SemaphoreSlim(6);
await Task.WhenAll(latestPerMinor.Select(async v =>
{
    await semaphore.WaitAsync();
    try
    {
        var url = $"https://raw.githubusercontent.com/compose-spec/compose-go/" +
                  $"v{v.Version}/schema/compose-spec.json";
        var json = await httpClient.GetStringAsync(url);
        await File.WriteAllTextAsync($"schemas/compose-spec-v{v.Version}.json", json);
    }
    finally { semaphore.Release(); }
}));

This produces 32 files: compose-spec-v1.0.9.json through compose-spec-v2.10.1.json, each containing the full JSON Schema for that version of the specification.

2. Declare the bundle

[ComposeBundle]
public partial class ComposeBundleDescriptor;

Plus the project file wiring:

<AdditionalFiles Include="schemas\compose-spec-*.json" />

That's it. The Roslyn incremental generator discovers the attribute, reads every matching JSON file, and emits the full type system at build time.

3. Use the generated API

var result = await new ComposeFileBuilder()
    .WithName("my-app")
    .WithService("web", s => s
        .WithImage("myapp:latest")
        .WithPorts([new() { Target = 80, Published = "8080" }])
        .WithEnvironment(new() { ["ASPNETCORE_ENVIRONMENT"] = "Development" })
        .WithDevelop(d => d                     // [SinceVersion("1.19.0")]
            .WithWatch([new() { Path = "./src", Action = "rebuild" }])))
    .WithService("db", s => s
        .WithImage("postgres:16")
        .WithVolumes([new() { Source = "pgdata", Target = "/var/lib/postgresql/data" }]))
    .WithNetwork("frontend", n => n
        .WithDriver("bridge"))
    .BuildAsync();

Every With method has IntelliSense. Properties introduced in later schema versions carry [SinceVersion] attributes that show up in documentation and can be queried at runtime. Builders inherit from AbstractBuilder<T> with async validation and return Result<T> for functional error handling.

Schema Version Merging: 32 Schemas, One Type System

This is the core innovation. SchemaVersionMerger takes 32 parsed schemas and produces a single UnifiedSchema where every definition and every property carries version bounds:

  1. Sort all 32 schemas by semantic version
  2. Union all definition names across every schema
  3. For each definition and each property: find the first version where it appeared and the last version where it's still present
  4. Take the latest definition (newest type shape, newest description)
  5. Annotate with [SinceVersion] if it didn't exist in the oldest schema, [UntilVersion] if it disappeared before the newest
Diagram

Here's what the generated ComposeService actually looks like (excerpted from the 473-line generated file):

// <auto-generated/>
public partial class ComposeService
{
    public ComposeDeployment? Deploy { get; set; }
    public ComposeServiceBuildConfig? Build { get; set; }
    public string? Image { get; set; }
    public List<ComposeServicePortsConfig>? Ports { get; set; }
    public Dictionary<string, string?>? Environment { get; set; }
    // ... 50+ always-present properties ...

    [SinceVersion("1.19.0")]
    public ComposeDevelopment? Develop { get; set; }

    [SinceVersion("2.3.0")]
    public List<ComposeServiceHook>? PostStart { get; set; }

    [SinceVersion("2.5.0")]
    public ComposeServiceProvider? Provider { get; set; }

    [SinceVersion("2.7.1")]
    public object? Models { get; set; }

    public Dictionary<string, object?>? Extensions { get; set; }
}

Why "latest wins": when a property's JSON Schema type changes across versions (e.g., added enum values, refined descriptions), the generator takes the newest shape. This is safe because compose-spec is additive — newer schemas are backward-compatible supersets.

The generated ComposeSchemaVersions class provides runtime access to the full version list:

ComposeSchemaVersions.Available  // 32 versions, sorted
ComposeSchemaVersions.Latest     // "2.10.1"
ComposeSchemaVersions.Oldest     // "1.0.9"

Union Types: oneOf Is Everywhere

The compose-spec uses JSON Schema oneOf extensively for properties that accept either a string shorthand or a structured object. SchemaReader resolves each pattern to a concrete C# type:

Schema Pattern Example Generated C# Type
oneOf[string, object{...}] build: "." | { context, dockerfile, ... } ComposeServiceBuildConfig?
oneOf[string, array] dns: "8.8.8.8" | ["8.8.8.8", "1.1.1.1"] List<string>?
oneOf[string, integer] cpus: "0.5" | 1 int?
oneOf[string, boolean] read_only: "true" | true bool?
oneOf[null, $ref] healthcheck: null | {...} ComposeHealthcheck?
Array of oneOf[string, object] ports: ["8080:80"] | [{ target, published }] List<ComposeServicePortsConfig>?

When the oneOf includes an object with properties, the generator creates an inline class named after the parent and property. For build, that's ComposeServiceBuildConfig — 25 properties generated directly from the schema:

// <auto-generated/>
public partial class ComposeServiceBuildConfig
{
    public string? Context { get; set; }
    public string? Dockerfile { get; set; }
    public string? DockerfileInline { get; set; }
    public string? Target { get; set; }
    public Dictionary<string, string?>? Args { get; set; }
    public List<string>? CacheFrom { get; set; }
    public List<string>? CacheTo { get; set; }
    public bool? NoCache { get; set; }
    public string? Network { get; set; }
    public List<string>? Platforms { get; set; }
    public List<string>? Tags { get; set; }
    public List<string>? Secrets { get; set; }
    public Dictionary<string, string?>? Ssh { get; set; }
    // ... 12 more properties
    public Dictionary<string, object?>? Extensions { get; set; }
}

Inline classes nest recursively. A service's volumes property contains ComposeServiceVolumesConfig, which itself has inline sub-types: ComposeServiceVolumesConfigBind, ComposeServiceVolumesConfigVolume, ComposeServiceVolumesConfigTmpfs, and ComposeServiceVolumesConfigImage.

The Generated Class Hierarchy

32 schemas in, ~80 C# files out — 40 model classes and 40 matching fluent builders:

ComposeFile (root — services, networks, volumes, secrets, configs, models)
├── ComposeService (67 properties, 473 lines)
│   ├── ComposeServiceBuildConfig (oneOf: string | object, 25 props)
│   ├── ComposeServiceBlkioConfig
│   ├── ComposeServiceCredentialSpec
│   ├── ComposeServiceDependsOnCondition
│   ├── ComposeServiceDevicesConfig
│   ├── ComposeServiceExtendsConfig
│   ├── ComposeServiceLogging
│   ├── ComposeServicePortsConfig (array of oneOf)
│   ├── ComposeServiceVolumesConfig (array of oneOf)
│   │   ├── ...Bind, ...Volume, ...Tmpfs, ...Image
│   ├── ComposeServiceProvider                   [since 2.5.0]
│   ├── ComposeServiceHook                       [since 2.3.0]
│   ├── ComposeDeployment
│   │   ├── ...Placement → ...PreferencesItem
│   │   ├── ...Resources → ...Limits, ...Reservations
│   │   ├── ...RestartPolicy, ...RollbackConfig, ...UpdateConfig
│   ├── ComposeHealthcheck
│   └── ComposeDevelopment                       [since 1.19.0]
│       └── ComposeDevelopmentWatchItem
├── ComposeNetwork → ComposeNetworkIpam → ...IpamConfigItem
├── ComposeVolume
├── ComposeSecret
├── ComposeConfig
├── ComposeBlkioLimit, ComposeBlkioWeight
└── ComposeModel                                 [since 2.7.1]

Every model class has a corresponding builder. Every builder inherits from AbstractBuilder<T> with per-property validation hooks and BuildAsync() returning Result<Reference<T>>. Every class includes an Extensions dictionary for x-* custom fields that round-trip through serialization.

Architecture at a Glance

DockerCompose/
├── src/
│   ├── FrenchExDev.Net.DockerCompose.Bundle.Design/      Schema downloader CLI
│   │   └── Program.cs                                    GitHub API → cached JSON
│   ├── FrenchExDev.Net.DockerCompose.Bundle.Attributes/  [ComposeBundle] marker
│   ├── FrenchExDev.Net.DockerCompose.Bundle.SourceGenerator/
│   │   ├── ComposeBundleGenerator.cs      Entry: IIncrementalGenerator
│   │   ├── SchemaReader.cs                JSON Schema → SchemaModel ($ref, oneOf, inline)
│   │   ├── SchemaVersionMerger.cs         THE KEY: merges N schemas → unified superset
│   │   ├── SchemaModels.cs                Internal types: SchemaModel, UnifiedSchema
│   │   ├── ModelClassEmitter.cs           Emits partial classes + version attributes
│   │   ├── VersionMetadataEmitter.cs      Emits ComposeSchemaVersions + attributes
│   │   ├── BuilderHelper.cs               Schema → BuilderEmitModel conversion
│   │   └── NamingHelper.cs                snake_case → PascalCase, def → class name
│   └── FrenchExDev.Net.DockerCompose.Bundle/             Consumer library
│       ├── ComposeBundleDescriptor.cs     [ComposeBundle] trigger (one line)
│       ├── ComposeSchemaVersion.cs        Semver record with Parse/Compare
│       └── schemas/                       32 cached JSON Schema files
└── FrenchExDev.Net.DockerCompose/                        CLI wrapper (BinaryWrapper)

Key Design Decisions

  • JSON as intermediate format — schemas are downloaded once and cached. Adding a new version means downloading one file and rebuilding. The generator runs in milliseconds.
  • Incremental source generator — only regenerates when AdditionalFiles change. No build penalty for a 32-version schema set.
  • Unified types, not versioned types — one ComposeService, not 32 versions. Version constraints are data ([SinceVersion]), not code duplication.
  • Fluent builders from FrenchExDev.Net.Builder — the generator reuses AbstractBuilder<T> with async validation, dictionary builders for services/networks, and Result<T> returns.
  • Extensions preserved — every generated class includes Dictionary<string, object?>? Extensions so that x-* custom fields survive serialization round-trips.

Why This Matters

The Docker Compose specification is one of the most widely used infrastructure schemas. Most .NET libraries targeting it either hand-code a model for one schema version, or fall back to Dictionary<string, object>. Both approaches break as the spec evolves.

This approach turns version evolution into a first-class concern. Instead of tracking changes manually, the generator discovers them automatically from the raw schemas. When compose-spec v2.11.0 drops, you run the design-time tool — it downloads one new file — and the next build produces updated types with the correct [SinceVersion] annotations. Zero manual model work.

The pattern is generalizable. Any specification that publishes versioned JSON Schemas — OpenAPI, AsyncAPI, CloudEvents, Kubernetes CRDs — could be wrapped the same way: download, merge, generate, annotate. The schema is the source of truth. The compiler is the enforcement layer. Version drift becomes a solved problem.


Docker Compose Bundle is part of the FrenchExDev.Net ecosystem. Built with Roslyn incremental source generators, JSON Schema traversal, and a firm belief that infrastructure schemas deserve the same type safety as domain models.