Part 32: The Compose Contributors Pattern
"The gap in DockerCompose.Bundle was: how do you compose contributions without conflicts? Answer: idempotent merges, ordering by Order, and a test that fails when two contributors fight."
Why
Part 31 used IComposeFileContributor like it had always existed. It hadn't. DockerCompose.Bundle (the existing FrenchExDev library) had typed ComposeFile, typed ComposeService, typed builders, and a serializer — but it did not have the contributor concept. Adding it is one of the explicit gaps in the HomeLab spec, and this part is the deep dive on how it works.
The thesis of this part is: IComposeFileContributor is a one-method interface (ISP). Multiple contributors compose by mutating a shared ComposeFile. Shared infrastructure — networks, volumes, secrets — is added idempotently with ??=. Conflicts (two contributors trying to define the same service) are caught by an architecture test, not by hoping nobody collides.
The shape
public interface IComposeFileContributor
{
string TargetVm { get; } // for per-VM partitioning
void Contribute(ComposeFile compose);
}public interface IComposeFileContributor
{
string TargetVm { get; } // for per-VM partitioning
void Contribute(ComposeFile compose);
}One method, one property. The property is for the topology-aware partitioning we saw in Part 31. The method takes a shared ComposeFile and mutates it.
The default implementation pattern is:
public void Contribute(ComposeFile compose)
{
// 1. Add my service
compose.Services["my-service"] = new ComposeService { /* ... */ };
// 2. Idempotently add shared infrastructure
compose.Networks["shared-net"] ??= new ComposeNetwork { Driver = "bridge" };
compose.Volumes["shared-data"] ??= new ComposeVolume { Driver = "local" };
compose.Secrets["shared-token"] ??= new ComposeSecret { File = "./secrets/token" };
}public void Contribute(ComposeFile compose)
{
// 1. Add my service
compose.Services["my-service"] = new ComposeService { /* ... */ };
// 2. Idempotently add shared infrastructure
compose.Networks["shared-net"] ??= new ComposeNetwork { Driver = "bridge" };
compose.Volumes["shared-data"] ??= new ComposeVolume { Driver = "local" };
compose.Secrets["shared-token"] ??= new ComposeSecret { File = "./secrets/token" };
}The ??= pattern is the entire trick. The first contributor that mentions shared-net creates it; subsequent contributors that need it do nothing because it already exists. The order of contribution does not matter for shared resources. A contributor cannot accidentally override another contributor's network definition because the operator does nothing on a non-null value.
Ordering
For Services, the order matters in one case: which contributor wins if two contributors try to define the same service. The answer is: neither. The architecture test fails the build:
[Fact]
public void no_two_contributors_define_the_same_service()
{
var contributors = StandardContributors();
var counts = new Dictionary<string, int>();
foreach (var c in contributors)
{
var probe = new ComposeFile();
c.Contribute(probe);
foreach (var svc in probe.Services.Keys)
counts[svc] = counts.GetValueOrDefault(svc) + 1;
}
counts.Where(kv => kv.Value > 1).Should().BeEmpty(
"two contributors are defining the same service — only one should");
}[Fact]
public void no_two_contributors_define_the_same_service()
{
var contributors = StandardContributors();
var counts = new Dictionary<string, int>();
foreach (var c in contributors)
{
var probe = new ComposeFile();
c.Contribute(probe);
foreach (var svc in probe.Services.Keys)
counts[svc] = counts.GetValueOrDefault(svc) + 1;
}
counts.Where(kv => kv.Value > 1).Should().BeEmpty(
"two contributors are defining the same service — only one should");
}Each contributor is run in isolation, the services it defines are tallied, and the test fails if any service is contributed twice. No silent overwrite. No "last one wins". A conflict is a structural bug, caught at unit-test speed.
For cross-cutting concerns like middlewares, the convention is the opposite: shared middlewares are intentionally added by multiple contributors via ??=, and the test allows it. The distinction is: services are owned by exactly one contributor; networks/volumes/secrets/middlewares are shared.
Conditional contribution
A contributor that only applies under certain conditions (e.g. "only contribute if topology = ha") implements a ShouldContribute() method that the generator checks before calling Contribute:
public interface IComposeFileContributor
{
string TargetVm { get; }
bool ShouldContribute() => true; // default
void Contribute(ComposeFile compose);
}
[Injectable(ServiceLifetime.Singleton)]
public sealed class HaProxyComposeContributor : IComposeFileContributor
{
private readonly HomeLabConfig _config;
public string TargetVm => "lb";
public bool ShouldContribute() => _config.Topology == "ha";
public void Contribute(ComposeFile compose)
{
compose.Services["haproxy"] = new ComposeService { /* ... */ };
}
}public interface IComposeFileContributor
{
string TargetVm { get; }
bool ShouldContribute() => true; // default
void Contribute(ComposeFile compose);
}
[Injectable(ServiceLifetime.Singleton)]
public sealed class HaProxyComposeContributor : IComposeFileContributor
{
private readonly HomeLabConfig _config;
public string TargetVm => "lb";
public bool ShouldContribute() => _config.Topology == "ha";
public void Contribute(ComposeFile compose)
{
compose.Services["haproxy"] = new ComposeService { /* ... */ };
}
}HaProxyComposeContributor only runs in HA topology. The generator checks ShouldContribute() before invoking Contribute(), and the architecture test treats ShouldContribute() == false as "not contributing this run" — so the no-duplicate-services rule is not violated.
The merge architecture in detail
When the per-VM generator runs, it does:
foreach (var vm in plan.Machines)
{
var compose = new ComposeFile();
var contributorsForVm = _contributors
.Where(c => c.ShouldContribute())
.Where(c => c.TargetVm == vm.Role)
.OrderBy(c => GetOrder(c));
foreach (var c in contributorsForVm)
{
var snapshot = SnapshotServiceKeys(compose);
c.Contribute(compose);
var added = compose.Services.Keys.Except(snapshot).ToList();
DetectConflicts(c, added, compose);
}
await _writer.WriteComposeAsync(compose, outputDir, $"compose.{vm.Role}.yaml", ct);
}foreach (var vm in plan.Machines)
{
var compose = new ComposeFile();
var contributorsForVm = _contributors
.Where(c => c.ShouldContribute())
.Where(c => c.TargetVm == vm.Role)
.OrderBy(c => GetOrder(c));
foreach (var c in contributorsForVm)
{
var snapshot = SnapshotServiceKeys(compose);
c.Contribute(compose);
var added = compose.Services.Keys.Except(snapshot).ToList();
DetectConflicts(c, added, compose);
}
await _writer.WriteComposeAsync(compose, outputDir, $"compose.{vm.Role}.yaml", ct);
}The snapshot-and-diff captures which keys this contributor added. If it added zero keys (and it is not declared as a "shared infrastructure only" contributor), that is suspicious — log a warning. If it tried to overwrite an existing key, raise a clear error with the name of the offending contributor and the conflicting service.
private static void DetectConflicts(IComposeFileContributor contributor, IReadOnlyList<string> added, ComposeFile compose)
{
foreach (var key in added)
{
// (already added by snapshot diff; this is the success path)
}
// No-op if no conflicts; the test catches them at design time anyway
}private static void DetectConflicts(IComposeFileContributor contributor, IReadOnlyList<string> added, ComposeFile compose)
{
foreach (var key in added)
{
// (already added by snapshot diff; this is the success path)
}
// No-op if no conflicts; the test catches them at design time anyway
}The runtime check is belt-and-braces. The architecture test is the primary defence.
A worked example: shared networks
TraefikComposeContributor and GitLabComposeContributor both want a platform network. Neither one should "own" it — it is shared infrastructure. Both use ??=:
public sealed class TraefikComposeContributor : IComposeFileContributor
{
public void Contribute(ComposeFile compose)
{
compose.Services["traefik"] = new ComposeService
{
Image = "traefik:v3.1",
// ...
Networks = new() { "platform" }
};
compose.Networks["platform"] ??= new ComposeNetwork { Driver = "bridge" };
}
}
public sealed class GitLabComposeContributor : IComposeFileContributor
{
public void Contribute(ComposeFile compose)
{
compose.Services["gitlab"] = new ComposeService
{
// ...
Networks = new() { "platform" }
};
compose.Networks["platform"] ??= new ComposeNetwork { Driver = "bridge" };
}
}public sealed class TraefikComposeContributor : IComposeFileContributor
{
public void Contribute(ComposeFile compose)
{
compose.Services["traefik"] = new ComposeService
{
Image = "traefik:v3.1",
// ...
Networks = new() { "platform" }
};
compose.Networks["platform"] ??= new ComposeNetwork { Driver = "bridge" };
}
}
public sealed class GitLabComposeContributor : IComposeFileContributor
{
public void Contribute(ComposeFile compose)
{
compose.Services["gitlab"] = new ComposeService
{
// ...
Networks = new() { "platform" }
};
compose.Networks["platform"] ??= new ComposeNetwork { Driver = "bridge" };
}
}Whichever runs first creates the network; the other one does nothing. The compose file ends up with a single platform network and both services attached. Order does not matter.
The wiring
IComposeFileContributor lives in DockerCompose.Bundle. The implementations live in their respective service projects (Traefik.DockerCompose, GitLab.DockerCompose, Baget.DockerCompose, etc.). The DI container picks them all up via [Injectable]. The per-VM generator from Part 31 consumes them.
The test
public sealed class ComposeContributorPatternTests
{
[Fact]
public void contributors_share_networks_idempotently()
{
var compose = new ComposeFile();
new TraefikComposeContributor(/* ... */).Contribute(compose);
new GitLabComposeContributor(/* ... */).Contribute(compose);
new BagetComposeContributor(/* ... */).Contribute(compose);
compose.Networks.Keys.Should().Contain("platform");
compose.Networks["platform"].Driver.Should().Be("bridge");
compose.Networks.Where(n => n.Key == "platform").Should().ContainSingle();
}
[Fact]
public void no_two_contributors_define_the_same_service()
{
var contributors = StandardContributors();
var counts = new Dictionary<string, int>();
foreach (var c in contributors)
{
var probe = new ComposeFile();
c.Contribute(probe);
foreach (var s in probe.Services.Keys)
counts[s] = counts.GetValueOrDefault(s) + 1;
}
counts.Where(kv => kv.Value > 1).Should().BeEmpty();
}
[Fact]
public void conditional_contributor_skips_when_should_not_contribute()
{
var ha = new HaProxyComposeContributor(Options.Create(new HomeLabConfig { Topology = "single" }));
ha.ShouldContribute().Should().BeFalse();
var compose = new ComposeFile();
if (ha.ShouldContribute()) ha.Contribute(compose);
compose.Services.Should().NotContain(s => s.Key == "haproxy");
}
}public sealed class ComposeContributorPatternTests
{
[Fact]
public void contributors_share_networks_idempotently()
{
var compose = new ComposeFile();
new TraefikComposeContributor(/* ... */).Contribute(compose);
new GitLabComposeContributor(/* ... */).Contribute(compose);
new BagetComposeContributor(/* ... */).Contribute(compose);
compose.Networks.Keys.Should().Contain("platform");
compose.Networks["platform"].Driver.Should().Be("bridge");
compose.Networks.Where(n => n.Key == "platform").Should().ContainSingle();
}
[Fact]
public void no_two_contributors_define_the_same_service()
{
var contributors = StandardContributors();
var counts = new Dictionary<string, int>();
foreach (var c in contributors)
{
var probe = new ComposeFile();
c.Contribute(probe);
foreach (var s in probe.Services.Keys)
counts[s] = counts.GetValueOrDefault(s) + 1;
}
counts.Where(kv => kv.Value > 1).Should().BeEmpty();
}
[Fact]
public void conditional_contributor_skips_when_should_not_contribute()
{
var ha = new HaProxyComposeContributor(Options.Create(new HomeLabConfig { Topology = "single" }));
ha.ShouldContribute().Should().BeFalse();
var compose = new ComposeFile();
if (ha.ShouldContribute()) ha.Contribute(compose);
compose.Services.Should().NotContain(s => s.Key == "haproxy");
}
}What this gives you that bash doesn't
A hand-written compose file is one big YAML where every service is intermingled with every other service, every network is declared once at the bottom, and every change risks breaking an unrelated section. The "contributor" concept does not exist in YAML.
A typed IComposeFileContributor pattern with idempotent shared infrastructure gives you, for the same surface area:
- One contributor per service with full isolation
- Idempotent shared resources via
??=so order does not matter - Conflict detection at unit-test speed via the architecture test
- Conditional contribution via
ShouldContribute()for topology-specific services - Per-VM partitioning via
TargetVmfor multi-VM and HA topologies
The bargain pays back the first time you add a new service and the test suite proves that no other contributor was already using its name.