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 XI: Schema Version Merging -- 32 to 1

32 schemas merged into 1 unified type system -- 67 properties tracked across versions, 12 additions, 1 deprecation.


The Problem That Merging Solves

Part X downloaded and parsed 32 JSON Schemas into SchemaModel trees. Now we merge them. This is the core innovation of the Compose Bundle: instead of generating 32 separate type systems, we produce one unified superset where every property knows exactly when it appeared and disappeared.

If you have not read the Docker Compose Bundle overview yet, start there for the motivating context. This post is the deep dive into the merge algorithm itself.

The naive approach to multi-version schemas is to generate one type system per version. You would end up with ComposeFile_v1_0_9, ComposeFile_v1_1_0, ComposeFile_v1_2_0, all the way up to ComposeFile_v2_10_1. Thirty-two type systems. Each with its own ComposeService, its own ComposeNetworkConfig, its own ComposeVolumeConfig. The combinatorial explosion is immediate: 32 versions times 40 model classes equals 1,280 classes. And that is before builders.

Worse, those 1,280 classes share 95% of their shape. The difference between ComposeService in version 1.0.9 and version 2.10.1 is 12 added properties and 1 deprecated property. The other 54 properties are identical across every version. Generating 32 copies of a 67-property class to track 13 version-specific differences is not engineering. It is waste.

The alternative -- the one I chose -- is to produce a single unified ComposeFile that is the superset of all 32 versions, with metadata annotations that record exactly when each property entered and exited the specification.

Here is what the difference feels like from the consuming side:

// Naive: 32 separate type systems
// Which one do you use? What if your Docker Compose is 2.5.0?
ComposeFile_v2_10_1 file = new();
// Fails at runtime if the user's Compose spec is older
file.Services["web"].Provider = new ComposeServiceProvider();

// Merged: one type system, version metadata on properties
ComposeFile file = new();
file.Services["web"].Provider = new ComposeServiceProvider();
// The IDE shows: [SinceVersion("2.5.0")]
// The validator warns at build time if you target < 2.5.0

One set of types. One set of builders. One API surface to learn. The version information is metadata, not a class hierarchy.


The Merge Algorithm

The merger operates on the output of SchemaReader from Part X: a list of (ComposeSchemaVersion, SchemaModel) pairs. Each SchemaModel contains a dictionary of definitions, and each definition contains a list of properties with their types.

The algorithm is five steps:

  1. Sort all 32 schemas by semantic version, oldest first.
  2. Union all definition names across all 32 schemas.
  3. For each definition, union all property names across all versions.
  4. For each property, find the first version where it appeared (SinceVersion) and the last version where it was still present (UntilVersion).
  5. Take the latest type shape -- the newest schema version's type wins.

Step 5 deserves justification, and I will get to that in the "Why Latest Wins" section. For now, here is the full implementation:

public static class SchemaVersionMerger
{
    public static UnifiedSchema Merge(
        IReadOnlyList<(ComposeSchemaVersion Version, SchemaModel Schema)> versioned)
    {
        var sorted = versioned.OrderBy(v => v.Version).ToList();
        var oldest = sorted[0].Version;
        var newest = sorted[^1].Version;

        // Step 2: Union all definition names across all versions
        var allDefinitions = sorted
            .SelectMany(v => v.Schema.Definitions.Keys)
            .Distinct()
            .ToList();

        var unified = new UnifiedSchema();

        foreach (var defName in allDefinitions)
        {
            var firstSeen = sorted
                .First(v => v.Schema.Definitions.ContainsKey(defName))
                .Version;
            var lastSeen = sorted
                .Last(v => v.Schema.Definitions.ContainsKey(defName))
                .Version;

            // Step 5: Take the latest version's definition shape
            var latestDef = sorted
                .Last(v => v.Schema.Definitions.ContainsKey(defName))
                .Schema.Definitions[defName];

            var unifiedDef = new UnifiedDefinition
            {
                Name = defName,
                Schema = latestDef,
                SinceVersion = firstSeen == oldest ? null : firstSeen,
                UntilVersion = lastSeen == newest ? null : lastSeen,
                Properties = MergeProperties(
                    defName, sorted, oldest, newest),
            };

            unified.Definitions.Add(defName, unifiedDef);
        }

        return unified;
    }

    private static List<UnifiedProperty> MergeProperties(
        string defName,
        List<(ComposeSchemaVersion Version, SchemaModel Schema)> sorted,
        ComposeSchemaVersion oldest,
        ComposeSchemaVersion newest)
    {
        // Step 3: Union all property names for this definition
        var allProps = sorted
            .Where(v => v.Schema.Definitions.ContainsKey(defName))
            .SelectMany(v => v.Schema.Definitions[defName]
                .Properties.Select(p => p.Name))
            .Distinct()
            .ToList();

        return allProps.Select(propName =>
        {
            // Step 4: Find version bounds
            var firstSeen = sorted
                .First(v =>
                    v.Schema.Definitions.ContainsKey(defName) &&
                    v.Schema.Definitions[defName].Properties
                        .Any(p => p.Name == propName))
                .Version;

            var lastSeen = sorted
                .Last(v =>
                    v.Schema.Definitions.ContainsKey(defName) &&
                    v.Schema.Definitions[defName].Properties
                        .Any(p => p.Name == propName))
                .Version;

            // Step 5: Take latest property shape
            var latestProp = sorted
                .Last(v =>
                    v.Schema.Definitions.ContainsKey(defName) &&
                    v.Schema.Definitions[defName].Properties
                        .Any(p => p.Name == propName))
                .Schema.Definitions[defName].Properties
                .First(p => p.Name == propName);

            return new UnifiedProperty
            {
                Name = propName,
                Schema = latestProp.Schema,
                Description = latestProp.Description,
                SinceVersion = firstSeen == oldest
                    ? null : firstSeen,
                UntilVersion = lastSeen == newest
                    ? null : lastSeen,
            };
        }).ToList();
    }
}

