Skip to content
Kumar Chandrachooda
.NET

One Builder, Thirty Packages: Inside IConveyBuilder

The hundred lines of code that let an entire microservices toolkit compose from one fluent chain — a named registry, a list of build actions, and two hard lessons about doing work at startup.

By Kumar Chandrachooda 02 Sep 2025 5 min read
Every package clicks into the same rail

Part 1 ended on a twenty-line Program.cs that wires up discovery, messaging, tracing, persistence and an HTTP API by chaining Add* calls. The obvious question is what those calls are chaining on. The answer is the least glamorous class in the whole of Convey — and the one I have stolen from most often in my own platform libraries.

ConveyBuilder is about fifty lines. There is no plugin manifest, no assembly attribute scanning, no MEF-style catalog. Just a wrapper around IServiceCollection that adds exactly three capabilities the raw container doesn't have. Everything else in the toolkit — all thirty-plus packages — is extension methods over this interface:

public interface IConveyBuilder
{
    IServiceCollection Services { get; }
    IConfiguration Configuration { get; }
    bool TryRegister(string name);
    void AddBuildAction(Action<IServiceProvider> execute);
    void AddInitializer(IInitializer initializer);
    void AddInitializer<TInitializer>() where TInitializer : IInitializer;
    IServiceProvider Build();
}

Let me take the three capabilities in turn, because each one earns its place.

TryRegister: idempotency by name

Composite packages create a real problem: AddRabbitMq() wants logging, AddMessageOutbox() also wants logging, and your own code calls AddLogging-something too. Without a guard, registrations pile up — three IHostedService instances, duplicated decorators, subtle bugs.

Convey's answer is a ConcurrentDictionary<string, bool> and one method (ConveyBuilder.cs):

public bool TryRegister(string name) => _registry.TryAdd(name, true);

Every package's Add* method starts by calling TryRegister("rabbitmq") (or "consul", "webApi", …) and returns early if it comes back false. The module registers once, no matter how many code paths ask for it. It is the DI equivalent of EnsureCreated — and because the registry is keyed by string, packages that have never heard of each other can still coordinate.

The pattern costs nothing and I now consider it table stakes for any internal library that other libraries depend on. The .NET ecosystem's usual alternative — services.TryAddSingleton<T>() per registration — protects individual descriptors, but not the composite act of setting up a module (options + clients + hosted services + middleware markers as one unit). Registering “a name for the whole module” is the right granularity.

AddBuildAction: work that needs the container

Some setup can't happen while you are still describing services, because it needs to resolve them: registering the service instance with Consul, declaring RabbitMQ exchanges, seeding a Mongo database. The classic hack is to do it in Configure() with app.ApplicationServices.GetService(...) scattered everywhere. Convey centralises it:

public void AddBuildAction(Action<IServiceProvider> execute)
    => _buildActions.Add(execute);

public IServiceProvider Build()
{
    var serviceProvider = _services.BuildServiceProvider();
    _buildActions.ForEach(a => a(serviceProvider));
    return serviceProvider;
}

A package queues a callback at registration time; the callback runs exactly once, immediately after the container is built, in registration order. That's the entire mechanism, and it's enough to give every package a “post-build hook” without inventing a lifecycle framework.

Initializers are built on top of build actions, which I find genuinely elegant. AddInitializer<T>() doesn't store the initializer — it queues a build action that resolves T from the finished container and hands it to a singleton IStartupInitializer:

public void AddInitializer<TInitializer>() where TInitializer : IInitializer
    => AddBuildAction(sp =>
    {
        var initializer = sp.GetRequiredService<TInitializer>();
        var startupInitializer = sp.GetRequiredService<IStartupInitializer>();
        startupInitializer.AddInitializer(initializer);
    });

So there are two phases: build actions run synchronously at Build(), and the collected IInitializers (async — database seeding, exchange declarations) run later, sequentially, when the app calls UseConvey(). Registration is cheap and declarative; deferred work is explicit and ordered.

GetOptions: the convention that scales

The third pillar is barely code at all (Extensions.cs):

public static TModel GetOptions<TModel>(this IConfiguration configuration, string sectionName)
    where TModel : new()
{
    var model = new TModel();
    configuration.GetSection(sectionName).Bind(model);
    return model;
}

No IOptions<T>, no validation framework — bind a POCO from a named section, done. What makes it matter is the convention stacked on it: every package owns exactly one section, named after itself. "consul", "fabio", "rabbitmq", "mongo", "jaeger", "logger", "swagger", "vault". After a week with Convey you stop reading documentation for configuration shapes; you open appsettings.json and the service describes itself. Even AddConvey() follows its own rule — it binds AppOptions from "app" for the service's name and version.

It also produces my favourite piece of frivolity in the toolkit: if app.name is set, AddConvey() prints the service name as ASCII art to the console using the Figgle library, in a font called Doom. A DI registration method that draws a banner is a purity violation by any standard — and in a terminal with eight services running, I have never once wanted to turn it off.

The rough edges, honestly

I promised source-level honesty in this series, and the builder has two warts worth internalising, because both are instructive failures.

UseConvey() blocks on async work. Here is how those carefully collected initializers actually run:

public static IApplicationBuilder UseConvey(this IApplicationBuilder app)
{
    using var scope = app.ApplicationServices.CreateScope();
    var initializer = scope.ServiceProvider.GetRequiredService<IStartupInitializer>();
    Task.Run(() => initializer.InitializeAsync()).GetAwaiter().GetResult();
    return app;
}

Sync-over-async at startup, with a Task.Run to dodge context capture. It works — there's no SynchronizationContext to deadlock against in a modern ASP.NET Core host — but exceptions surface with mangled stacks, and a slow initializer freezes startup with no cancellation path. Today the platform has a proper seam for exactly this: IHostedService.StartAsync, or IHostApplicationLifetime. Convey predates that idiom being widely adopted and it shows. When I ported this pattern into my own chassis, this was the first thing I changed.

GetOptions may build a throwaway container. The IConveyBuilder overload has a fallback for when no IConfiguration was passed to AddConvey():

using var serviceProvider = builder.Services.BuildServiceProvider();
var configuration = serviceProvider.GetRequiredService<IConfiguration>();
return configuration.GetOptions<TModel>(settingsSectionName);

Building a ServiceProvider mid-registration just to read config means every singleton registered so far gets a second short-lived life — and because nearly every package calls GetOptions during registration, a service that skips the configuration argument builds a dozen disposable containers before the real one exists. ASP.NET Core even logs a warning about it. The fix is trivially available — pass IConfiguration into AddConvey() — but the API making the bad path the default path is a design lesson: your convenience overload is the one everybody will use.

Why this class is the whole series

Neither wart changes my overall verdict. The reason ConveyBuilder matters is what it makes cheap: after this class exists, adding a capability to the ecosystem costs one static class with an Add* method that calls TryRegister, binds an options section, registers services, and maybe queues a build action. That is the entire barrier to entry. It is why Convey could grow to thirty-three packages with one recognisable shape, and why the rest of this series can be a package-by-package tour without re-explaining plumbing each time.

Next part: the CQRS spine — three marker interfaces, an assembly scan, and the dispatching trade-offs hiding inside using var scope.