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
The bundle splits into a one-time design-time download of 32 schemas and a Roslyn build-time pipeline that merges them into one unified schema feeding model, builder and version metadata emitters.

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.

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
SchemaVersionMerger folds every historical schema into a single ComposeService, stamping each property with the exact version where it first appeared so the unified type keeps the whole history in its attributes.

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.

⬇ Download