Discovery That Works by Accident
Nothing in either host references an extension type, so nothing should load one - and the reason six packages are found anyway is a call five steps earlier in a fluent chain.
You add the package reference, you add the YAML block, you restart the gateway, and the feature is not there. No exception. No warning. No line in the log saying it looked for your extension and failed. The startup banner prints, the routes work, and the thing you configured simply does not exist. Now find out which of three completely different causes you are looking at, with no evidence except an absence.
Part 2 showed a four-member interface that buys six packages for almost nothing. This part is about how those six get found, which is the place the cheapness is paid for.
Forty-nine lines of provider
public IEnumerable<IEnabledExtension> GetAll()
{
if (_extensions.Any())
{
return _extensions;
}
var type = typeof(IExtension);
var extensionTypes = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(s => s.GetTypes())
.Where(p => type.IsAssignableFrom(p) && !p.IsInterface);
var extensions = new HashSet<IEnabledExtension>();
foreach (var extensionType in extensionTypes)
{
var extension = (IExtension) Activator.CreateInstance(extensionType);
var options = _options.Extensions?.SingleOrDefault(o =>
o.Key.Equals(extension.Name, StringComparison.InvariantCultureIgnoreCase)).Value;
if (options is null)
{
continue;
}
extensions.Add(new EnabledExtension(extension, options));
}
_extensions = new HashSet<IEnabledExtension>(extensions.OrderBy(e => e.Options.Order));
return _extensions;
}
— src\Ntrada\Extensions\ExtensionProvider.cs:19-48
Four things in that method decide the whole plugin story.
AppDomain.CurrentDomain.GetAssemblies()returns only assemblies already loaded into the runtime at the moment of the call. It does not scanbin/. It does not read.deps.json. It does not probe a plugins folder. Whatever the CLR has not yet had a reason to load is invisible.Activator.CreateInstanceruns before any filtering. Every non-interface type assignable toIExtensionanywhere in the process is constructed, whether or not the operator has ever heard of it.- A missing YAML key is a
continue. Not a log line, not a warning — a barecontinue, and the extension ceases to exist. - The result is memoised into a field, so the whole scan happens once and both the service-registration pass and the pipeline pass see the same set.
The first of those is the interesting one, because the CLR loads assemblies lazily, on first execution of a method that references a type from them. And nothing in this estate ever references an extension type.
Nobody mentions an extension, anywhere
Ntrada.Samples.Api project-references all six extension assemblies. Its Startup is four functional lines:
public void ConfigureServices(IServiceCollection services)
{
services.AddNtrada();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseNtrada();
}
— samples\Ntrada.Samples.Api\Startup.cs:11-19
Not one type from Ntrada.Extensions.Jwt, Ntrada.Extensions.Cors or any other package is named there. Ntrada.Host is the same: five project references, and a Program.cs that mentions nothing but AddNtrada and UseNtrada. A ProjectReference puts a DLL beside your application; it does not load it. Under plain lazy loading, Ntrada.Extensions.Jwt.dll would sit on disk, unloaded, at the moment GetAll() runs — and the gateway would boot with zero extensions and no error at all.
It works anyway, and the reason is not in ExtensionProvider.cs. It is in the order of a fluent chain:
public static IServiceCollection AddNtrada(this IServiceCollection services)
{
var (configuration, optionsProvider) = BuildConfiguration(services);
return services.AddCoreServices()
.ConfigureLogging(configuration)
.ConfigureHttpClient(configuration)
.ConfigurePayloads(configuration)
.AddNtradaServices()
.AddExtensions(optionsProvider);
}
— src\Ntrada\NtradaExtensions.cs:41-51
AddCoreServices() is first, and its body begins services.AddMvcCore(). In ASP.NET Core 3.1, AddMvcCore builds an ApplicationPartManager and hands it to DefaultAssemblyPartDiscoveryProvider, which walks DependencyContext.Default and calls Assembly.Load on every library in the dependency graph that references an MVC assembly. The core references Microsoft.AspNetCore.Mvc.NewtonsoftJson; all six extensions reference the core; therefore all six are MVC-referencing libraries in the entry application's dependency context, and MVC's part discovery force-loads every one of them for its own reasons.
By the time AddExtensions runs, five links later in that chain, the assemblies are in the AppDomain. The plugin system works because a completely unrelated framework subsystem eagerly loads its plugins as a side effect, and because one method happens to be called before another in a fluent expression.
Nothing documents that. Nothing tests it. Reorder the chain, drop AddMvcCore for a leaner pipeline, or build an extension that does not transitively reference MVC — a pure IApplicationBuilder middleware package, say — and discovery breaks silently.
Three silences, one symptom
Trace every path by which an extension can fail to appear:
- Assembly not loaded. Not in
GetAssemblies(), not in the type scan. No log, no error. - Assembly loaded, no matching YAML key.
options is null,continueat line 39. No log, no error. - Assembly loaded, key present,
enabled: false. BothAddExtensionsandUseExtensionsskip it withif (extension.Options.Enabled == false) continue;. No log, no error.
Three distinct causes, one identical symptom, and no diagnostic separates them. The only positive signal is the presence of a line like Enabled extension: 'jwt' (JSON Web Token authentication) on stdout, and an operator has to already know to look for it.
Cause two is not hypothetical, and the repository demonstrates it at production scale. src\Ntrada.Host\ntrada.yml is seven lines and contains no extensions: section at all. Ntrada.Host.csproj project-references Cors, CustomErrors, Jwt, RabbitMq and Tracing. The Dockerfile publishes Ntrada.Host. So the container the README tells you to build ships five extension assemblies, loads all five into the AppDomain, constructs all five, and activates none of them — while the README's “Extensions: JWT, RabbitMQ, Open Tracing with Jaeger, CORS, Custom errors” list sits directly above the docker build instructions. Every boot reflects over five packages and throws them away.
Note also which gate is the real one. Enabled is a bool? checked with == false, so a missing enabled: key means enabled — the right semantics, because presence in YAML is already the activation switch. The gate everybody thinks about is the weaker one; the gate that actually decides is whether you spelled the key correctly.
The one loud failure, and it is the wrong one
Activator.CreateInstance(extensionType) runs on every candidate type before the YAML lookup. So the failure this system is loudest about is the one an operator has no control over.
Write an abstract base for your own extensions and it is a non-interface type assignable to IExtension; Activator.CreateInstance throws. Write an extension with a constructor parameter — a logger, a clock, an injected option — and it throws. Reference a testing library that ships a fake IExtension and it throws. In every case the gateway dies at startup over an extension the operator never configured, with a MissingMethodException naming a type they have never seen. Alongside it, s.GetTypes() is unguarded against ReflectionTypeLoadException, the standard hazard of walking every loaded assembly in a process you do not fully control.
The model requires a public parameterless constructor and never says so anywhere.
The scan's reach is demonstrated, unintentionally, by the author's own tests. ExtensionProviderTests declares its fixture as a private nested class:
private class TestExtension : IExtension
{
public string Name { get; } = ExtensionName;
public string Description { get; } = ExtensionName;
public void Add(IServiceCollection services, IOptionsProvider optionsProvider) { }
public void Use(IApplicationBuilder app, IOptionsProvider optionsProvider) { }
}
— tests\Ntrada.Tests.Unit\Extensions\ExtensionProviderTests.cs:101-113
get_all_should_return_extension_with_not_null_extension_property passes because the production scan reaches a private type nested inside a test class in the test assembly. That is the strongest available proof that the scan has no boundary: it will find, construct and register anything, anywhere in the process, that implements the interface.
The fair reading, and the line that was missing
Judged against 2019, AppDomain.CurrentDomain.GetAssemblies() was not a shortcut — it was the idiomatic .NET Core plugin scan. It is what Autofac's assembly scanning did, what Scrutor did, and what MVC's own part discovery fell back on. AssemblyLoadContext-based plugin loading only became genuinely usable with .NET Core 3.0 and was not well documented until later. Source generators, which would have made the whole scan unnecessary, arrived with C# 9 in late 2020, after the last author commit. There is no version of this repository written on its own timeline that uses a materially better mechanism.
So the defect is not the reflection. It is that every failure mode in this system is a silence, and the fix available in 2019 was one line. ExtensionProvider.GetAll() had, in hand, at the moment of the continue: the extension's Name, its Description, and the fact that no configuration matched. A single logger.LogWarning("Found extension '{Name}' with no matching configuration.") would have turned all three of the silences above into a grep. There is no logger in ExtensionProvider — the class takes exactly one constructor parameter, NtradaOptions — so adding it would have cost a second parameter and a line.
It is worth naming the general rule, because it outlives this repository: a discovery mechanism that can decline to find something owes you a message when it declines. Not an exception, not a failed startup — just a sentence. The cost of that sentence is one line; the cost of its absence is every deployment where somebody stares at a working gateway wondering which of three identical silences they are in.
Next, what happens once the extensions have been found — because the order that scan produces is the order your middleware pipeline runs in, and nobody sets it.