Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/four-members-are-the-whole-plugin-contract && read --section="top" 0%
.NET

Four Members Are the Whole Plugin Contract

Two strings and two void methods buy you six shipping packages - and the list of things the interface never asks for is the more instructive half.

By Kumar Chandrachooda 22 Feb 2026 7 min read
Four filled slots on a card, and a column of empty ones running off the page

Most plugin systems fail in the direction of ceremony. You want to add one middleware, and first you must implement a lifecycle interface with six methods, declare a manifest, pick a version range, register in a catalogue, and satisfy a loader that wants an isolated context. By the time the plugin works you have written more infrastructure than feature. The opposite failure is rarer and quieter: a contract so thin that everything it omits becomes the implementer's problem, silently, one package at a time.

Part 1 put six extension packages on a star around one core. This part reads the interface at the centre of that star, because it is twelve lines long and it is the reason the six leaves can be as small as they are.

The whole interface

namespace Ntrada
{
    public interface IExtension
    {
        string Name { get; }
        string Description { get; }
        void Add(IServiceCollection services, IOptionsProvider optionsProvider);
        void Use(IApplicationBuilder app, IOptionsProvider optionsProvider);
    }
}

src\Ntrada\IExtension.cs, entire

Four members. Two are metadata and two are the two halves of ASP.NET Core startup. There is no base class, no attribute, no registration call, and no manifest. Implement this, put a matching key in your YAML, and you have an extension.

  • Name is the entire contract between code and configuration. It is used in three places, and the third is not obvious. ExtensionProvider matches it case-insensitively against the keys under extensions: in the YAML document; IOptionsProvider.GetForExtension<T>(Name) builds the configuration path extensions:<name>; and RabbitMqExtension.Use reuses it as the request-handler key, which is the string an operator writes in use: rabbitmq on a route. One string is a config section name, an identity, and a route verb. Nothing enforces that they agree; it just happens that one extension chose to make them agree.
  • Description is consumed exactly once, in a startup log line, and reaches no API, no health endpoint and no generated document.
  • Add runs inside ConfigureServices, Use inside Configure. The split is real and maps cleanly onto the framework it wraps.
  • Both methods receive IOptionsProvider, not a bound options object, so an extension fetches and binds its own configuration on its own terms.

The economy this buys is not theoretical. The entire CORS package is two files and fifty-nine lines of C#. CustomErrorsExtension is twenty-three lines. That is a complete, publishable, independently-versioned integration package, and the reason it can be that small is that the contract asks for nothing it does not need.

What a real extension looks like

Here is a fresh one — a response-header stamper, written for this article, not from the repository:

public sealed class ServerHeaderExtension : IExtension
{
    public string Name => "serverHeader";
    public string Description => "Stamps a fixed Server header on every response";

    public void Add(IServiceCollection services, IOptionsProvider optionsProvider)
        => services.AddSingleton(optionsProvider.GetForExtension<ServerHeaderOptions>(Name));

    public void Use(IApplicationBuilder app, IOptionsProvider optionsProvider)
        => app.Use(async (ctx, next) =>
        {
            var options = app.ApplicationServices.GetRequiredService<ServerHeaderOptions>();
            ctx.Response.Headers["Server"] = options.Value;
            await next();
        });
}

public sealed class ServerHeaderOptions : IOptions
{
    public string Value { get; set; } = "gateway";
}

Nine functional lines, plus a marker interface. Add serverHeader: { value: edge-01 } under extensions: in the YAML and it is live. As a developer-experience story for a 2019 solo-maintained project, that is genuinely good, and I want it on the record before the rest of this post takes the contract apart.

The internal extension, and why reflection does not mind

One of the six is declared differently from the other five:

internal class JwtExtension : IExtension

extensions\Ntrada.Extensions.Jwt\JwtExtension.cs:10

CorsExtension, CustomErrorsExtension, RabbitMqExtension, SwaggerExtension and TracingExtension are all public. JwtExtension is internal, and so is its JwtOptions. Nothing breaks, and the reason is worth stating precisely because it looks like it should break.

The core instantiates extensions with Activator.CreateInstance(extensionType). That overload requires a public parameterless constructor; it does not require a public type. JwtExtension declares no constructor, so C# supplies the implicit default one, and an implicit default constructor is public even when its class is not. The type is invisible to every consumer at compile time and perfectly visible to reflection at runtime.