The null convention matters. If SinceVersion is null, the property existed in the oldest schema -- it has always been there. If UntilVersion is null, the property exists in the newest schema -- it is still current. A property with both null has been present in every single version of the compose specification. A property with SinceVersion = "2.5.0" and UntilVersion = null was added in 2.5.0 and is still present. A property with SinceVersion = null and UntilVersion = "1.8.0" was present in the earliest schemas but was removed after 1.8.0.

That last case -- removal -- happened exactly once across 32 versions. I will get to which property it was.


Visualizing the Merge

Here is the algorithm operating on three versions to show how properties align and get their version annotations:

Diagram
The merge algorithm at work on three schema versions — always-present properties keep null bounds, develop/provider/models pick up [SinceVersion] from the earliest schema they appear in, and build takes the latest (richest) object shape.

The four properties that appear in all three versions get no version annotation -- they are part of the base API surface. The develop property, first seen in v1.19.0, gets [SinceVersion("1.19.0")]. The provider and models properties, added later, get their respective annotations. All type shapes are taken from the latest version (v2.10.1).


The Five Tricky Merging Cases

Most properties merge trivially: a string stays a string, a boolean stays a boolean, the type is identical across all 32 versions. The interesting engineering is in the five properties whose schema shapes changed across versions in non-trivial ways.

Case 1: build -- string | object across versions

In the earliest compose schemas, build was a simple string -- the build context path:

{
  "build": {
    "type": "string",
    "description": "Build context path"
  }
}

Later versions made build a oneOf -- either a string or a detailed object:

{
  "build": {
    "oneOf": [
      { "type": "string" },
      {
        "type": "object",
        "properties": {
          "context": { "type": "string" },
          "dockerfile": { "type": "string" },
          "args": { "$ref": "#/definitions/list_or_dict" },
          "ssh": { "$ref": "#/definitions/list_or_dict" },
          "cache_from": { "type": "array" },
          "cache_to": { "type": "array" },
          "extra_hosts": { "$ref": "#/definitions/list_or_dict" },
          "isolation": { "type": "string" },
          "labels": { "$ref": "#/definitions/list_or_dict" },
          "no_cache": { "type": "boolean" },
          "pull": { "type": "boolean" },
          "shm_size": { "type": ["integer", "string"] },
          "target": { "type": "string" },
          "secrets": { "$ref": "#/definitions/service_config_or_secret" },
          "tags": { "type": "array" },
          "platforms": { "type": "array" },
          "privileged": { "type": "boolean" },
          "network": { "type": "string" },
          "entitlements": { "type": "array" },
          "ulimits": { "$ref": "#/definitions/ulimits" },
          "additional_contexts": { "$ref": "#/definitions/list_or_dict" },
          "dockerfile_inline": { "type": "string" }
        }
      }
    ]
  }
}

The object form grew from 5 properties in 1.0.9 to 25 properties in 2.10.1. The SchemaReader from Part X already handles oneOf flattening -- the string variant becomes the Context property, and the object properties become the other 24 properties. The merger takes the latest shape (25 properties) and annotates each sub-property with its version range.

The generated C# for the merged result:

public partial class ComposeServiceBuildConfig
{
    /// <summary>
    /// Build context path. Also accepts the string shorthand form.
    /// </summary>
    public string? Context { get; set; }

    public string? Dockerfile { get; set; }

    public ComposeListOrDict? Args { get; set; }

    public ComposeListOrDict? Ssh { get; set; }

    public List<string>? CacheFrom { get; set; }

    public List<string>? CacheTo { get; set; }

    public ComposeListOrDict? ExtraHosts { get; set; }

    public string? Isolation { get; set; }

    public ComposeListOrDict? Labels { get; set; }

    public bool? NoCache { get; set; }

    public bool? Pull { get; set; }

    public StringOrInt? ShmSize { get; set; }

    public string? Target { get; set; }

    [SinceVersion("1.4.0")]
    public List<ComposeServiceConfigOrSecret>? Secrets { get; set; }

    [SinceVersion("1.8.0")]
    public List<string>? Tags { get; set; }

    [SinceVersion("1.12.0")]
    public List<string>? Platforms { get; set; }

    [SinceVersion("1.15.0")]
    public bool? Privileged { get; set; }

    [SinceVersion("1.15.0")]
    public string? Network { get; set; }

    [SinceVersion("1.19.0")]
    public List<string>? Entitlements { get; set; }

    [SinceVersion("1.19.0")]
    public ComposeUlimits? Ulimits { get; set; }

    [SinceVersion("2.0.0")]
    public ComposeListOrDict? AdditionalContexts { get; set; }

    [SinceVersion("2.1.0")]
    public string? DockerfileInline { get; set; }
}

Twenty-five properties. Fourteen always present. Eleven added across versions. Every consumer can see at a glance which properties are safe to use for any given compose specification version.

Case 2: ports -- short syntax strings OR structured objects

The ports property in a compose file accepts two forms:

# Short syntax (string)
ports:
  - "8080:80"
  - "443:443/tcp"

# Long syntax (object)
ports:
  - target: 80
    published: "8080"
    protocol: tcp
    mode: host

Both forms coexist -- you can mix them in the same file. The JSON Schema represents this as:

{
  "ports": {
    "type": "array",
    "items": {
      "oneOf": [
        { "type": "number" },
        {
          "type": "string",
          "format": "ports"
        },
        {
          "type": "object",
          "properties": {
            "name": { "type": "string" },
            "mode": { "type": "string" },
            "host_ip": { "type": "string" },
            "target": { "type": "integer" },
            "published": { "type": ["string", "integer"] },
            "protocol": { "type": "string" },
            "app_protocol": { "type": "string" }
          }
        }
      ]
    }
  }
}

The SchemaReader flattens this into a single type with all properties, including the string shorthand. The merger tracks sub-property additions:

public partial class ComposeServicePortsConfig
{
    /// <summary>
    /// The short syntax form (e.g., "8080:80/tcp").
    /// Mutually exclusive with structured properties.
    /// </summary>
    public string? Value { get; set; }

    public int? Target { get; set; }

    public StringOrInt? Published { get; set; }

    public string? Protocol { get; set; }

    public string? Mode { get; set; }

    public string? HostIp { get; set; }

    [SinceVersion("1.16.0")]
    public string? Name { get; set; }

    [SinceVersion("2.6.0")]
    public string? AppProtocol { get; set; }
}

The Name field was added in 1.16.0 -- before that, ports were anonymous. AppProtocol appeared in 2.6.0 for service mesh integration. Every other field has been there since the beginning.

Case 3: depends_on -- list | map with conditions

This is the property where the compose specification evolved the most dramatically. Early versions accepted only a list of service names:

depends_on:
  - db
  - redis

Later versions added a structured form with conditions:

depends_on:
  db:
    condition: service_healthy
  redis:
    condition: service_started
    restart: true
    required: true

The restart field was added in 2.3.0 and required in 2.9.0. The merger handles this as a oneOf between a string list and a map of condition objects:

public partial class ComposeServiceDependsOnConfig
{
    /// <summary>
    /// Simple form: just the service name.
    /// </summary>
    public string? Value { get; set; }

    public string? Condition { get; set; }

    [SinceVersion("2.3.0")]
    public bool? Restart { get; set; }

    [SinceVersion("2.9.0")]
    public bool? Required { get; set; }
}

Four properties. The simple form (Value) is the string shorthand. The structured form has Condition (always present once the structured form existed), Restart (added in 2.3.0), and Required (added in 2.9.0). The consumer picks a form and the version annotations guide them.

Case 4: volumes -- 4 sub-types

Volume mounts in Docker Compose come in four flavors, and each has its own set of properties:

volumes:
  # Bind mount
  - type: bind
    source: ./data
    target: /app/data
    bind:
      propagation: rprivate
      create_host_path: true

  # Named volume
  - type: volume
    source: db-data
    target: /var/lib/postgresql/data
    volume:
      nocopy: true
      subpath: pgdata

  # tmpfs mount
  - type: tmpfs
    target: /tmp
    tmpfs:
      size: 67108864
      mode: 1777

  # Image mount (newest addition)
  - type: image
    source: my-config-image:latest
    target: /config

The JSON Schema represents these as a oneOf with four object branches plus the string shorthand. Each sub-type has a nested configuration object (bind, volume, tmpfs), and those nested objects gained properties independently across versions.

The merger tracks each sub-type's property additions separately:

public partial class ComposeServiceVolumeConfig
{
    /// <summary>
    /// Short syntax: "source:target:mode" or just "target"
    /// </summary>
    public string? Value { get; set; }

    public string? Type { get; set; }     // bind | volume | tmpfs | image
    public string? Source { get; set; }
    public string? Target { get; set; }
    public bool? ReadOnly { get; set; }

    public ComposeServiceVolumeBindConfig? Bind { get; set; }
    public ComposeServiceVolumeVolumeConfig? Volume { get; set; }
    public ComposeServiceVolumeTmpfsConfig? Tmpfs { get; set; }

    [SinceVersion("2.8.0")]
    public string? Image { get; set; }
}

public partial class ComposeServiceVolumeBindConfig
{
    public string? Propagation { get; set; }

    [SinceVersion("1.13.0")]
    public bool? CreateHostPath { get; set; }

    [SinceVersion("2.2.0")]
    public string? Selinux { get; set; }
}

public partial class ComposeServiceVolumeVolumeConfig
{
    public bool? Nocopy { get; set; }

    [SinceVersion("2.6.0")]
    public string? Subpath { get; set; }
}

public partial class ComposeServiceVolumeTmpfsConfig
{
    public long? Size { get; set; }

    [SinceVersion("1.10.0")]
    public int? Mode { get; set; }
}

The image mount type is the newest -- added in 2.8.0 for mounting filesystem content from OCI images directly. CreateHostPath on bind mounts (1.13.0) automatically creates the host directory if it does not exist. Subpath on named volumes (2.6.0) mounts a subdirectory within the volume. Each sub-type evolved on its own timeline, and the merger preserves that timeline precisely.

Case 5: deploy -- subtree growth

The deploy property is the most deeply nested schema object in the compose specification, and it grew the most across versions. In 1.0.9 it had 5 properties. By 2.10.1 it has 20+. And those properties contain nested types -- resources has limits and reservations, placement has preferences and constraints, update_config and rollback_config share a shape.

Here is the timeline of deploy property additions:

1.0.9:  endpoint_mode, labels, mode, placement, replicas,
        resources, restart_policy, update_config
1.7.0:  + rollback_config
1.15.0: + extensions (x- prefix support)
2.4.0:  + placement.max_replicas_per_node
2.8.0:  + resources.reservations.devices (GPU support)

The generated type tree:

