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 11: The FrenchExDev Toolbelt — 19 Libraries HomeLab Stands On

"The best code is the code you didn't write because somebody on your team already wrote it last year."


Why

HomeLab is a meta-orchestrator. By definition, it spends most of its time coordinating other things: binaries, files, configs, plugins, events. The temptation, when building a meta-orchestrator, is to write a small framework for every cross-cutting concern: a logging utility, an option-binding utility, a state-machine helper, a config merger, a retry loop, a test fixture base. Every meta-orchestrator I have ever started building reinvented half of System.IO by month two.

The discipline that prevents this is not writing what already exists. The FrenchExDev monorepo has accumulated, over years, a set of small, well-tested, single-purpose libraries that solve every cross-cutting concern HomeLab needs. None of them are larger than they need to be. None of them have transitive dependencies you don't already have. All of them are [Injectable]-friendly. All of them return Result<T>. All of them are tested at the unit level by their own repos.

This part is the tour. Nineteen libraries. One code block per library showing exactly how HomeLab uses it. By the end, you should be able to point at any concern in the HomeLab codebase and say "that's the X library". If you cannot, the architecture has failed and we have a new library to extract.


1. FrenchExDev.Net.Injectable — generated DI

[Injectable(ServiceLifetime.Singleton)]
public sealed class HomeLabPipeline : IHomeLabPipeline { /* ... */ }

[InjectableDecorator(typeof(IHomeLabPipeline))]
public sealed class LoggingPipelineDecorator : IHomeLabPipeline
{
    private readonly IHomeLabPipeline _inner;
    private readonly ILogger<HomeLabPipeline> _log;
    public LoggingPipelineDecorator(IHomeLabPipeline inner, ILogger<HomeLabPipeline> log) { _inner = inner; _log = log; }
    public async Task<Result<HomeLabContext>> RunAsync(HomeLabRequest req, CancellationToken ct)
    {
        _log.LogInformation("Pipeline starting for {Verb}", req.GetType().Name);
        var sw = Stopwatch.StartNew();
        var result = await _inner.RunAsync(req, ct);
        _log.LogInformation("Pipeline {Status} in {Ms}ms", result.IsSuccess ? "ok" : "FAIL", sw.ElapsedMilliseconds);
        return result;
    }
}

The [Injectable] source generator scans the assembly, emits AddHomeLab(IServiceCollection), and applies the decorator chain automatically. Adding a new decorator is one new class with [InjectableDecorator(typeof(SomeInterface))]. The generator stitches them in the right order. We covered this in Part 02 and Part 08.

Why HomeLab uses it: Eliminates the entire class of "I forgot to register X" runtime errors. The composition root is generated; you cannot ship without it.


2. FrenchExDev.Net.Result — explicit failure

