Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/the-bootstrapper-is-seventy-six-lines && read --section="top" 0%
Architecture

The Bootstrapper Is Seventy-Six Lines

Inflow's host knows nothing about its five modules - it finds them by scanning DLL filenames and reflecting for an interface. Reading the loader shows why the whole estate is that small, and where a missing settings file silently deletes a module.

By Kumar Chandrachooda 18 Jan 2026 7 min read
A host with two files, reaching into a folder it has never read

Part 1 established that no Inflow module references another. That leaves an obvious question: something has to start them, and whatever that something is, it will need to know all five modules exist. That is usually where the boundary quietly re-forms — a Program.cs with five AddCustomersModule() calls and five using directives pointing into every module in the estate.

On master, the Bootstrapper project is two files. Program.cs is nineteen lines. Startup.cs is fifty-seven. Seventy-six lines total, and neither of them names a module.

Nineteen lines of host

public class Program
{
    public static Task Main(string[] args)
        => CreateHostBuilder(args).Build().RunAsync();

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup<Startup>())
            .ConfigureModules()
            .UseLogging();
}

That is Program.cs in full, from src/Bootstrapper/Inflow.Bootstrapper/Program.cs. Two of those four builder calls are Inflow's own. UseLogging() wires Serilog from the logger configuration section. ConfigureModules() is the interesting one:

public static IHostBuilder ConfigureModules(this IHostBuilder builder)
    => builder.ConfigureAppConfiguration((ctx, cfg) =>
    {
        foreach (var settings in GetSettings("*"))
        {
            cfg.AddJsonFile(settings);
        }

        foreach (var settings in GetSettings($"*.{ctx.HostingEnvironment.EnvironmentName}"))
        {
            cfg.AddJsonFile(settings);
        }

        IEnumerable<string> GetSettings(string pattern)
            => Directory.EnumerateFiles(ctx.HostingEnvironment.ContentRootPath,
                $"module.{pattern}.json", SearchOption.AllDirectories);
    });

It walks the content root recursively looking for files named module.*.json, adds each one to the configuration builder, then does a second pass for module.*.{Environment}.json. Each module ships its own settings file — module.customers.json, module.payments.json and so on, five of them plus five .development.json overlays — and the host never learns their names. A module's configuration is discovered by filename glob, in the same way its code is discovered by assembly scan.

Fifty-seven lines of composition

Startup.cs does four things. Its constructor loads modules:

public Startup(IConfiguration configuration)
{
    _assemblies = ModuleLoader.LoadAssemblies(configuration, "Inflow.Modules.");
    _modules = ModuleLoader.LoadModules(_assemblies);
}

ConfigureServices calls services.AddModularInfrastructure(_assemblies, _modules) and then loops module.Register(services). Configure calls app.UseModularInfrastructure(), loops module.Use(app), validates contracts, and maps three endpoints — the controllers, a root "Inflow API" string, and GET /modules. Then it does something I have not seen elsewhere:

_assemblies.Clear();
_modules.Clear();

The two lists that drove the whole composition are emptied at the end of Configure. The IModule instances are constructed by the loader, used twice, and dropped; nothing keeps a registry of module objects past startup. That is a deliberate statement about lifetime — modules are a boot-time concept, not a runtime one — and it is the sort of small decision that only makes sense once you have seen a codebase where a static ModuleCatalog outlives the request pipeline.

The loader, line by line

ModuleLoader.LoadAssemblies is where discovery actually happens.

public static IList<Assembly> LoadAssemblies(IConfiguration configuration, string modulePart)
{
    var assemblies = AppDomain.CurrentDomain.GetAssemblies().ToList();
    var locations = assemblies.Where(x => !x.IsDynamic).Select(x => x.Location).ToArray();
    var files = Directory.GetFiles(AppDomain.CurrentDomain.BaseDirectory, "*.dll")
        .Where(x => !locations.Contains(x, StringComparer.InvariantCultureIgnoreCase))
        .ToList();

    var disabledModules = new List<string>();
    foreach (var file in files)
    {
        if (!file.Contains(modulePart))
        {
            continue;
        }

        var moduleName = file.Split(modulePart)[1].Split(".")[0].ToLowerInvariant();
        var enabled = configuration.GetValue<bool>($"{moduleName}:module:enabled");
        if (!enabled)
        {
            disabledModules.Add(file);
        }
    }
    // ... removes disabled files, then loads the rest
}

Read it slowly, because every line is a design decision.

AppDomain.CurrentDomain.GetAssemblies() first, then the directory. The already-loaded set is captured, and only DLLs not already loaded become candidates. Inflow's Bootstrapper has project references to every module API project, so in a normal build those assemblies are already in the domain and the file scan finds nothing new. The scan exists for the deployment model where module DLLs are dropped into the output folder without a compile-time reference — which is the model the design is really aiming at, even though the shipped solution does not use it.

The module name comes from the filename. file.Split("Inflow.Modules.")[1].Split(".")[0].ToLowerInvariant() turns .../Inflow.Modules.Customers.Core.dll into customers. The identity of a module is a substring of a path. This is the first appearance of a pattern that runs through the whole framework and gets part 3 to itself: module identity is derived by string surgery, never declared.