That inconsistency is harmless here only because nothing was ever going to reference an extension type by name. No host in the estate mentions one; that is the subject of part 3. But it is a coin-flip the model never adjudicates. An implementer reading these six packages for guidance finds five votes for public and one for internal, with no rule anywhere and no consequence either way — which is exactly the kind of thing a plugin contract exists to settle.

The list of things it never asks for

Set the four members against what a plugin model normally carries:

Concern In IExtension?
Version or compatibility range No
Declared dependencies on other extensions No
Ordering preference No — Order lives on the config, not the interface
Schema for its own options No
Validation hook No
Shutdown or disposal No
Health or readiness signal No
Required constructor shape Not declared; enforced by a startup crash

Two of those absences bite in this repository rather than in the abstract.

Declared dependencies. The RabbitMQ package genuinely wants one. It defines ISpanContextBuilder precisely so that a tracing-aware implementation can be substituted and a trace context can travel into the AMQP headers. What it gets instead is NullSpanContextBuilder, a default that returns null, and no way whatsoever to say “I require the tracing extension.” The seam exists, the dependency is real, and the model has no vocabulary for it.

Ordering preference. Order is an int? on the configuration object, which means an extension author cannot express “I must be outermost” — only an operator can, and only by writing a number in YAML. Part 4 follows that one all the way to its consequence, which is worse than it sounds.

The reserved words nobody mentions

There is a second binding pass over the same YAML that no extension author is told about. The core binds NtradaOptions, whose Extensions property is an IDictionary<string, ExtensionOptions>, and ExtensionOptions has exactly two properties:

public sealed class ExtensionOptions : IExtensionOptions
{
    public int? Order { get; set; }
    public bool? Enabled { get; set; }
}

src\Ntrada\Extensions\ExtensionOptions.cs

So the subtree under extensions: rabbitmq: is bound twice, by two unrelated classes, for two different consumers. The framework binds it into ExtensionOptions and keeps order and enabled, discarding everything else. Then RabbitMqExtension.Add binds the same subtree into RabbitMqOptions and keeps everything else, discarding order and enabled.

It is a neat trick — framework metadata and plugin configuration in one YAML block, with no envelope and no settings: sub-key. It also means order and enabled are reserved words in every extension's option namespace, and nothing in the model, the source or the README says so. The RabbitMQ package nearly collides with it already: it has ssl.enabled, messageContext.enabled and logger.enabled, all nested and therefore safe, and the shipped sample writes a top-level enabled: true under rabbitmq: that the framework reads and RabbitMqOptions silently ignores. The collision is demonstrated in the estate's own flagship config without a word of comment.

What the model offloads onto you

Enumerated from the six packages, this is the work every implementer must repeat:

  1. The Debug/Release conditional ItemGroup pair — six byte-identical copies, no Directory.Build.props, no project template.
  2. The options-fetch lineoptionsProvider.GetForExtension<TOptions>(Name), six copies, one of which has already drifted to a hard-coded literal in TracingExtension.cs:21.
  3. An empty marker interface — every options class must implement IOptions to satisfy a generic constraint.
  4. A public parameterless constructor — undocumented, and enforced by a MissingMethodException at boot.
  5. Your own defaults, as C# property initialisers, because there is no defaults mechanism and Bind only overwrites what the config supplies.
  6. Your own options documentation, as a hand-maintained .yml snippet beside the source that nothing in the build checks. Part 7 is what happens when that drifts.
  7. Your own validation. Nothing validates options. RabbitMqHandler reads routeConfig["routing_key"] with a raw dictionary indexer, so a misconfigured route throws KeyNotFoundException on the first matching request rather than at startup.
  8. Your own DI-lifetime discipline. The core registers everything as a singleton; RabbitMQ registers its client and handler as transient; CustomErrors registers its middleware as scoped. Three lifetimes across six packages, no guidance, and no warning if you capture a scoped dependency in a singleton.

To be fair to the design, and this matters: judged against 2019 this contract is not thin by accident, it is thin because thin was the point, and most of the gaps had no cheap answer at the time. IOptions<T> validation with ValidateOnStart landed in .NET 6. Keyed DI landed in .NET 8. Source generators that would make the whole model declarative arrived with C# 9 in late 2020. Four members and no ceremony was a better bet in 2019 than most production frameworks of that era were making, and the six packages on the far side of it are the evidence.

The defect is not the reflection or the minimalism. It is the silence — an extension that fails to load, fails to match its YAML key, or is disabled produces no message at all. Next, the discovery mechanism that decides which of those three silences you get.