public async Task<Result<TlsCertificateBundle>> GenerateCaAsync(string caName, CancellationToken ct)
{
    if (string.IsNullOrWhiteSpace(caName))
        return Result.Failure<TlsCertificateBundle>("ca name must not be empty");
    if (caName.Length > 64)
        return Result.Failure<TlsCertificateBundle>("ca name must be ≤ 64 chars");

    using var rsa = RSA.Create(2048);
    var req = new CertificateRequest($"CN={caName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
    // ...
    return Result.Success(new TlsCertificateBundle(certBytes, keyBytes, certPath, keyPath));
}

Why HomeLab uses it: No exceptions for control flow. Every stage, every contributor, every plugin, every binary wrapper returns Result<T> or Result. The pipeline short-circuits cleanly. Tests assert on result.IsSuccess and result.Errors. We covered this in Part 07.


3. FrenchExDev.Net.Builder — async, validated, cycle-safe builders

[Builder]
public sealed record HomeLabConfig
{
    public required string Name { get; init; }
    public required string Topology { get; init; }
    public VosConfig Vos { get; init; } = new();
    // ...
}

// Generated:
//   HomeLabConfigBuilder { With*(...), BuildAsync() : Result<Reference<HomeLabConfig>> }

var config = await new HomeLabConfigBuilder()
    .WithName("devlab")
    .WithTopology("multi")
    .WithVos(new VosConfigBuilder().WithCpus(8).WithMemory(8192).BuildAsync())
    .BuildAsync();

Why HomeLab uses it: Async config construction (the Vos field is itself a builder; BuildAsync lets nested builders await network or file operations). Validated (the builder runs [ValidationMethod] checks before returning success). Cycle-safe (HomeLab's plugin graph can have cycles; the builder breaks them with reference identity).


4. FrenchExDev.Net.Guard — boundary checks

public async Task<Result<WrittenFile>> WriteComposeAsync(ComposeFile compose, DirectoryInfo outputDir, CancellationToken ct)
{
    Guard.Against.Null(compose);
    Guard.Against.Null(outputDir);
    Guard.Against.False(outputDir.Exists, $"output directory does not exist: {outputDir.FullName}");
    // ...
}

Why HomeLab uses it: Cheap, declarative argument validation at the boundary of the lib. Internal calls between stages do not need guards (the types already prove correctness). Public methods that accept arguments from the CLI or from plugins do.


5. FrenchExDev.Net.Clock — testable time

[Injectable(ServiceLifetime.Singleton)]
public sealed class CertExpiryWatcher : IHomeLabEventSubscriber
{
    private readonly IClock _clock;
    private readonly IHomeLabConsole _console;

    public CertExpiryWatcher(IClock clock, IHomeLabConsole console) { _clock = clock; _console = console; }

    public void Subscribe(IHomeLabEventBus bus)
    {
        bus.Subscribe<TlsCertIssued>((e, ct) =>
        {
            var daysLeft = (int)(e.Expiry - _clock.UtcNow).TotalDays;
            if (daysLeft < 30)
                _console.WriteLine($"⚠ cert {e.Domain} expires in {daysLeft} days");
            return Task.CompletedTask;
        });
    }
}

// In test:
var clock = new FakeClock(DateTimeOffset.Parse("2026-04-08T12:00:00Z"));
clock.AdvanceBy(TimeSpan.FromDays(335));   // jump to 30 days before expiry
// ...assertions on warning behaviour

Why HomeLab uses it: Cert expiry, backup schedules, cost wall-clock, event timestamps — all testable in milliseconds via FakeClock. We saw IClock in Part 09 and we'll see it again in Part 47.


6. FrenchExDev.Net.FiniteStateMachine — VM lifecycle

public enum VmState { Off, Building, Up, Halted, Destroyed }
public enum VmEvent { Build, Boot, Halt, Destroy, Crashed }

[StateMachine(typeof(VmState), typeof(VmEvent))]
public partial class VmLifecycleMachine
{
    [Transition(From = VmState.Off,      On = VmEvent.Build,    To = VmState.Building)]
    [Transition(From = VmState.Building, On = VmEvent.Boot,     To = VmState.Up)]
    [Transition(From = VmState.Up,       On = VmEvent.Halt,     To = VmState.Halted)]
    [Transition(From = VmState.Halted,   On = VmEvent.Boot,     To = VmState.Up)]
    [Transition(From = VmState.Up,       On = VmEvent.Destroy,  To = VmState.Destroyed)]
    [Transition(From = VmState.Halted,   On = VmEvent.Destroy,  To = VmState.Destroyed)]
    [Transition(From = VmState.Up,       On = VmEvent.Crashed,  To = VmState.Off)]
    public partial void Configure();
}

// Use:
var vm = new VmLifecycleMachine();
vm.Configure();
vm.Fire(VmEvent.Build);    // Off → Building
vm.Fire(VmEvent.Boot);     // Building → Up
vm.Fire(VmEvent.Halt);     // Up → Halted
// vm.Fire(VmEvent.Destroy) — valid
// vm.Fire(VmEvent.Build)   — throws InvalidTransition: Halted/Build undefined

Why HomeLab uses it: VM lifecycle, cert lifecycle, deployment lifecycle — every stateful entity in HomeLab is a generated state machine. Invalid transitions are compile errors when defined statically and runtime errors when the input is dynamic. The generator emits Mermaid for documentation, too.


7. FrenchExDev.Net.Saga — compensable transactions

[Saga]
public sealed class BootstrapDevLabSaga
{
    [SagaStep(Order = 1, Compensation = nameof(DeleteVm))]
    public async Task<Result> CreateVm(BootstrapContext ctx, CancellationToken ct) { /* ... */ }

    [SagaStep(Order = 2, Compensation = nameof(StopGitLab))]
    public async Task<Result> InstallGitLab(BootstrapContext ctx, CancellationToken ct) { /* ... */ }

    [SagaStep(Order = 3, Compensation = nameof(RemoveRunner))]
    public async Task<Result> RegisterRunner(BootstrapContext ctx, CancellationToken ct) { /* ... */ }

    public async Task<Result> DeleteVm(BootstrapContext ctx, CancellationToken ct) { /* ... */ }
    public async Task<Result> StopGitLab(BootstrapContext ctx, CancellationToken ct) { /* ... */ }
    public async Task<Result> RemoveRunner(BootstrapContext ctx, CancellationToken ct) { /* ... */ }
}

Why HomeLab uses it: The bootstrap and the dogfood loops are long-running multi-step transactions. If RegisterRunner fails on step 3 of 5, the saga calls StopGitLab and DeleteVm in reverse order to leave the system in a clean state. We see this in Part 36.


8. FrenchExDev.Net.Reactive — event streams

// IHomeLabEventBus is built on EventStream<T>
private readonly IEventStream<IHomeLabEvent> _stream = new EventStream<IHomeLabEvent>();

// Subscribers compose with operators
var slowStages = _stream
    .OfType<StageCompleted>()
    .Filter(e => e.Duration > TimeSpan.FromSeconds(10))
    .Throttle(TimeSpan.FromSeconds(5))
    .Subscribe(e => _console.WriteLine($"slow stage: {e.StageName} took {e.Duration}"));

Why HomeLab uses it: The event bus internals from Part 09. Also: hot-reload of config-homelab.yaml (a FileSystemWatcher publishes into a stream that the lib subscribes to).


9. FrenchExDev.Net.OptionsOption<T> instead of null

public sealed record DnsConfig
{
    public string Provider { get; init; } = "hosts-file";
    public Option<Uri> PiHoleUrl { get; init; } = Option.None<Uri>();
    public Option<string> PiHoleTokenFile { get; init; } = Option.None<string>();
}

// Use:
config.Dns.PiHoleUrl.Match(
    onSome: url => _console.WriteLine($"using PiHole at {url}"),
    onNone: () => _console.WriteLine("using local hosts file"));

Why HomeLab uses it: Optional config slots without null. The compiler refuses to let you forget the "what if it's missing" branch. No NullReferenceException from a config field that the user didn't set.


10. FrenchExDev.Net.Mapper — DTO ↔ domain mapping

[MapFrom(typeof(HomeLabConfigDto))]
public sealed class HomeLabConfig
{
    public string Name { get; init; }
    [MapProperty("topo")]                  // YAML uses "topo", domain uses "Topology"
    public string Topology { get; init; }
    [IgnoreMapping]                         // computed, not mapped
    public string DisplayLabel => $"{Name} ({Topology})";
}

// Generated:
//   IMapper<HomeLabConfigDto, HomeLabConfig>

Why HomeLab uses it: YAML → DTO → domain model, with no reflection at runtime. The mapper is source-generated. Property name mismatches, type mismatches, missing fields all become compile errors.


11. FrenchExDev.Net.Mediator — CLI verb dispatch

public sealed record VosUpRequest(string? MachineName, FileInfo Config) : IRequest<Result<VosUpResponse>>;

[Injectable(ServiceLifetime.Singleton)]
public sealed class VosUpRequestHandler : IRequestHandler<VosUpRequest, Result<VosUpResponse>>
{
    public async Task<Result<VosUpResponse>> HandleAsync(VosUpRequest req, CancellationToken ct) { /* ... */ }
}

// Verb command body becomes:
var result = await _mediator.SendAsync(new VosUpRequest(name, configFile), ct);

Why HomeLab uses it: Decouples CLI verbs from concrete handler types. The verb knows about the request and the response — nothing else. The mediator wires them together via DI. Pipeline behaviours (IPipelineBehavior<TReq, TRes>) layer cross-cutting concerns: logging, validation, retry.


12. FrenchExDev.Net.Outbox — reliable event delivery

[Injectable(ServiceLifetime.Singleton)]
public sealed class AuditOutboxSubscriber : IHomeLabEventSubscriber
{
    private readonly IOutbox _outbox;
    public AuditOutboxSubscriber(IOutbox outbox) => _outbox = outbox;

    public void Subscribe(IHomeLabEventBus bus)
    {
        bus.SubscribeAll(async (e, ct) =>
        {
            // Persist to a local SQLite outbox; a background flush ships to a central audit endpoint
            await _outbox.EnqueueAsync(new OutboxMessage(e.GetType().Name, JsonSerializer.Serialize(e)), ct);
        });
    }
}

Why HomeLab uses it: Some subscribers (audit, observability) need guaranteed delivery. The outbox writes to local storage first, then a background drainer ships to the destination, with retries. If HomeLab crashes mid-pipeline, the outbox replays on next start. Same library that the rest of the FrenchExDev DDD stack uses.


13. FrenchExDev.Net.BinaryWrapper — typed CLI wrappers

[BinaryWrapper("docker", HelpCommand = "--help")]
public partial class DockerClient
{
    [Command("ps")]
    public partial Task<Result<DockerPsOutput>> PsAsync(
        [Flag("--all", Aliases = "-a")] bool all = false,
        [Flag("--filter")] string? filter = null,
        CancellationToken ct = default);

    [Command("run")]
    public partial Task<Result<DockerRunOutput>> RunAsync(
        [PositionalArgument] string image,
        [Flag("--name")] string? name = null,
        [Flag("-d", IsBoolean = true)] bool detach = false,
        CancellationToken ct = default);
}

Why HomeLab uses it: Docker, Podman, Packer, Vagrant, Git, mkcert are all wrapped this way. The source generator scrapes --help output, generates a typed C# class, and exposes every flag as a strongly-typed method parameter. Exit codes become Result<T>. We saw this in Part 03 and we'll see it in detail in Part 15.


14. FrenchExDev.Net.GitLab.Ci.Yaml — typed .gitlab-ci.yml

var pipeline = new GitLabCiPipelineBuilder()
    .WithStages("build", "test", "publish")
    .WithJob(new JobBuilder("build-homelab")
        .WithStage("build")
        .WithImage("mcr.microsoft.com/dotnet/sdk:10.0")
        .WithScript("dotnet build src/HomeLab")
        .WithArtifact("src/HomeLab/bin/Release/net10.0/", expireIn: TimeSpan.FromDays(7)))
    .WithJob(new JobBuilder("publish-nupkg")
        .WithStage("publish")
        .WithRules(r => r.OnDefaultBranch())
        .WithScript("dotnet nuget push *.nupkg --source $BAGET_URL"))
    .Build();

await new GitLabCiYamlWriter().WriteAsync(pipeline, ".gitlab-ci.yml");

Why HomeLab uses it: DevLab's .gitlab-ci.yml is generated from the same C# code that knows about the runners, the artifacts, the NuGet feed URL, the cache key. Schema-driven (the underlying types come from the official GitLab CI JSON schema). No hand-written YAML. We see this in Part 35 and Part 42.


15. FrenchExDev.Net.Alpine.Version — image pinning

var searcher = new AlpineVersionSearcher(httpClient);
var versions = await searcher.SearchAsync(new AlpineVersionSearchingFiltersBuilder()
    .WithMajor(3)
    .WithArch(AlpineArch.x86_64)
    .WithFlavor(AlpineFlavor.Virt)
    .Build());

var latest = versions.Where(v => !v.IsReleaseCandidate).OrderByDescending(v => v).First();
// → 3.21.0

if (config.Packer.Version != latest.ToString())
    _console.WriteLine($"⚠ Alpine {latest} is available; you are pinned to {config.Packer.Version}");

Why HomeLab uses it: Packer base images are pinned to a specific Alpine version. This library queries the Alpine CDN, lists available versions, and lets HomeLab warn (or fail CI) when the pinned version drifts behind upstream. Drift detection without manual checking.


16. FrenchExDev.Net.Requirements — feature traceability

public abstract class HomeLabFeature : Feature<HomeLabEpic>
{
    // ...
}

public abstract class TopologyHaFeature : HomeLabFeature
{
    public override string Title => "HA topology with Reference Architecture on Omnibus VMs";
    public override RequirementPriority Priority => RequirementPriority.Critical;
    public abstract AcceptanceCriterionResult RailsBehindHaproxy();
    public abstract AcceptanceCriterionResult GitalyClusterWithPraefect();
    public abstract AcceptanceCriterionResult PatroniPostgres();
}

[ForRequirement(typeof(TopologyHaFeature), nameof(TopologyHaFeature.RailsBehindHaproxy))]
public Result GenerateHaproxyConfig(HomeLabContext ctx) { /* ... */ }

[Verifies(typeof(TopologyHaFeature), nameof(TopologyHaFeature.RailsBehindHaproxy))]
public void Haproxy_routes_to_two_rails_nodes() { /* test */ }

Why HomeLab uses it: Every HomeLab feature is traceable from acceptance criterion to implementation to test. The compiler proves the chain. Nothing ships without a test. Same pattern as Feature Tracking.


17. FrenchExDev.Net.QualityGate — the dev-loop bar

# .quality-gate.yml
coverage:
  threshold: 85
mutation:
  threshold: 60
complexity:
  per_method_max: 15
  per_class_max: 200
maintainability:
  index_min: 70

# Run:
dotnet quality-gate test

Why HomeLab uses it: Every PR runs dotnet quality-gate test. Coverage, mutation, complexity, maintainability are checked as a single gate. If the gate fails, the PR cannot merge. The dashboard shows the trend over time. This is the dev-loop bar HomeLab ships under, and it is enforced in the DevLab CI (the GitLab runner that builds HomeLab is configured by HomeLab to run dotnet quality-gate test on every push — yes, the dogfood loop applies to quality too).


18. FrenchExDev.Net.Ddd + FrenchExDev.Net.Entity.Dsl — domain aggregates

[AggregateRoot("Lab", BoundedContext = "HomeLab")]
public partial class Lab
{
    [EntityId] public partial LabId Id { get; }
    [Property(Required = true)] public partial string Name { get; }
    [Property(Required = true)] public partial Topology Topology { get; }
    [Composition] public partial IReadOnlyList<Machine> Machines { get; }
    [Composition] public partial IReadOnlyList<Service> Services { get; }
    [Composition] public partial IReadOnlyList<Cert> Certs { get; }
    [Composition] public partial IReadOnlyList<DnsEntry> DnsEntries { get; }

    [Invariant("Lab name must be unique on the host machine")]
    private Result NameMustBeUnique() => /* ... */;

    [Invariant("HA topology requires at least 9 machines")]
    private Result HaRequiresEnoughMachines()
        => Topology != Topology.Ha || Machines.Count >= 9
            ? Result.Success()
            : Result.Failure("HA needs ≥ 9 machines");
}

Why HomeLab uses it: Lab, Machine, Service, Cert, DnsEntry are real DDD aggregates with invariants enforced at construction time. Source generators emit the entity implementation, the builder, EnsureInvariants(), the EF Core mapping, the repository. The pipeline operates on these aggregates, not on free-form dictionaries.


19. FrenchExDev.Net.HttpClient — typed HTTP

[TypedHttpClient]
public partial interface IPiHoleApi
{
    [Get("/admin/api.php?customdns&action=add&domain={hostname}&ip={ip}&auth={token}")]
    Task<Result<PiHoleResponse>> AddCustomDnsAsync(string hostname, string ip, string token, CancellationToken ct);

    [Get("/admin/api.php?customdns&action=delete&domain={hostname}&auth={token}")]
    Task<Result<PiHoleResponse>> RemoveCustomDnsAsync(string hostname, string token, CancellationToken ct);
}

Why HomeLab uses it: Typed HttpClient calls for PiHole.Api, GitLab.Api, Vagrant.Registry, Cloudflare.Api. Source-generated. No manual HttpRequestMessage building. No manual JSON parsing. No manual error mapping. Result types instead of exceptions for non-success status codes.


A note on what is not in the toolbelt

Things HomeLab does not import:

  • MediatR — replaced by FrenchExDev.Net.Mediator, which is API-compatible at the source level but has no commercial-license complexity.
  • AutoMapper — replaced by FrenchExDev.Net.Mapper, which is source-generated and has zero runtime reflection cost.
  • Polly — used internally by the binary wrappers but never imported by HomeLab application code; retries and timeouts are declared via [InjectableDecorator] on the wrapped service, not at the call site.
  • SerilogMicrosoft.Extensions.Logging.Abstractions is enough, with structured fields handled by the event bus, not by string templates.
  • Newtonsoft.JsonSystem.Text.Json everywhere.

Every "I bet they used X" inference you might have about a typical .NET project: the answer is "the FrenchExDev.Net version of X, which is smaller, generated, and [Injectable]-friendly".


What this gives you that bash doesn't

Bash gives you set, read, printf, getopts, trap, IFS, and a thousand corners of POSIX you have to relearn every six months. Each cross-cutting concern in bash is open-coded in every script that needs it. There is no library. There is no test. There is no version. There is no upgrade path.

The FrenchExDev toolbelt gives you, for the same surface area:

  • DI generated by [Injectable] instead of hand-rolled if (foo == null) foo = new Foo() everywhere
  • Errors typed by Result<T> instead of $? and set -e lying to you
  • Builders generated by [Builder] instead of three-line YAML you copy-paste
  • State machines generated by [StateMachine] instead of case statements with bugs in the off-by-one transition
  • Sagas generated by [Saga] instead of trap EXIT cleanup_everything you forget to write
  • Event streams from Reactive instead of tee >(grep …) >(awk …) pipelines
  • Optional types from Options instead of ${VAR:-default} and the bugs that creep in
  • Mappers generated by [MapFrom] instead of jq filters that drift from the schema
  • Mediator dispatch instead of case "$verb" in switch statements
  • Outbox-backed reliability instead of "I hope the curl succeeded"
  • Typed binary wrappers from [BinaryWrapper] instead of docker run strings spread across files
  • Typed CI YAML from GitLab.Ci.Yaml instead of hand-written YAML you forgot to indent
  • Alpine version pinning from Alpine.Version instead of LATEST=$(curl … | jq -r .version)
  • Requirements traceability from Requirements instead of "PROJ-142 — somewhere in the wiki"
  • Quality gate from QualityGate instead of "did anyone run the tests"
  • DDD aggregates from Ddd instead of dictionaries with magic strings
  • Typed HTTP from HttpClient instead of curl | jq everywhere

The bargain is that you spend a week learning the toolbelt and never spend an hour reinventing one of its components. The toolbelt is written. The toolbelt is tested. The toolbelt is upgraded. Your job is to use it.


⬇ Download