public partial class ComposeServiceDeployConfig
{
    public string? EndpointMode { get; set; }
    public ComposeListOrDict? Labels { get; set; }
    public string? Mode { get; set; }
    public ComposeServiceDeployPlacement? Placement { get; set; }
    public int? Replicas { get; set; }
    public ComposeServiceDeployResources? Resources { get; set; }
    public ComposeServiceDeployRestartPolicy? RestartPolicy { get; set; }
    public ComposeServiceDeployUpdateConfig? UpdateConfig { get; set; }

    [SinceVersion("1.7.0")]
    public ComposeServiceDeployUpdateConfig? RollbackConfig { get; set; }
}

public partial class ComposeServiceDeployResources
{
    public ComposeServiceDeployResourcesLimit? Limits { get; set; }
    public ComposeServiceDeployResourcesReservation? Reservations { get; set; }
}

public partial class ComposeServiceDeployResourcesReservation
{
    public string? Cpus { get; set; }
    public StringOrInt? Memory { get; set; }

    [SinceVersion("2.8.0")]
    public List<ComposeServiceDeployResourcesDevice>? Devices { get; set; }
}

public partial class ComposeServiceDeployPlacement
{
    public List<string>? Constraints { get; set; }
    public List<ComposeServiceDeployPlacementPreference>? Preferences { get; set; }

    [SinceVersion("2.4.0")]
    public int? MaxReplicasPerNode { get; set; }
}

The deploy subtree is where the merger's recursive nature matters most. It is not just ComposeServiceDeployConfig gaining properties -- it is the nested Placement and Resources types also gaining properties, each on their own schedule. The merger handles this the same way at every level: union properties, find version bounds, take latest shape.


The oneOf Resolution Decision Flowchart

Each of the five tricky cases involves a oneOf in the JSON Schema -- a union of types that the reader must resolve into a single C# class. The merger delegates to SchemaReader for the resolution strategy and then merges the resolved result. Here is the decision flow:

Diagram
The oneOf resolution strategy table as a decision flow — SchemaReader picks one of four patterns per property (string-or-object, string-or-array, multi-branch, multi-object discriminator) before the merger unions the resolved properties across all 32 versions.

The key insight: the oneOf resolution happens in SchemaReader (per-version), not in the merger. The merger sees already-resolved property lists and simply unions them. This separation of concerns keeps the merger straightforward -- it never needs to understand JSON Schema semantics. It only needs to diff property names across sorted version lists.


Version Metadata Emission

Once the merger produces the UnifiedSchema, the code generator emits three pieces of version infrastructure: the version catalog, the attribute definitions, and the attribute annotations on generated properties.

The Version Catalog

Every known schema version is recorded in a generated static class:

// <auto-generated by FrenchExDev.Net.DockerCompose.Bundle.SourceGenerator/>
namespace FrenchExDev.Net.DockerCompose.Bundle;

public static class ComposeSchemaVersions
{
    public static IReadOnlyList<ComposeSchemaVersion> Available { get; } = new[]
    {
        ComposeSchemaVersion.Parse("1.0.9"),
        ComposeSchemaVersion.Parse("1.1.0"),
        ComposeSchemaVersion.Parse("1.2.0"),
        ComposeSchemaVersion.Parse("1.3.0"),
        ComposeSchemaVersion.Parse("1.4.0"),
        ComposeSchemaVersion.Parse("1.5.0"),
        ComposeSchemaVersion.Parse("1.6.0"),
        ComposeSchemaVersion.Parse("1.7.0"),
        ComposeSchemaVersion.Parse("1.8.0"),
        ComposeSchemaVersion.Parse("1.9.0"),
        ComposeSchemaVersion.Parse("1.10.0"),
        ComposeSchemaVersion.Parse("1.11.0"),
        ComposeSchemaVersion.Parse("1.12.0"),
        ComposeSchemaVersion.Parse("1.13.0"),
        ComposeSchemaVersion.Parse("1.14.0"),
        ComposeSchemaVersion.Parse("1.15.0"),
        ComposeSchemaVersion.Parse("1.16.0"),
        ComposeSchemaVersion.Parse("1.17.0"),
        ComposeSchemaVersion.Parse("1.18.0"),
        ComposeSchemaVersion.Parse("1.19.0"),
        ComposeSchemaVersion.Parse("2.0.0"),
        ComposeSchemaVersion.Parse("2.1.0"),
        ComposeSchemaVersion.Parse("2.2.0"),
        ComposeSchemaVersion.Parse("2.3.0"),
        ComposeSchemaVersion.Parse("2.4.0"),
        ComposeSchemaVersion.Parse("2.5.0"),
        ComposeSchemaVersion.Parse("2.6.0"),
        ComposeSchemaVersion.Parse("2.7.0"),
        ComposeSchemaVersion.Parse("2.7.1"),
        ComposeSchemaVersion.Parse("2.8.0"),
        ComposeSchemaVersion.Parse("2.9.0"),
        ComposeSchemaVersion.Parse("2.10.0"),
        ComposeSchemaVersion.Parse("2.10.1"),
    };

    public static ComposeSchemaVersion Oldest => Available[0];     // 1.0.9
    public static ComposeSchemaVersion Latest => Available[^1];    // 2.10.1
}

Thirty-two entries. The ComposeSchemaVersion type implements IComparable<ComposeSchemaVersion> with proper semantic version ordering, so consumers can write range checks:

var target = ComposeSchemaVersion.Parse("2.5.0");
bool isSupported = target >= ComposeSchemaVersions.Oldest
    && target <= ComposeSchemaVersions.Latest;

The Version Attributes

