Architecture Decision Record
ADR-106: C# Extension Members as the Public DI Registration Surface
Status
Accepted (2026-09-01).
Context
Every host in this workspace boots the same way: a Program.cs calls a short list of Add* methods
on IServiceCollection, one per layer, and all of the framework's wiring sits behind those names.
MMCA.Helpdesk's web host is the minimal case, calling services.AddApplication()
(MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:66),
services.AddInfrastructure(builder.Configuration) (:67), services.AddAPI(modulesSettings)
(:89) and services.AddApplicationDecorators() (:120).
What is unusual is how those methods are declared. None of them is a classic static extension method
with a this parameter. Each is a member of a C# extension(T) block: AddApplication is written
as public IServiceCollection AddApplication() inside extension(IServiceCollection services)
(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:29, method at :35), and
AddInfrastructure
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:55, method at :63),
AddAPI (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:27, method at
:44) and AddUIShared (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:28,
method at :34) take the identical shape. The framework says so on the types themselves:
Infrastructure's DI class documents itself as using "C# preview extension types to add methods
directly to IServiceCollection" (Infrastructure/DependencyInjection.cs:50-51) and UI's repeats it
for AddUIShared (UI/DependencyInjection.cs:24).
Compiling that requires a preview language version, and every repo in the workspace sets one:
LangVersion preview in MMCA.Common/Directory.Build.props:6, MMCA.Store/Directory.Build.props:15,
MMCA.ADC/Directory.Build.props:9 and MMCA.Helpdesk/Directory.Build.props:10, each beside the same
net10.0 target (:3, :6, :6, :7) and the same TreatWarningsAsErrors (:7, :16, :10,
:11). It is a solution-wide property in a Directory.Build.props, not a per-project opt-in that a
leaf csproj could decline.
This is therefore not a stylistic preference confined to one file. It is the shape of the entire
public registration surface of packages published to nuget.org and GitHub Packages under ADR-053,
frozen member by member by the ADR-015 public-API gate, and repeated by every consumer that writes
its own module registration. A language feature compiled under preview sits underneath all of it,
and nothing in the code records that as a decision with a stated cost and a stated way out. This
record does.
Decision
The framework's public dependency-injection surface is written as C# extension members:
extension(T) blocks inside public static class types, compiled under LangVersion preview in all
four repos and shipped to both registries in that form. The compiler-emitted classic static extension
method is what keeps the choice reversible, and the public-API baselines record both shapes.
Preview is a workspace-wide language version, not a local opt-in. All four repos set
LangVersiontopreviewin their rootDirectory.Build.props(MMCA.Common/Directory.Build.props:6,MMCA.Store/Directory.Build.props:15,MMCA.ADC/Directory.Build.props:9,MMCA.Helpdesk/Directory.Build.props:10), so every project in every solution compiles at it. None of the fourglobal.jsonfiles pins an SDK: each contains only the Microsoft Testing Platform runner (MMCA.Common/global.json:1-5, and the Store, ADC and Helpdesk files are identical), so the compiler that interpretspreviewis whichever 10.0.x SDK is installed.Twenty-three
extension(IServiceCollection services)blocks are the DI surface. Measured on 2026-09-04 acrossMMCA.Common/Source, there are 23 such blocks in 23 files, spread over ten packages: Application (Application/DependencyInjection.cs:29,Application/Notifications/DependencyInjection.cs:29), Infrastructure (Infrastructure/DependencyInjection.cs:55), API (API/DependencyInjection.cs:27,API/Authentication/ExternalAuthExtensions.cs:30,API/Authorization/AuthorizationExtensions.cs:14,API/Caching/OutputCacheEvictionExtensions.cs:95,API/Startup/MiniProfilerExtensions.cs:11,API/Startup/WebApplicationBuilderExtensions.cs:238), UI (UI/DependencyInjection.cs:28,UI/Notifications/DependencyInjection.cs:14,UI/Services/Capabilities/DependencyInjection.cs:25), UI.Web (UI.Web/DependencyInjection.cs:16), UI.Maui (UI.Maui/DependencyInjection.cs:34), Grpc (Grpc/DependencyInjection.cs:17), Aspire (Aspire/Extensions.cs:381,Aspire/GatewayCorsExtensions.cs:18,Aspire/Security/SecurityHeaders.cs:212,Aspire/Gateway/GatewayRateLimitingExtensions.cs:179,Aspire/Gateway/GatewayHealthCheckExtensions.cs:96), Gateway (Gateway/RateLimiting/GatewayRoutePolicyExtensions.cs:29) and Testing (Testing/Support/FeatureManagementTestExtensions.cs:12,Testing/Support/RateLimiterTestExtensions.cs:13). A plain text search finds 26 occurrences of that exact receiver, because three of them are analyzer-suppression justification strings rather than declarations (Infrastructure/DependencyInjection.cs:870,:893,:932).The idiom reaches well past DI. The same measurement finds 83
extensionblocks across 66 files underMMCA.Common/Source. Receivers includeWebApplicationBuilder(API/Startup/ModuleHostExtensions.cs:24,Aspire/Logging/SerilogHostExtensions.cs:29),WebApplication(API/Startup/WebApplicationExtensions.cs:37),IEndpointRouteBuilder(API/Startup/Endpoints/JwksEndpointExtensions.cs:22,API/SessionCookies/SessionCookieEndpoints.cs:20),IApplicationBuilder(Gateway/ForwardedHeadersExtensions.cs:25),IDistributedApplicationBuilderandIResourceBuilder<ProjectResource>(Aspire.Hosting/Extensions.cs:126,:340,:410),IPageandILocator(Testing.E2E/Infrastructure/PageExtensions.cs:62,:335),Type,AssemblyandPropertyInfo(Testing.Architecture/RuleHelpers.cs:16,:40,:114), and generic receivers such asIReadRepository<TEntity, TIdentifierType>(Application/Extensions/ReadRepositoryExtensions.cs:12).Module composition is registered through one of these blocks.
AddModuleHostbinds the two settings sections, builds theModuleLoaderand registers it as a singleton, and it is an extension member onWebApplicationBuilder(API/Startup/ModuleHostExtensions.cs:24, method at:51). TheIModulecontract of ADR-059 therefore reaches a host through the same surface this record describes.Consumers write them too. The idiom is not confined to the framework: MMCA.ADC declares 20 blocks across 20 files under
Source(14 module DI classes, the four service-contract packages,AppHost/BrokerSelection.csandModules/Conference/MMCA.ADC.Conference.API/Authorization/CurrentUserServiceExtensions.cs), MMCA.Store 16 across 16, and MMCA.Helpdesk 3 across 3 (Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/DependencyInjection.csand its.APIand.Infrastructuresiblings). The reference seed teaches the shape by using it.The call site is indistinguishable from a classic extension method. A host writes
services.AddApplication();(Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:66), and the same holds for every other entry point. Nothing about the declaration style is visible to the caller.The public-API gate records every extension member twice. RS0016 and RS0017 stay at error severity and every packable Source project declares its surface in
PublicAPI.Shipped.txt(MMCA.Common/Directory.Build.props:77-92, gate item group at:86, rules described at:78-79). For an extension member the baseline holds a container line plus a member line, and a separate classic static line carrying athisparameter.AddApplicationappears asMMCA.Common.Application.DependencyInjection.extension(...IServiceCollection!).AddApplication()(Application/PublicAPI.Shipped.txt:64, container at:63) and asstatic MMCA.Common.Application.DependencyInjection.AddApplication(this ...IServiceCollection! services)(:701). Across the repo there are 187.extensionlines in 13PublicAPI.Shipped.txtfiles and 58 in 11PublicAPI.Unshipped.txtfiles, covering 14 packages. Gateway's whole surface is still unshipped: itsPublicAPI.Shipped.txtcontains only#nullable enable, and both shapes ofUseCommonForwardedHeaderssit inGateway/PublicAPI.Unshipped.txt:12and:98.One extension property exists in the whole surface, and it emits a different classic shape.
IsIdValueGeneratedis declared as an extension property onType(Domain/Extensions/EntityTypeExtensions.cs:11) and is recorded as...EntityTypeExtensions.extension(System.Type!).IsIdValueGenerated.get -> bool(Domain/PublicAPI.Shipped.txt:68) with the classic counterpartstatic ...EntityTypeExtensions.get_IsIdValueGenerated(System.Type! entityType) -> bool(:224). A method emitsName(this T x); a property emitsget_Name(T x). Those are different members.The MAUI package uses the idiom but sits outside the gate.
MMCA.Common.UI.Mauideclares two blocks (UI.Maui/DependencyInjection.cs:34onIServiceCollection,UI.Maui/HostingDependencyInjection.cs:17onMauiAppBuilder) and is the one project excluded from the public-API analyzer, because it lives outsideMMCA.Common.slnxand builds only on the windows MAUI job (MMCA.Common/Directory.Build.props:86, reason at:82-85, naming ADR-042). Its extension surface is therefore unbaselined.Analyzer fallout is carried as documented suppressions, not by changing the code shape. CA1708 ("identifiers should differ by more than case") fires on the compiler-generated grouping members of an
extension(T)block and is suppressed at the type with an explicit false-positive justification in 17 files, 16 of them underMMCA.Common/Source(for exampleGateway/ForwardedHeadersExtensions.cs:19-22,UI/Extensions/MoneyExtensions.cs:10-13) and one in an ADC E2E page object. IDE0051 ("unused private member") misses calls that cross from inside a block to a private member of the containing class on SDK 10.0.201 and later, and is suppressed three times in one file with that reason spelled out (Infrastructure/DependencyInjection.cs:867,:890,:929).A fitness function has to know the emitted shape. The
DomainThrowsOnlyArgumentGuardsrule (Testing.Architecture/Rules/Domain/ArchitectureRules.DomainThrows.cs:67) walks IL and would otherwise flag the skeleton members anextension(T)block leaves in a Domain assembly, whoseNotSupportedExceptionnobody typed. It skips any method carryingSystem.Runtime.CompilerServices.ExtensionMarkerAttribute(constant at:8, filter at:88, predicate at:174-178, documented at:157-173).The exit path is a mechanical rewrite that does not reach callers. If the feature changed shape, each block would be flattened back to classic static extension methods: a
public R M(...)insideextension(T x)becomespublic static R M(this T x, ...), with the method names, parameters and return types unchanged. That is exactly the form the baselines already record on theirstatic ...(this ...)lines (Application/PublicAPI.Shipped.txt:701-705,:706,:707,:717), so the public API a consumer binds to would not move and noProgram.csline would change. The single exception is the extension property in Decision point 8, whose classic form isget_IsIdValueGenerated(Type)rather than athis-marked method.
Rationale
- One
Add*name per layer is the point of the surface. A host reads as a list of layers (Program.cs:66,:67,:89,:120), and grouping the registrations by receiver in a single block is what keeps the declaration site organized by what it extends rather than by a repeatedthis IServiceCollection servicesparameter on every method. - The compiler already emits the classic shape, so the exposure is smaller than the word "preview" suggests. Both forms are in the baseline for every extension member, which is direct evidence that the shipped metadata still contains an ordinary static extension method. The choice is about a declaration syntax, not about a new binding mechanism reaching consumers.
- The public-API gate turns that into a reviewable diff. RS0016 and RS0017 at error severity
(
Directory.Build.props:78-79) mean any change to an extension member, including one caused by a compiler change to the emitted shape, shows up as a text diff inPublicAPI.Shipped.txtbefore a package is published, which is the same protection ADR-015 gives every other member. - Consistency across four repos beats a mixed idiom. With 83 blocks in the framework and 39 more
across ADC, Store and Helpdesk, a partial adoption would mean a reader has to know which of two
declaration styles a given
Add*uses. The property is set once per repo inDirectory.Build.propsand the shape is uniform. - The suppressions are cheaper than the alternative. Seventeen type-level CA1708 suppressions and
three IDE0051 ones are a bounded, documented cost. The alternative under
TreatWarningsAsErrorsplusCodeAnalysisTreatWarningsAsErrors(Directory.Build.props:7,:13) would be lowering an analyzer's severity repo-wide, which hides real hits along with the false ones.
Trade-offs
- A preview language feature under a floating SDK is a moving target. No
global.jsonpins an SDK version and CI installsdotnet-version: '10.0.x'(MMCA.Common/.github/workflows/ci.yml:82and seven more,release.yml:24,:108), so the compiler and the analyzers that interpret these blocks can change on any patch release with no repo edit. That is not hypothetical: the IDE0051 suppressions record behavior that differs between SDK 10.0.201 and the 10.0.104 the same comment names (Infrastructure/DependencyInjection.cs:870). - Method to property inside a block is a binary break, and it does not look like one. Both are
members of the same block and the source edit is two words, but the emitted classic member changes
from
Name(this T)toget_Name(T)(Domain/PublicAPI.Shipped.txt:68beside:224, againstApplication/PublicAPI.Shipped.txt:64beside:701). RS0017 catches the removal at build time in MMCA.Common; a consumer that had already compiled against the old member does not get that warning. - Analyzers do not fully understand the shape. CA1708 is wrong on every block it flags (17 type-level suppressions) and IDE0051 is wrong across the block boundary (three more). Each suppression is a place where a genuine future hit on that type is silenced too, and the IDE0051 ones carry an explicit "remove this once Roslyn fixes it" that nothing enforces.
- Anything reflecting over the assemblies has to special-case the marker attribute. The
architecture fitness rule already does
(
Testing.Architecture/Rules/Domain/ArchitectureRules.DomainThrows.cs:8,:174-178). Any future rule, source generator or documentation tool that walks methods in a framework assembly inherits the same requirement, and the failure mode is a false positive on a body no developer wrote. - The public-API baselines are roughly doubled for this surface. 187 shipped and 58 unshipped
.extensionlines sit alongside theirstatic ...(this ...)counterparts, so a single new registration method costs two or three baseline lines instead of one, and a reviewer reading a baseline diff sees the same member twice. - The one package with no gate is the one with the least coverage.
MMCA.Common.UI.Maui's two blocks (UI.Maui/DependencyInjection.cs:34,UI.Maui/HostingDependencyInjection.cs:17) are excluded from RS0016/RS0017 (Directory.Build.props:86), so a reshape there would reach a published package without the text diff that protects the other thirteen. - The declaration reads as an instance method that is not one.
public IServiceCollection AddApplication()(Application/DependencyInjection.cs:35) has no visible receiver parameter; the receiver comes from the enclosing block header six lines up. That is the ergonomic benefit and the readability cost in the same line, and it is why three suppression justifications had to explain the block boundary in prose rather than point at a rule.
Related
ADR-015 (the RS0016/RS0017 baseline that freezes both
emitted shapes of every extension member, and the fitness-rule tier that had to learn about
ExtensionMarkerAttribute), ADR-059 (the IModule
contract, whose host-side composition is registered through the AddModuleHost extension member),
ADR-053 (the dual-registry publish that ships this surface
to nuget.org and GitHub Packages), ADR-016 (lockstep
versioning: a reshape of this surface lands in every package at one version and every consumer bumps
in one pass), ADR-042 (the MAUI record named by the
Directory.Build.props exclusion that leaves MMCA.Common.UI.Maui outside the public-API gate),
ADR-101 (the metapackage a host installs to get most of these Add*
names in one PackageReference).