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:
versionis marked obsolete - Union types proliferate:
buildaccepts either a string or an object with 25 properties;portsaccepts either short strings or structured objects;depends_onaccepts 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.
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(); }
}));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;[ComposeBundle]
public partial class ComposeBundleDescriptor;Plus the project file wiring:
<AdditionalFiles Include="schemas\compose-spec-*.json" /><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();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:
- Sort all 32 schemas by semantic version
- Union all definition names across every schema
- For each definition and each property: find the first version where it appeared and the last version where it's still present
- Take the latest definition (newest type shape, newest description)
- Annotate with
[SinceVersion]if it didn't exist in the oldest schema,[UntilVersion]if it disappeared before the newest
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; }
}// <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"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; }
}// <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]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)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
AdditionalFileschange. 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?>? Extensionsso thatx-*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.