Two attributes carry the version metadata:

// <auto-generated by FrenchExDev.Net.DockerCompose.Bundle.SourceGenerator/>
namespace FrenchExDev.Net.DockerCompose.Bundle;

/// <summary>
/// Indicates the compose specification version where this property
/// first appeared. Absence means the property has always been present.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public sealed class SinceVersionAttribute : Attribute
{
    public string Version { get; }

    public SinceVersionAttribute(string version)
        => Version = version;
}

/// <summary>
/// Indicates the last compose specification version where this
/// property was present. Absence means the property is still current.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public sealed class UntilVersionAttribute : Attribute
{
    public string Version { get; }

    public UntilVersionAttribute(string version)
        => Version = version;
}

The generator emits these on properties during code generation. The emission logic is a direct read from the UnifiedProperty:

private static void EmitProperty(
    IndentedTextWriter writer,
    UnifiedProperty prop)
{
    if (prop.Description is not null)
    {
        writer.WriteLine("/// <summary>");
        writer.WriteLine($"/// {prop.Description}");
        writer.WriteLine("/// </summary>");
    }

    if (prop.SinceVersion is not null)
        writer.WriteLine(
            $"[SinceVersion(\"{prop.SinceVersion}\")]");

    if (prop.UntilVersion is not null)
        writer.WriteLine(
            $"[UntilVersion(\"{prop.UntilVersion}\")]");

    writer.WriteLine(
        $"public {prop.ClrType} {prop.PascalName} {{ get; set; }}");
    writer.WriteLine();
}

Simple conditional emission. If the property has no version bounds, no attributes appear -- the property is part of the base specification.

How It Looks on Generated Properties

Here is the generated ComposeService class with version annotations in context:

// <auto-generated by FrenchExDev.Net.DockerCompose.Bundle.SourceGenerator/>
namespace FrenchExDev.Net.DockerCompose.Bundle;

public partial class ComposeService
{
    /// <summary>
    /// Container image to use.
    /// </summary>
    public string? Image { get; set; }

    /// <summary>
    /// Build configuration.
    /// </summary>
    public ComposeServiceBuildConfig? Build { get; set; }

    /// <summary>
    /// Command to run in the container.
    /// </summary>
    public ComposeStringOrList? Command { get; set; }

    // ... 50+ properties with no version annotation ...

    /// <summary>
    /// Development mode configuration for hot-reload
    /// and file watching.
    /// </summary>
    [SinceVersion("1.19.0")]
    public ComposeDevelopment? Develop { get; set; }

    /// <summary>
    /// Post-start lifecycle hooks.
    /// </summary>
    [SinceVersion("2.3.0")]
    public List<ComposeServiceHook>? PostStart { get; set; }

    /// <summary>
    /// Pre-stop lifecycle hooks.
    /// </summary>
    [SinceVersion("2.3.0")]
    public List<ComposeServiceHook>? PreStop { get; set; }

    /// <summary>
    /// External service provider configuration.
    /// </summary>
    [SinceVersion("2.5.0")]
    public ComposeServiceProvider? Provider { get; set; }

    /// <summary>
    /// GPU and AI model configurations.
    /// </summary>
    [SinceVersion("2.7.1")]
    public object? Models { get; set; }

    /// <summary>
    /// GPGPU configuration (deprecated, use deploy.resources).
    /// </summary>
    [UntilVersion("1.8.0")]
    public ComposeServiceGpuConfig? Gpus { get; set; }
}

There it is -- the one deprecated property. Gpus appeared in the earliest schemas and was removed after 1.8.0, replaced by deploy.resources.reservations.devices. It gets [UntilVersion("1.8.0")] to tell consumers: "this property exists in the type system for backward compatibility, but it was removed from the compose specification after version 1.8.0."


The Full ComposeService Property Tree

Here are all 67 properties of ComposeService, organized by category and version range. This is the unified superset -- the single type that represents every version of the compose specification.

Diagram
The full unified ComposeService surface — 67 properties grouped by concern and annotated with the version bounds the merger inferred, from always-present fields like image down to the single [UntilVersion] entry for the deprecated Gpus property.

Complete Property Table