configuration.GetValue<bool>($"{moduleName}:module:enabled") is the disable switch, and its default is the sharp edge. GetValue<bool> returns default(bool)false — when the key is absent. So a module whose module.<name>.json fails to be copied to the output directory, or gets renamed, or has its enabled key removed, is not merely un-configured. It is disabled, silently, with no log line. The absence of a settings file and the deliberate decision to switch a module off are indistinguishable to this code.

That matters more than it looks, because disabling is thorough. Back in AddModularInfrastructure, the same configuration scan runs again, and for every disabled module the MVC application-part manager removes any part whose name contains the module name:

var parts = manager.ApplicationParts.Where(x => x.Name.Contains(disabledModule,
    StringComparison.InvariantCultureIgnoreCase));

Controllers vanish, routes vanish, and the module's absence is visible only as 404s. A substring match on a part name is also broad enough to be interesting: a module named pay would remove the application parts for payments too.

Ordering by type name

The second half of the loader is four lines:

public static IList<IModule> LoadModules(IEnumerable<Assembly> assemblies)
    => assemblies
        .SelectMany(x => x.GetTypes())
        .Where(x => typeof(IModule).IsAssignableFrom(x) && !x.IsInterface)
        .OrderBy(x => x.Name)
        .Select(Activator.CreateInstance)
        .Cast<IModule>()
        .ToList();

IModule itself is six lines — Name, an optional Policies collection with a default interface implementation returning null, Register(IServiceCollection) and Use(IApplicationBuilder). Every module implements it with an internal class, which works because Activator.CreateInstance does not care about accessibility for a public parameterless constructor on an internal type.

OrderBy(x => x.Name) is the line worth arguing about. Registration order and middleware order are both alphabetical by class name — CustomersModule, PaymentsModule, SagaModule, UsersModule, WalletsModule. For this estate that is harmless, because every module's Register is independent and every Use only subscribes request paths and contracts. But it is determinism by coincidence rather than by design: rename WalletsModule to AccountsModule and its middleware moves to the front of the pipeline. A Priority property on IModule, or a topological sort on declared dependencies, is the version of this that survives a bigger estate.

To be fair to the design, alphabetical ordering is stable, which is the property that actually matters most. A boot order that changes between runs is far worse than one that changes when you rename a class, and reflection-based discovery gives you the unstable version for free unless you sort. Somebody thought about this.

Two phases, and what each is allowed to do

The IModule interface has exactly two verbs, and the split between them is the framework's whole lifecycle model.

Register(IServiceCollection) runs during ConfigureServices and is where a module adds its own services. In practice every module implements it as a single call to an internal AddCore() extension, which registers the module's DbContext, its repositories, its options and its unit of work. Nothing in Register touches the message bus, because handlers are found by the assembly scan rather than registered by hand — a point that turns out to matter enormously, and gets its own treatment in part 9.

Use(IApplicationBuilder) runs during Configure and is where a module declares what it exposes to the rest of the estate. Two things happen there, and only two: request paths get subscribed, and contracts get registered.

app.UseModuleRequests()
    .Subscribe<GetCustomer, CustomerDetailsDto>("customers/get",
        (query, serviceProvider, cancellationToken)
            => serviceProvider.GetRequiredService<IQueryDispatcher>().QueryAsync(query, cancellationToken));

app.UseContracts()
    .Register<SignedUpContract>()
    .Register<UserStateUpdatedContract>();

That is the Customers module's entire public surface to other modules, in nine lines. "customers/get" is a string path, not a type — the synchronous request/response channel is addressed like a URL rather than like a method call, which is another of the seams that only makes sense if you know a transport is coming. The Func given to Subscribe receives an IServiceProvider, because ModuleSubscriber creates a scope per request and hands the scoped provider in; the module never sees the root container.

There is a third IModule member that neither phase uses directly. Policies is a default-interface-implementation property returning null, and modules that override it — Customers returns new[] { "customers" } — have those strings collected by AddModuleInfo into a ModuleInfoProvider and surfaced on GET /modules. They are also passed into AddAuth(modules), which turns each into an authorisation policy. A module declares the permissions it wants to exist, and the shared auth layer materialises them. It is a small thing and it is the only place in the design where a module tells the framework something rather than the reverse.

The other half of boot happens outside IModule entirely. DbContextAppInitializer is a hosted service that reflects over every loaded assembly for DbContext subtypes, resolves each one, and calls Database.MigrateAsync — so schema migration is also discovery-driven, and a module that ships a DbContext gets its migrations run without telling anybody. It then runs every registered IInitializer, each wrapped in its own try/catch that logs and continues. That last detail is the first appearance of a convention this series will keep meeting: the boot path is strict about configuration and forgiving about work.

What seventy-six lines buys

The Bootstrapper contains no module names, no feature flags, no conditional registration, and no knowledge of what a module is beyond an interface with four members. Everything specific lives either in the module or in the shared framework. Adding a sixth module is: create the projects, implement IModule, ship a module.<name>.json with enabled: true, and add a project reference from the Bootstrapper — or don't, and drop the DLL in the output folder instead.

That is a genuinely good composition root, and it is worth saying so plainly before this series starts finding defects, because the defects are all inside the framework rather than in how the host uses it. The host is fine. The interesting question is what AddModularInfrastructure does with those assemblies once it has them, and the answer starts with a string index.

Next, a namespace segment is a routing key — the single Split(".")[2] that four separate subsystems depend on, and that nothing in the repository validates.