# Property C# Type Since Until
1 image string? -- --
2 build ComposeServiceBuildConfig? -- --
3 command ComposeStringOrList? -- --
4 entrypoint ComposeStringOrList? -- --
5 container_name string? -- --
6 hostname string? -- --
7 domainname string? -- --
8 user string? -- --
9 working_dir string? -- --
10 restart string? -- --
11 stop_signal string? -- --
12 stop_grace_period string? -- --
13 stdin_open bool? -- --
14 tty bool? -- --
15 privileged bool? -- --
16 read_only bool? -- --
17 init bool? -- --
18 scale int? -- --
19 environment ComposeServiceEnvironment? -- --
20 env_file ComposeStringOrList? -- --
21 configs List<ComposeServiceConfigOrSecret>? -- --
22 secrets List<ComposeServiceConfigOrSecret>? -- --
23 labels ComposeListOrDict? -- --
24 expose List<string>? -- --
25 ports List<ComposeServicePortsConfig>? -- --
26 networks ComposeServiceNetworks? -- --
27 dns ComposeStringOrList? -- --
28 dns_search ComposeStringOrList? -- --
29 dns_opt List<string>? -- --
30 extra_hosts ComposeListOrDict? -- --
31 links List<string>? -- --
32 external_links List<string>? -- --
33 network_mode string? -- --
34 mac_address string? -- --
35 volumes List<ComposeServiceVolumeConfig>? -- --
36 tmpfs ComposeStringOrList? -- --
37 shm_size StringOrInt? -- --
38 depends_on ComposeServiceDependsOn? -- --
39 healthcheck ComposeServiceHealthcheck? -- --
40 deploy ComposeServiceDeployConfig? -- --
41 logging ComposeServiceLogging? -- --
42 cap_add List<string>? -- --
43 cap_drop List<string>? -- --
44 cgroup_parent string? -- --
45 devices List<string>? -- --
46 security_opt List<string>? -- --
47 sysctls ComposeListOrDict? -- --
48 userns_mode string? -- --
49 pid string? -- --
50 ipc string? -- --
51 isolation string? -- --
52 ulimits ComposeUlimits? -- --
53 oom_score_adj int? -- --
54 oom_kill_disable bool? -- --
55 platform string? -- --
56 profiles List<string>? 1.6.0 --
57 pull_policy string? 1.7.0 --
58 annotations ComposeListOrDict? 1.12.0 --
59 develop ComposeDevelopment? 1.19.0 --
60 post_start List<ComposeServiceHook>? 2.3.0 --
61 pre_stop List<ComposeServiceHook>? 2.3.0 --
62 provider ComposeServiceProvider? 2.5.0 --
63 models object? 2.7.1 --
64 gpgpu List<ComposeGpuConfig>? 2.8.0 --
65 storage_opt ComposeListOrDict? 2.8.0 --
66 watch List<ComposeWatchConfig>? 2.9.0 --
67 cgroup string? 2.10.0 --
-- gpus (deprecated) ComposeServiceGpuConfig? -- 1.8.0

Fifty-five properties have been there since 1.0.9. Twelve were added across later versions. One was deprecated. That is the headline stat: 67 properties tracked, 12 additions, 1 deprecation, all in one unified type.


Generated Output Stats

Category Count
Model classes 40
Builder classes 40
Version metadata class 1
Attribute classes 2
Total generated files ~83
Total generated lines ~18,000
Properties with [SinceVersion] 12
Properties with [UntilVersion] 1
Schema versions processed 32
Definitions merged 40
Total properties across all definitions 310+
Merge time (cold) < 400ms
Merge time (incremental) < 50ms

The incremental time matters because the Roslyn source generator caches aggressively. If the input schemas have not changed (which they have not -- they are embedded resources), the generator returns the cached UnifiedSchema and skips the merge entirely. The 50ms figure is for the equality check only.


The Supporting Types

The merger produces a UnifiedSchema containing UnifiedDefinition objects, each with a list of UnifiedProperty objects. Here are those types:

public sealed class UnifiedSchema
{
    public Dictionary<string, UnifiedDefinition> Definitions { get; }
        = new();

    public ComposeSchemaVersion OldestVersion { get; init; }
    public ComposeSchemaVersion NewestVersion { get; init; }
    public int TotalVersions { get; init; }
}

public sealed class UnifiedDefinition
{
    public required string Name { get; init; }
    public required SchemaDefinition Schema { get; init; }
    public ComposeSchemaVersion? SinceVersion { get; init; }
    public ComposeSchemaVersion? UntilVersion { get; init; }
    public required List<UnifiedProperty> Properties { get; init; }

    public bool IsAlwaysPresent
        => SinceVersion is null && UntilVersion is null;

    public bool IsDeprecated
        => UntilVersion is not null;
}

public sealed class UnifiedProperty
{
    public required string Name { get; init; }
    public required SchemaPropertyType Schema { get; init; }
    public string? Description { get; init; }
    public ComposeSchemaVersion? SinceVersion { get; init; }
    public ComposeSchemaVersion? UntilVersion { get; init; }

    public string PascalName => Name.ToPascalCase();
    public string ClrType => Schema.ToClrType();

    public bool IsAlwaysPresent
        => SinceVersion is null && UntilVersion is null;

    public bool IsDeprecated
        => UntilVersion is not null;
}

The IsAlwaysPresent and IsDeprecated convenience properties drive downstream decisions. The code generator uses IsDeprecated to emit [Obsolete] alongside [UntilVersion]:

// Generated output for a deprecated property
[UntilVersion("1.8.0")]
[Obsolete("Removed after compose-spec 1.8.0. " +
    "Use deploy.resources.reservations.devices instead.")]
public ComposeServiceGpuConfig? Gpus { get; set; }

Two attributes, one purpose: the IDE shows the deprecation warning, and the version metadata is available at runtime via reflection for any consumer that needs it.


Why "Latest Wins"

Step 5 of the merge algorithm says "take the latest type shape." This is a deliberate design choice, not a shortcut.

The compose specification is additive. The maintainers of compose-spec/compose-go follow a strict policy: newer schemas are backward-compatible supersets of older schemas. A property's type does not change -- it can only grow. The build property did not change from string to object; the object form was added alongside the string form. The ports property did not replace strings with objects; objects were added as an alternative.

This means the latest version's type shape is guaranteed to be a superset of every earlier version's shape. Taking it is always safe. If I took the earliest version's shape instead, I would miss properties. If I tried to merge shapes (union of all fields from all versions), I would get the same result as taking the latest, because the latest already contains everything.

There is one theoretical exception: a true type change, where a property that was a string in version N becomes an integer in version N+1. This has never happened in the compose specification. If it did, the merger would detect the inconsistency:

// Inside MergeProperties, after finding firstSeen and lastSeen
var typeAtFirst = sorted
    .First(v => HasProperty(v, defName, propName))
    .Schema.Definitions[defName].Properties
    .First(p => p.Name == propName).Schema;

var typeAtLast = sorted
    .Last(v => HasProperty(v, defName, propName))
    .Schema.Definitions[defName].Properties
    .First(p => p.Name == propName).Schema;

if (!typeAtFirst.IsAssignableFrom(typeAtLast))
{
    diagnostics.Report(Diagnostic.Create(
        DiagnosticDescriptors.IncompatibleTypeChange,
        Location.None,
        defName, propName,
        typeAtFirst.ToClrType(),
        typeAtLast.ToClrType()));
}

A compile-time diagnostic, reported through Roslyn's diagnostic infrastructure, that would surface as a warning in the IDE and CI. It has never fired. I keep it as a safety net because "has never happened" is not the same as "cannot happen."

What About Enum Refinements?

Some properties have enum constraints in the JSON Schema -- allowed values. For example, restart allows "no", "always", "on-failure", "unless-stopped". If a newer version adds "on-abnormal" (hypothetical), the latest schema's enum is the superset of all earlier enums. Taking the latest is correct.

If a newer version removed an enum value (which would be a breaking change to the specification), the merger would not detect it automatically because it only checks type shape, not enum membership. This is a known limitation that I have accepted because:

  1. The compose specification has never removed an enum value.
  2. Checking enum membership would require deep schema comparison, significantly complicating the merger.
  3. The practical risk is negligible -- the compose specification maintainers are extremely careful about backward compatibility.

Definitions That Appear and Disappear

A definition (not just a property) can appear in one version and disappear in a later version. This happens when the specification refactors its internal structure -- renaming a $ref target, for example. The merger handles this the same way as properties: SinceVersion and UntilVersion on the definition itself.

In practice, no compose-spec definition has been removed. They have been added (as the specification grew) but never removed. The UntilVersion on UnifiedDefinition has been null for every definition in every merge I have run.

Empty Definitions

Some schema versions define a type with zero properties -- an empty object. This is valid JSON Schema and it means "any object with any properties." The merger handles it by producing a UnifiedDefinition with an empty property list. The code generator emits an empty class, which is a valid C# type that acts as a marker.

Duplicate Property Names Across Definitions

Different definitions can have properties with the same name but different types. build.context is a string. logging.options is a map<string, string>. The merger does not confuse them because properties are scoped to their parent definition. The merge loop is foreach definition -> foreach property, never a global property list.

The Sort Order Contract

The algorithm depends on a stable sort by ComposeSchemaVersion. The ComposeSchemaVersion type implements IComparable<ComposeSchemaVersion> with major-minor-patch ordering:

public readonly record struct ComposeSchemaVersion(
    int Major, int Minor, int Patch)
    : IComparable<ComposeSchemaVersion>
{
    public int CompareTo(ComposeSchemaVersion other)
    {
        var major = Major.CompareTo(other.Major);
        if (major != 0) return major;

        var minor = Minor.CompareTo(other.Minor);
        if (minor != 0) return minor;

        return Patch.CompareTo(other.Patch);
    }

    public static ComposeSchemaVersion Parse(string version)
    {
        var parts = version.Split('.');
        return new(
            int.Parse(parts[0]),
            int.Parse(parts[1]),
            parts.Length > 2 ? int.Parse(parts[2]) : 0);
    }

    public override string ToString()
        => $"{Major}.{Minor}.{Patch}";

    public static bool operator >(ComposeSchemaVersion left,
        ComposeSchemaVersion right) => left.CompareTo(right) > 0;
    public static bool operator <(ComposeSchemaVersion left,
        ComposeSchemaVersion right) => left.CompareTo(right) < 0;
    public static bool operator >=(ComposeSchemaVersion left,
        ComposeSchemaVersion right) => left.CompareTo(right) >= 0;
    public static bool operator <=(ComposeSchemaVersion left,
        ComposeSchemaVersion right) => left.CompareTo(right) <= 0;
}

A readonly record struct for zero-allocation comparisons in the sort. The OrderBy(v => v.Version) call in the merge algorithm relies on this ordering being correct. If 2.10.0 sorted before 2.9.0 (lexicographic string sorting), the merge would produce wrong version bounds. Semantic version comparison prevents that.


Integration with the Generator Pipeline

The merger sits between the SchemaReader (which produces per-version SchemaModel objects) and the CodeEmitter (which produces .g.cs files). The pipeline is:

SchemaDownloader  →  SchemaReader  →  SchemaVersionMerger  →  CodeEmitter
(32 JSON files)     (32 SchemaModels)  (1 UnifiedSchema)     (~83 .g.cs files)

All four stages run inside the Roslyn incremental source generator. The generator's Initialize method registers an IncrementalValuesProvider that reads embedded JSON resources, pipes them through the reader and merger, and feeds the unified schema to the emitter:

public void Initialize(IncrementalGeneratorInitializationContext context)
{
    var schemas = context.AdditionalTextsProvider
        .Where(t => t.Path.EndsWith(".schema.json"))
        .Select((text, ct) =>
        {
            var version = ExtractVersion(text.Path);
            var content = text.GetText(ct)!.ToString();
            var schema = SchemaReader.Read(content);
            return (Version: version, Schema: schema);
        })
        .Collect();

    context.RegisterSourceOutput(schemas, (ctx, versions) =>
    {
        var unified = SchemaVersionMerger.Merge(versions);
        CodeEmitter.Emit(ctx, unified);
    });
}

The .Collect() call is critical. It gathers all 32 (Version, Schema) pairs into a single ImmutableArray before passing them to the merger. Without .Collect(), the generator would try to merge one schema at a time, which is meaningless.

The Roslyn incremental generator infrastructure caches each stage. If the .schema.json files have not changed, the SchemaReader.Read calls return cached results, the .Collect() returns the same array, and the merger's input is identical -- so the generator skips the merge and emission entirely. This is why incremental builds are < 50ms: nothing actually runs.


Testing the Merger

The merger has 47 tests, covering:

Property presence tests -- verify that every property in the property table above appears in the merged output with the correct version bounds. These are data-driven tests using [Theory] and [InlineData]:

[Theory]
[InlineData("image", null, null)]
[InlineData("develop", "1.19.0", null)]
[InlineData("provider", "2.5.0", null)]
[InlineData("gpus", null, "1.8.0")]
[InlineData("post_start", "2.3.0", null)]
[InlineData("models", "2.7.1", null)]
public void ComposeService_PropertyVersionBounds(
    string propertyName,
    string? expectedSince,
    string? expectedUntil)
{
    var unified = SchemaVersionMerger.Merge(_allVersions);
    var service = unified.Definitions["service"];
    var prop = service.Properties
        .Single(p => p.Name == propertyName);

    Assert.Equal(
        expectedSince is null ? null
            : ComposeSchemaVersion.Parse(expectedSince),
        prop.SinceVersion);
    Assert.Equal(
        expectedUntil is null ? null
            : ComposeSchemaVersion.Parse(expectedUntil),
        prop.UntilVersion);
}

Monotonicity tests -- verify that the set of properties only grows or stays the same as versions increase. A property present in version N must be present in all versions from N to its UntilVersion (or the latest, if UntilVersion is null):

[Fact]
public void Properties_AreMonotonicallyPresent()
{
    var sorted = _allVersions.OrderBy(v => v.Version).ToList();

    foreach (var defName in _unified.Definitions.Keys)
    {
        foreach (var prop in _unified.Definitions[defName].Properties)
        {
            bool appeared = false;
            bool disappeared = false;

            foreach (var (version, schema) in sorted)
            {
                var hasProp = schema.Definitions
                    .TryGetValue(defName, out var def)
                    && def.Properties.Any(p => p.Name == prop.Name);

                if (hasProp && !appeared) appeared = true;
                if (!hasProp && appeared && !disappeared)
                    disappeared = true;
                if (hasProp && disappeared)
                    Assert.Fail(
                        $"{defName}.{prop.Name} reappeared " +
                        $"in {version} after disappearing");
            }
        }
    }
}

This test catches a class of schema error where a property is removed in one version and re-added in a later version. If that happened, the merger's "first seen / last seen" logic would produce incorrect bounds (it would report the property as present across the gap). The monotonicity test ensures the input schemas do not have this pattern. They do not. But the test exists because "they do not" is an observation, not a guarantee.

Round-trip tests -- verify that the merged schema, when used to generate code and then used to deserialize YAML, produces the same ComposeFile objects as the per-version schemas. This is the ultimate correctness test: does the unified type system actually work as a superset?

[Theory]
[MemberData(nameof(AllVersions))]
public void RoundTrip_PerVersion(ComposeSchemaVersion version)
{
    var yaml = LoadTestYaml(version);

    // Deserialize with per-version types (ground truth)
    var perVersion = PerVersionDeserializer.Deserialize(
        yaml, version);

    // Deserialize with unified types
    var unified = UnifiedDeserializer.Deserialize(yaml);

    // Compare all properties that exist in this version
    AssertEquivalent(perVersion, unified, version);
}

Forty-seven tests. Zero failures. The merger works.


What the Developer Sees

After all of this -- 32 schemas downloaded, parsed, merged, and code-generated -- the developer sees one set of types. They never interact with SchemaVersionMerger directly. They never see UnifiedSchema or UnifiedProperty. They see ComposeFile, ComposeService, ComposeServiceBuildConfig, and the other 37 model classes. They see [SinceVersion] in their IDE tooltips. They see [Obsolete] on the one deprecated property.

The merge algorithm is invisible infrastructure. It runs once during source generation and produces types that feel hand-written. That is the goal: complexity at build time, simplicity at use time.

// What the developer writes -- no knowledge of 32 schemas needed
var compose = new ComposeFileBuilder()
    .WithService("api", s => s
        .WithImage("myapp:latest")
        .WithBuild(b => b
            .WithContext(".")
            .WithDockerfile("Dockerfile")
            .WithTarget("runtime"))
        .WithPorts(p => p.Add("8080:80"))
        .WithDevelop(d => d          // IDE: [SinceVersion("1.19.0")]
            .WithWatch(w => w
                .WithPath("./src")
                .WithAction("rebuild")))
        .WithDependsOn("db", dep => dep
            .WithCondition("service_healthy")
            .WithRestart(true)))       // IDE: [SinceVersion("2.3.0")]
    .Build();

One ComposeFile. One API. Sixty-seven properties. Thirty-two versions collapsed into precise version annotations that the IDE shows and the compiler enforces.


Closing

32 schemas, 1 unified type system. The merger produces the superset of all versions with precise annotations on when each property appeared and disappeared. The developer gets one set of types, the IDE shows version metadata, and the compiler catches version-incompatible property usage.

The algorithm itself is not clever -- sort, union, find bounds, take latest. The cleverness, if there is any, is in the decision to merge at all instead of generating per-version type systems. That decision eliminates 1,240 redundant classes, gives consumers a single API surface to learn, and reduces the generated output from what would have been ~200,000 lines to ~18,000.

Now that we have typed models, Part XII shows how to turn them back into YAML -- the serialization pipeline that produces docker-compose.yml from C# objects.


Previous: Part X: The Compose Bundle -- Downloading and Reading 32 Schemas | Next: Part XII: From ComposeFile to docker-compose.yml

⬇ Download