# API review conventions (ASP.NET Core)

The conventions the `@dotnet/aspnet-api-review` team applies when reviewing a proposed public API. Each entry is a self-contained data point: *Trigger* (the shape of proposal it applies to) → *Decision* → *Why* (reasoning that generalizes) → *E.g.* (one concrete anchor) → *Scope* (how firm, and the exception characteristic). Baseline is the [.NET Framework Design Guidelines](https://learn.microsoft.com/dotnet/standard/design-guidelines/); this is the ASP.NET-Core-specific layer on top. When two rules are in tension, **smaller public surface and consistency with existing APIs win.**

## Table of contents
- [Should this exist (need + audience)](#should-this-exist-need--audience)
- [Naming](#naming)
- [Namespaces & placement](#namespaces--placement)
- [Method families & type suffixes](#method-families--type-suffixes)
- [Async & cancellation](#async--cancellation)
- [Nullability](#nullability)
- [Return & parameter types](#return--parameter-types)
- [Overloads vs optional parameters](#overloads-vs-optional-parameters)
- [Options & builders](#options--builders)
- [Extensibility & sealing](#extensibility--sealing)
- [Language features (modern C#)](#language-features-modern-c)
- [Defaults](#defaults)
- [Consistency](#consistency)
- [Breaking changes & obsoletion](#breaking-changes--obsoletion)
- [Generics](#generics)
- [Ref-assembly hygiene](#ref-assembly-hygiene)
- [Type & assembly placement](#type--assembly-placement)
- [Relationships between types](#relationships-between-types)

## Should this exist (need + audience)

- **Add public API only when the proposed shape addresses a demonstrated real caller scenario not already covered by an existing mechanism.** *Trigger:* a proposed extension point, option, binder, constant, or convenience member justified mainly by "someone might want this." *Decision:* decline or defer; point to the existing mechanism, workaround, or documentation path. *Why:* every public member becomes permanent surface area, so speculative extensibility is not enough when callers can already compose the behavior. *E.g.* a WebSocket extension point was deferred because little external interest had been shown. *Scope:* common (legitimate exception when the feature fixes a framework bug or has concrete external consumers).
- **Preserve established defaults and idioms unless the proposed behavior change solves a common enough problem to justify inconsistency.** *Trigger:* a proposed API changes a default response, routing idiom, serialization behavior, or framework pattern callers already rely on. *Decision:* keep the existing idiom, or add an opt-in/configuration path instead of changing the default. *Why:* defaults teach the framework's mental model; changing them for niche scenarios makes common code less predictable and can break applications. *E.g.* a request to prefer route constraints that produce not-found responses was declined because the established bad-request behavior remained the better default for most apps. *Scope:* common (legitimate exception when a major-version change, security fix, or broad ecosystem shift justifies the migration).
- **Do not expose a convenience API whose easiest use encourages unsafe, unsupported, or protocol-hostile behavior.** *Trigger:* a proposed shortcut silences security failures, exposes protocol data the spec says clients should not read, or normalizes a discouraged storage/authentication pattern. *Decision:* decline the shortcut; document the safe alternative, require explicit opt-in, or expose a lifecycle/event hook for advanced users. *Why:* convenience shapes become recommendations; dangerous operations should be deliberate and auditable, not the path of least resistance. *E.g.* a cookie-based refresh-token convenience was declined because the recommended alternative was to store the data as a user claim or use documented token flows. *Scope:* near-universal.
- **Keep broad, high-traffic receiver types and curated public lists limited to common, general-purpose members.** *Trigger:* a feature-specific helper is proposed on `HttpContext`, a top-level app object, a broad builder, or a shared constants list. *Decision:* avoid adding it there; prefer composition, feature-scoped extensions, metadata, or a narrower receiver. *Why:* IntelliSense is part of the API experience; piling specialized helpers onto universal types hides the common path and makes unrelated features feel coupled. *E.g.* adding more routing-style members directly to the top-level application object was pushed toward composition because top-level IntelliSense would become noisy. *Scope:* common (legitimate exception when the member is cross-cutting and frequently used by ordinary app developers).
- **Keep independently-owned concerns separate.** *Trigger:* a proposal fuses separately configurable components, adds one feature's customization to another feature's options, or combines independent registration calls. *Decision:* keep the APIs separate or place configuration under the owning feature's builder/options/events model. *Why:* independent concerns need independent lifetime, ordering, defaults, and ownership; fused APIs obscure what is configured and make future evolution harder. *E.g.* authentication-specific storage customization was rejected on session options because browser sessions and authentication tickets are separate concerns. *Scope:* near-universal.
- **Prioritize the end-application happy path; require concrete consumer evidence for library extensibility, advanced domains, and dangerous admin operations.** *Trigger:* a proposal serves library authors, advanced protocol/domain users, or administrators rather than the common app-developer path. *Decision:* scope first-class APIs to common cases; direct advanced scenarios to libraries or general mechanisms, and gate dangerous legitimate operations with explicit capability flags and documentation. *Why:* ASP.NET Core APIs should make the common app path simple without turning every possible extension point or admin escape hatch into prominent public surface. *E.g.* a domain-specific authentication API was scoped to common Identity scenarios while advanced OAuth/OIDC needs were left to specialized libraries. *Scope:* common (legitimate exception when a library-author or admin scenario has concrete consumers and cannot be built safely from existing primitives).

## Naming

- **Name a member for the observable effect it produces, not for an intent/goal or an implementation detail.** *Trigger:* a proposed name whose words denote a desired outcome or a capability being switched on (`Enable…`/`Use…`/`Allow…` a goal), a product/branding term, or how the feature is built internally, rather than what the member concretely does. *Decision:* rename so the words denote the concrete operation performed or artifact produced, scoped to what it acts on. *Why:* the signature is the caller's whole contract: an intent/goal name *underdetermines* behavior (many different mechanisms could achieve the same goal, so the caller can't predict which one this is or what its side effects are), and an implementation/branding name leaks internals the caller doesn't reason in and that may change; an effect name is both predictable (the caller knows what will happen) and discoverable (they can find it from the outcome they want). *E.g.* a flag named for the capability it enables → name it for the action it takes. *Scope:* near-universal.
- **Use `Create*` for factory-shaped members whose output is a newly produced object, and treat `static` as a strong factory signal rather than a requirement for every `Create*`.** *Trigger:* a proposed static member, or a member on a factory/builder/activator type, whose primary output is a newly constructed object or binding artifact. *Decision:* use `Create`/`CreateX` when the member produces the object rather than finding, configuring, or parsing an existing one. *Why:* callers read `Create` as allocation/production; using it only for creation keeps factory members distinct from lookup, mutation, and configuration verbs, while instance `Create*` remains appropriate on types whose role is already factory-like. *E.g.* `Default()` → `CreateDefault()` for a static default-instance factory. *Scope:* common (legitimate exception when an established family uses `Build`, `Parse`, or a more precise domain verb).
- **Use `Try*` only when the output channel explicitly models failure without throwing.** *Trigger:* a proposed lookup, parse, match, validation, or conditional operation that may not produce the requested result. *Decision:* name it `TryX` and return `bool` with an `out` result for synchronous value lookups, or a Boolean/Boolean task for status-only or async attempts. *Why:* `Try` tells callers to branch on success and look for a fallback result path; without a Boolean success channel, the name overpromises the standard .NET try-pattern contract. *E.g.* `GetToken(out accessToken)` → `TryGetToken(out AccessToken? accessToken)`. *Scope:* common (legitimate exception when the operation is conditional mutation, validation, or async status with no separate result value).
- **Use `Set*` for assigning or replacing the value at a key/name slot, not for adding a new entry.** *Trigger:* a proposed member writes a value identified by a key, name, option slot, header, or parameter and a later call for the same identifier should overwrite the previous value. *Decision:* use `Set`/`SetX`; reserve `Add`/`TryAdd` for append-or-first-insert semantics. *Why:* keyed writes differ by duplicate behavior, and the verb is the caller's clue whether existing data is replaced, preserved, or causes failure. *E.g.* `AddString(key, value)` → `SetString(key, value)`. *Scope:* common (legitimate exception when the target collection allows multiple values or the operation appends).
- **Include the targeted feature or server in an extension helper name when the receiver is a shared builder.** *Trigger:* an extension method hangs off a broad receiver such as an application, host, endpoint, or service builder but configures one narrower server, transport, middleware, or feature. *Decision:* include the target noun in the method name. *Why:* shared builders collect many unrelated helpers; the name must carry enough scope for IntelliSense and code review without requiring callers to inspect the extension class or implementation. *E.g.* `UseHttpsConfiguration` → `UseKestrelHttpsConfiguration`. *Scope:* common (legitimate exception when the operation truly applies to the whole receiver or the established pipeline term is already unambiguous).
- **Name endpoint-mapping helpers after the complete endpoint surface they add, not just the resource noun.** *Trigger:* a proposed `Map*` helper registers a feature's route set or API surface rather than a single endpoint/resource. *Decision:* include the surface kind, such as `Api`, when the output is a grouped API surface. *Why:* `MapX` can imply one route or resource mapping; `MapXApi` tells callers that a full externally callable surface is being added. *E.g.* `MapIdentity` → `MapIdentityApi`. *Scope:* common (legitimate exception when the helper maps one endpoint, hub, pattern, or conventional route).
- **Omit a qualifier when the containing type, namespace, or noun already supplies that meaning.** *Trigger:* a proposed member repeats context already carried by its declaring type or by an adjacent domain noun. *Decision:* remove the repeated word and keep only the distinguishing concept. *Why:* redundant qualifiers make APIs harder to scan and can imply there are alternate variants when there are not; the container is part of the public contract callers see. *E.g.* `RemoveEnumTypePrefix` → `RemoveEnumPrefix`. *Scope:* common (legitimate exception when the repeated term distinguishes real sibling concepts or the member is commonly consumed detached from its container).
- **Name Boolean values for the affirmative state the caller opts into or permits.** *Trigger:* a Boolean property or parameter is proposed as `Disable…`, `Disallow…`, or another negative form where `true` means a feature is unavailable. *Decision:* invert to `Enable…`, `Allow…`, `Is…Enabled`, or another positive state name unless the actual behavior is suppression/disabling. *Why:* positive Booleans make defaults and conditionals readable; negative names create double negatives, but suppression knobs are legitimate when disabling is the observable effect. *E.g.* `DisableSynchronousIO` → `AllowSynchronousIO`. *Scope:* common (legitimate exception when `true` means "suppress/disable an existing default behavior").
- **Reserve danger words such as `Unsafe` or `Dangerous` for APIs where the caller accepts an actual safety, security, or compatibility risk.** *Trigger:* a proposed name includes a danger word even though the behavior is standards-compliant, documentable, and safe under ordinary preconditions. *Decision:* drop the danger word and name the behavior directly. *Why:* danger words are strong guidance, not emphasis; overusing them hides the specific operation and dilutes warnings on APIs that truly bypass protections. *E.g.* `UnsafeEnableRangeProcessing` → `EnableRangeProcessing`. *Scope:* common (legitimate exception when using the API can violate safety, security, protocol, or compatibility expectations).
- **Match casing precedent in the nearest existing API family when a term has conflicting global precedent.** *Trigger:* a proposed name uses a term whose casing is inconsistent across existing APIs. *Decision:* choose the casing used by the closest namespace/type family, not an unrelated global example. *Why:* local consistency improves search and discoverability, while a global rule is unreliable when existing APIs already disagree. *E.g.* an authentication no-operation type named `NoOp…` → `Noop…` to align with `NoopClaimsTransformation`. *Scope:* common (legitimate exception when the adjacent feature family has already standardized the alternate casing).
- **When a property exposes a dependency or service whose role is exactly its type, name the property after that type.** *Trigger:* a proposed property returns a service-like object and the property has no narrower semantic role than the type itself. *Decision:* use the exposed type's simple name. *Why:* roleless aliases make callers learn two names for the same concept and reduce discoverability by type name. *E.g.* `Clock` → `TimeProvider`. *Scope:* common (legitimate exception when multiple properties of the same type need role names, or the property exposes a narrower role than the type).
- **Use `Arguments` for runtime values supplied to an invocation and `Parameters` for declarations, binding metadata, or configurable parameter sets.** *Trigger:* a proposed name describes values passed into a handler/function/component, or describes the formal inputs the callable declares or accepts. *Decision:* choose `Arguments` for actual supplied values and `Parameters` for declarations/configuration. *Why:* callers need to distinguish call-site data from the callable's formal shape; conflating the terms obscures whether the API exposes values or metadata. *E.g.* `EndpointFilterInvocationContext.Parameters` → `EndpointFilterInvocationContext.Arguments`. *Scope:* common (legitimate exception when a protocol or framework concept is itself named "parameters," such as query parameters).
- **Do not name a member as a richer domain type unless its return type has that richer shape.** *Trigger:* a proposed property name ends with a domain type word such as `Pattern`, `Descriptor`, or `Options` but returns a primitive or simpler representation. *Decision:* name the simple artifact directly, and reserve the richer noun for members returning that richer type. *Why:* names imply available operations and invariants; a `string` named like a parsed model makes callers expect structure that is not present. *E.g.* `RoutePattern` returning `string` → `Route`. *Scope:* near-universal.

## Namespaces & placement

- **Place a niche or less-common type in a feature sub-namespace rather than the root or a default global-using namespace; keep a concrete implementation in its interface's namespace.** *Trigger:* a proposed type serves one feature and isn't something most callers in the area touch. *Decision:* nest it under a feature-specific sub-namespace, and co-locate an implementation with the contract it implements. *Why:* the root/global-using namespace is the caller's default surface, so crowding it with specialized types hides the common ones; co-locating implementation with contract keeps discovery and layering predictable. *E.g.* a specialized typed result → a dedicated `*.HttpResults` namespace rather than the root HTTP namespace. *Scope:* common (legitimate exception when the type is genuinely a core concept of the area).
- **Put infrastructure types that callers shouldn't invoke in an `*.Infrastructure` namespace.** *Trigger:* a proposed public type exists only to be called by the framework or source generators, not by application code. *Decision:* place it in a clearly-named `*.Infrastructure` namespace. *Why:* the namespace signals "not for direct use" so the type doesn't crowd ordinary IntelliSense and callers don't build on a contract that may shift. *E.g.* a source-generator-only helper → a feature `*.Infrastructure` namespace. *Scope:* common.
- **Group related data-transfer types in a dedicated data namespace and seal them.** *Trigger:* a feature ships several plain data/DTO types consumed together. *Decision:* group them under a feature `*.Data` namespace and seal them unless subclassing is part of the contract. *Why:* grouping makes the data shape discoverable as a set, and sealing keeps inert payloads from acquiring an unintended inheritance contract. *E.g.* identity request/response DTOs → an Identity `*.Data` namespace. *Scope:* common.
- **Add a registration to an existing well-known extension class instead of a new single-purpose one, and keep one canonical type rather than duplicating a concept across namespaces.** *Trigger:* a proposed `Add*`/`Use*`/`Map*` extension would create a new holder class, or an attribute/type would duplicate an existing concept in another namespace. *Decision:* extend the established extension class for that receiver, and keep a single canonical type. *Why:* a proliferation of single-purpose extension classes and duplicate types fragments discovery and creates call-site ambiguity. *E.g.* a new service registration → an existing `*ServiceCollectionExtensions` class rather than a new one. *Scope:* common (legitimate exception when the receiver/feature genuinely has no existing home).

## Method families & type suffixes

- **Make `Add*` an input-to-registration method whose receiver and return keep service registration fluent.** *Trigger:* a proposed public `Add*` API for registering services or enabling a feature. *Decision:* define it as an `IServiceCollection` extension in `Microsoft.Extensions.DependencyInjection`, register the feature, and return `IServiceCollection` or a feature builder. *Why:* callers recognize `Add*` as dependency-injection setup; changing the receiver, namespace, or output breaks discovery and chaining expectations. *E.g.* `AddAntiforgery`. *Scope:* near-universal.
- **Make `Configure*` an options-registration method that accepts an options delegate.** *Trigger:* a proposed public `Configure*` API for modifying feature configuration. *Decision:* define it as an `IServiceCollection` extension in `Microsoft.Extensions.DependencyInjection`, take `Action<TOptions>`, and return `IServiceCollection`. *Why:* `Configure*` names describe configuring stored options, not performing work or mutating pipelines directly. *E.g.* `ConfigureHttpJsonOptions`. *Scope:* near-universal.
- **Make `Use*` a middleware-insertion method over the application pipeline.** *Trigger:* a proposed public `Use*` API that activates request-processing behavior. *Decision:* define it as an `IApplicationBuilder` extension in `Microsoft.AspNetCore.Builder`, insert middleware, and return `IApplicationBuilder`. *Why:* `Use*` communicates ordered pipeline mutation; a different receiver or result obscures where the API participates. *E.g.* `UseAntiforgery`. *Scope:* near-universal.
- **Make `Map*` an endpoint-registration method over endpoint routing.** *Trigger:* a proposed public `Map*` API that creates routes or endpoints. *Decision:* define it as an `IEndpointRouteBuilder` extension in `Microsoft.AspNetCore.Builder` and return an endpoint convention builder. *Why:* `Map*` APIs create endpoint entries and hand back a fluent object for endpoint conventions. *E.g.* `MapGet`. *Scope:* near-universal.
- **Make `With*` a fluent convention or metadata attachment that preserves the builder chain.** *Trigger:* a proposed public `With*` API on an endpoint convention builder or similar fluent builder. *Decision:* attach metadata or conventions and return the same builder type. *Why:* `With*` reads as additive decoration of an already-created object, not construction of a separate feature. *E.g.* `WithMetadata`. *Scope:* near-universal.
- **Reserve `Build*` for terminal materialization from accumulated builder state.** *Trigger:* a proposed public `Build*` method on a builder-like type. *Decision:* make it finalize accumulated configuration and return the completed product. *Why:* `Build*` signals the end of fluent setup and the beginning of a usable object. *E.g.* `WebApplicationBuilder.Build`. *Scope:* near-universal.
- **Name extension-method holder types as static `*Extensions` classes.** *Trigger:* a proposed public type whose purpose is grouping extension methods. *Decision:* make the holder static and suffix it with `Extensions`. *Why:* this separates callable extension surfaces from instantiable service or model types and makes API discovery predictable. *E.g.* `AntiforgeryServiceCollectionExtensions`. *Scope:* near-universal.
- **Use a type suffix only when the type has the shape promised by that suffix.** *Trigger:* a proposed public type named with `Options`, `Context`, `Feature`, `Provider`, `Result`, `Factory`, `Builder`, `Metadata`, `Handler`, `Attribute`, `EventArgs`, `Exception`, or `Defaults`. *Decision:* align the type shape with the suffix: settable configuration, callback state, collection capability, pluggable provider, operation outcome, creator, fluent accumulator, inspectable descriptor, executable handler, annotation, event payload, failure type, or constants holder. *Why:* suffixes are part of the caller's mental type system; mismatched shapes make APIs harder to discover and compose. *E.g.* `JwtBearerOptions`. *Scope:* near-universal.

## Async & cancellation

- **Async members return the task abstraction that matches their completion profile.** *Trigger:* a proposed asynchronous method returning a task-like result. *Decision:* return `Task` by default; return `ValueTask` only when the path is hot, allocation-sensitive, and commonly completes synchronously. *Why:* `Task` is simpler and more broadly composable; `ValueTask` adds consumption constraints that only pay off on measured hot paths. *E.g.* `PublishAsync(...) -> ValueTask` → `IHealthCheckPublisher.PublishAsync(...) -> Task`. *Scope:* common (legitimate exception when synchronous completion is frequent and justified).
- **Caller-supplied cancellation is an optional final input unless the token is supplied by an existing contract or context.** *Trigger:* a proposed async or blocking operation that may wait on I/O, scheduling, streaming, or external work. *Decision:* add `CancellationToken cancellationToken = default` as the last ordinary parameter, pass it through, and document what cancellation stops or does not undo. *Why:* callers can opt into cancellation without overload churn, and final placement keeps overloads predictable. *E.g.* `ReadFormAsync(request)` → `ReadFormAsync(request, cancellationToken = default)`. *Scope:* common (legitimate exception when implementing an existing signature, a trailing `params` array is required, or cancellation already flows through a context object).
- **Async operation names end in `Async` unless an established framework contract fixes the name.** *Trigger:* a proposed member whose return type is `Task`, `Task<T>`, `ValueTask`, or `ValueTask<T>`. *Decision:* suffix the operation name with `Async`; do not drop the suffix just because a paired delegate or property uses an `On...` name. *Why:* the suffix is the caller-visible signal that awaiting, exception timing, and cancellation behavior differ from synchronous calls. *E.g.* `CheckHealth(...) -> Task<HealthReport>` → `CheckHealthAsync(...) -> Task<HealthReport>`. *Scope:* near-universal (legitimate exception when overriding or implementing a pre-existing lifecycle/delegate contract).

## Nullability

- **Required collaborators are non-null inputs.** *Trigger:* a constructor, factory, or registration API needs a service, handler, provider, logger, manager, or options object to function. *Decision:* declare the collaborator non-nullable and fail fast rather than accepting `null` and silently degrading. *Why:* nullable collaborators make misconfiguration look supported and move failures away from the call site. *E.g.* `SignInManager(UserManager? userManager, ...)` → `SignInManager(UserManager userManager, ...)`. *Scope:* common (legitimate exception when absence selects a documented fallback).
- **Nullable outputs represent an observable absent/unset state, not defensive uncertainty.** *Trigger:* a returned value or get/init property can be absent in normal use and callers must distinguish absence from a concrete value. *Decision:* mark that output nullable; keep required results non-nullable. *Why:* nullability is part of the contract and should encode state, not implementation caution. *E.g.* `ContentResult.StatusCode -> int` → `ContentResult.StatusCode -> int?`. *Scope:* near-universal.
- **Input parameters are non-null unless `null` has a named semantic meaning.** *Trigger:* a proposed reference-type parameter is required for lookup, mutation, creation, or formatting. *Decision:* make it non-nullable; use nullable only for omitted optional data, fallback selection, or clearing/resetting state. *Why:* callers should not have to infer whether `null` means "missing", "default", or "bug". *E.g.* `GetFiles(string? name)` → `GetFiles(string name)`. *Scope:* near-universal.

## Return & parameter types

- **Read-only inputs use the least-specific shape that supports the operation.** *Trigger:* a proposed parameter is enumerated or inspected but not stored for mutation. *Decision:* accept `IEnumerable<T>` for read-only sequences; use `ReadOnlySpan<T>` for synchronous buffer inspection and `Memory<T>`/`ReadOnlyMemory<T>` only when async or lifetime requirements need it. *Why:* narrow input contracts admit more caller data structures and avoid implying ownership or mutation. *E.g.* `AddRange(List<RootComponentMapping> items)` → `AddRange(IEnumerable<RootComponentMapping> items)`. *Scope:* common (legitimate exception when indexing, counting, mutation, or lifetime extension is required).
- **Collection outputs expose the minimum caller capability, preferring read-only interfaces.** *Trigger:* a member returns a list-like collection. *Decision:* return `IReadOnlyList<T>` when index access matters, `IReadOnlyCollection<T>`/`ICollection<T>` when only count/enumeration matters, and `IList<T>` only when callers must replace or mutate items. *Why:* return types advertise ownership and mutation rights; over-specific mutable types make later storage changes breaking. *E.g.* `GetMultipleFiles(...) -> List<IBrowserFile>` → `GetMultipleFiles(...) -> IReadOnlyList<IBrowserFile>`. *Scope:* common (legitimate exception when mutation is the feature).
- **Map outputs are read-only dictionaries unless another shape is needed for overload resolution.** *Trigger:* a member returns or accepts keyed metadata, parameters, route values, or extensions. *Decision:* expose maps as `IReadOnlyDictionary<TKey,TValue>`; use `IEnumerable<KeyValuePair<TKey,TValue>>` when a dictionary-shaped overload would create ambiguous or source-breaking overload resolution. *Why:* map mutability should be explicit, and overload shape can be as important as semantic shape. *E.g.* `RazorComponentResult.Parameters -> Dictionary<string,object?>` → `RazorComponentResult.Parameters -> IReadOnlyDictionary<string,object?>`. *Scope:* common (legitimate exception when callers must mutate the map).
- **Multi-field public results use a named result type rather than a tuple.** *Trigger:* a proposed public return value has multiple independently meaningful fields. *Decision:* return a named `readonly struct` or result type with documented members. *Why:* named result types are discoverable, versionable, and documentable; tuples freeze field names into a weak contract. *E.g.* `SetHeadersAndLog(...) -> (range, rangeLength, serveBody)` → `SetHeadersAndLog(...) -> FileRangeDecision`. *Scope:* near-universal.
- **Try-style APIs use `bool` plus `out` only for synchronous single-value probes.** *Trigger:* a proposed `Try*` operation is asynchronous, returns multiple facts, or needs pattern-friendly composition. *Decision:* return a result type, or return `Task/ValueTask<bool>` only when no `out` payload is needed. *Why:* `out` does not compose with async and becomes hard to extend. *E.g.* `GetAsync<T>(key) -> ValueTask<bool>` plus an out value → `GetAsync<T>(key) -> ValueTask<ProtectedBrowserStorageResult<T>>`. *Scope:* common (legitimate exception when the operation is a synchronous parse/lookup with one value).
- **Factories and probes return the stable abstraction when the concrete type is not the contract.** *Trigger:* a factory, binder, provider, or `Try*` member creates an implementation whose concrete type is incidental or less stable than an interface. *Decision:* return the interface or stable base type. *Why:* callers should not depend on implementation details or be forced into suppressions for a concrete type they do not need. *E.g.* `GetBinder(...) -> TryParseModelBinder` → `GetBinder(...) -> IModelBinder?`. *Scope:* common (legitimate exception when concrete members are the intended contract).
- **Large bags of settings become an options object.** *Trigger:* a method accumulates many optional, same-category, or future-extensible parameters. *Decision:* introduce an options type or configuration callback instead of extending the positional list. *Why:* long parameter lists are hard to read, easy to misorder, and source-breaking to evolve when defaults interact. *E.g.* `WithFormOptions(bufferBody, memoryBufferThreshold, ..., multipartBodyLengthLimit)` → `WithFormOptions(FormOptions options)`. *Scope:* common (legitimate exception when the parameters are a small fixed identity tuple).

## Overloads vs optional parameters

- **Existing callable shapes are extended with overloads, not changed defaults.** *Trigger:* a proposed change adds, removes, reorders, or changes the default of an optional parameter on an existing public member. *Decision:* add a new overload that preserves the old call shape. *Why:* optional defaults are baked at compile time and overload resolution is source-sensitive; changing them can break callers without changing binaries. *E.g.* `CheckHealthAsync(predicate = null, cancellationToken = default)` replacing an existing call → keep `CheckHealthAsync(cancellationToken)` and add the predicate overload. *Scope:* near-universal.
- **A cross-cutting setting is configured once, not repeated on every overload.** *Trigger:* the same option would have to appear on many related methods or overloads. *Decision:* put the setting on an options/configuration object or builder property. *Why:* repeated parameters multiply overloads and make global policy inconsistent. *E.g.* passing an antiforgery field name to every operation → configure `AntiforgeryOptions.FormFieldName`. *Scope:* common (legitimate exception when the value is truly per-call data).
- **Configuration callbacks have a non-configuring happy path unless the callback itself is the optional path.** *Trigger:* a registration or mapping method has an `Action<TOptions>` parameter. *Decision:* provide a no-callback overload for default configuration, or make the callback nullable/defaulted only when a single overload is intentionally the happy path. *Why:* callers should not pass no-op lambdas to get defaults, and overload sets should reveal the default scenario. *E.g.* `AddAntiforgery(services, setupAction)` → `AddAntiforgery(services)` plus `AddAntiforgery(services, setupAction)`. *Scope:* common (legitimate exception when `configure = null` is the chosen simple overload).
- **Endpoint mapping overloads cover distinct delegate models when both are first-class.** *Trigger:* a `Map*` API accepts either raw HTTP pipeline delegates or route-handler delegates. *Decision:* provide separate `RequestDelegate` and `Delegate` overloads with return types appropriate to each model. *Why:* the two delegate shapes imply different binding, metadata, and convention behavior. *E.g.* `MapGet(pattern, Delegate handler)` alone → `MapGet(pattern, RequestDelegate requestDelegate)` plus `MapGet(pattern, Delegate handler)`. *Scope:* common (legitimate exception when only one delegate model is supported).
- **Same-shaped delegate overloads are disambiguated by role in the name.** *Trigger:* two overloads would otherwise differ only by delegate type or delegate meaning. *Decision:* add a role suffix such as `Factory`, `Predicate`, or `Handler` to the overload whose delegate produces/configures another component. *Why:* delegate types are visually similar at call sites and lambdas can make overload resolution unclear. *E.g.* `AddEndpointFilter(Func<factory-context,...> filter)` → `AddEndpointFilterFactory(Func<factory-context,...> filterFactory)`. *Scope:* common (legitimate exception when parameter types are unambiguous and names already convey roles).
- **New overloads avoid default values when defaults could steal existing calls.** *Trigger:* a proposed overload has the same leading parameters as an existing overload and only adds optional dictionary, delegate, or configuration parameters. *Decision:* omit defaults on the new overload, or choose a less ambiguous shape. *Why:* a defaultable overload participates in source overload resolution and can redirect existing source calls after recompilation. *E.g.* `Problem(..., IReadOnlyDictionary<string,object?>? extensions = null)` → `Problem(..., IEnumerable<KeyValuePair<string,object?>> extensions)` without making the new argument optional. *Scope:* common (legitimate exception when no existing call shape can be affected).

## Options & builders

- **Represent independently variable configuration as an options object with settable properties, rather than widening call signatures.** *Trigger:* a method needs multiple related callbacks, state values, feature switches, or future per-mode knobs. *Decision:* add an options parameter or configuration overload, and put boolean switches on the options type unless the boolean is intrinsic value data. *Why:* options preserve source compatibility, make new knobs discoverable, and avoid positional boolean ambiguity. *E.g.* `AddProblemDetails(services)` → `AddProblemDetails(services, Action<ProblemDetailsOptions>?)`. *Scope:* common (legitimate exception when the value is immutable data or attribute metadata).
- **Keep data-shaped configuration on the owning options type, and reserve builder methods for composition.** *Trigger:* a proposed builder method only sets an independent setting owned by another subsystem. *Decision:* expose an options-based configuration surface instead of a one-off fluent method. *Why:* options let each subsystem own its contract while builders remain focused on assembling services, endpoints, or middleware. *E.g.* bespoke JSON builder knob → `ConfigureHttpJsonOptions(services, Action<JsonOptions>)`. *Scope:* common (legitimate exception when the operation composes pipeline/order-sensitive behavior).
- **Expose app-wide construction concerns from the root builder when callers configure them during application setup.** *Trigger:* a cross-cutting facility affects many subsystems and is routinely configured at app construction time. *Decision:* make it a first-class builder property rather than requiring discovery of only extension methods. *Why:* root properties communicate that the concern is part of the application model, not an optional add-on hidden in extension search. *E.g.* extension-only logging setup → `WebApplicationBuilder.Logging`. *Scope:* common (legitimate exception when the feature is package-local or niche).

## Extensibility & sealing

- **Seal inert concrete data/configuration types unless subclassing is part of the contract.** *Trigger:* a concrete options, DTO, result, descriptor, or metadata type has no virtual member contract and is passed as data. *Decision:* seal it by default; leave it unsealed or abstract only for a demonstrated subtype scenario. *Why:* unsealed types create permanent inheritance obligations, while sealing can be relaxed later if real extensibility appears. *E.g.* an unsealed inert options type → a sealed data holder when no subclass contract exists. *Scope:* common (legitimate exception when existing compatibility or documented subclassing requires openness).
- **Use an empty interface only when presence alone is the signal; use a membered capability interface when callers need typed behavior.** *Trigger:* a type needs to advertise metadata, permission, or optional protocol support. *Decision:* choose a marker interface for presence-only semantics, and a feature/capability interface when callers must read state or invoke operations. *Why:* markers are cheap and precise for metadata, but capabilities need a typed contract for detection and rejection. *E.g.* `IAllowAnonymous` marker → `IHttpRequestTimeoutFeature` when timeout operations must be callable. *Scope:* near-universal.
- **Prefer interfaces for pure contracts and abstract bases when shared behavior or state is part of the extension model.** *Trigger:* callers implement a new extension point or subclass a framework-provided context/base. *Decision:* use an interface for behavior-only contracts; use an abstract base when default lifecycle, helper methods, protected state, or construction shape must be shared. *Why:* interfaces minimize coupling, while abstract bases encode reusable framework behavior and subclass intent. *E.g.* `IEndpointFilter` contract → `ComponentBase` when lifecycle behavior is inherited. *Scope:* common (legitimate exception when evolving an already-shipped interface requires a compatible default member).
- **Make external extension seams deliberate public contracts, not dependencies on implementation internals.** *Trigger:* code outside the assembly needs to plug into framework behavior. *Decision:* expose a narrow public abstraction or public base type for that seam. *Why:* public seams can be versioned and documented; internal implementation details can change during servicing. *E.g.* an internal startup hook → `IStartupFilter.Configure(...)`. *Scope:* near-universal.

## Language features (modern C#)

The repo targets the latest C# language version, so every modern feature is available, and the choices below are deliberate. Judge a feature by whether it bakes hard-to-evolve synthesized or behavioral members into the binary/reference-assembly contract, **not** by how often it appears: a feature can be scarce simply because it postdates most of the codebase (an adoption curve), which is not a reason to avoid it.

- **Do not ship `record` or `record struct` on the public surface; author a `class` (or `struct`) with explicitly-written members instead.** *Trigger:* a proposed public (shipping-library) type declared as a `record` or `record struct`. *Decision:* declare it as a hand-authored `class`/`struct`; add only the equality, formatting, or deconstruction members it actually commits to. *Why:* `record` synthesizes value-equality, `GetHashCode`/`IEquatable`, `ToString`/`PrintMembers`, `Deconstruct`, a `with`/clone member, and a copy constructor straight into the binary and reference-assembly contract: each one is then frozen, so adding a field later silently changes equality/hashing semantics and none can be removed without a breaking change; an explicitly-authored type lets the designer commit to members one at a time and evolve the type compatibly. *E.g.* feature DTO/context/model types ship as classes (with `required`/`init` members), never as records. *Scope:* near-universal (records remain fine for `internal` types, CLI-tool executables, and template/sample code, where the contract is not a frozen public surface).
- **Prefer `required` and `init`-only auto-properties over additional constructor overloads to express mandatory or immutable-after-construction inputs.** *Trigger:* a new public model/context/options-style type where some inputs are mandatory and/or must not change after construction, and the author is considering extra constructors to enforce that. *Decision:* declare those inputs as `required` and/or `init`-only auto-properties set through an object initializer, rather than multiplying constructor overloads. *Why:* member-level `required`/`init` states the constraint where the member is declared, avoids the combinatorial growth of constructor overloads as the input set expands, and reads naturally at the call site; it is the team's established modern idiom for new immutable/mandatory shapes, so its relative scarcity in older code is recency, not avoidance, and is not a reason to push back. *E.g.* recent model/context types use `required { get; init; }` members instead of forcing initialization through constructors. *Scope:* common (do **not** apply `init` to options/builder properties that must stay settable across the configuration or request lifecycle; keep those `get; set;`).
- **Allow `static abstract`/`static virtual` interface members and default interface methods where they serve a concrete purpose; question them only when a class or extension method would do the job.** *Trigger:* a proposed public `static abstract`/`static virtual` interface member, or a default interface method. *Decision:* accept it when it enables a generic-constrained abstraction (a parse/bind/factory contract over `TSelf`) or a non-breaking addition to an already-shipped interface; steer toward an instance interface, base class, or extension method when neither motivation is present. *Why:* both ship dispatch shape or executable behavior into the contract and can be awkward to evolve, so they warrant a specific justification, but they are used where they fit and are not negative space. *E.g.* a generic binding contract exposing `static abstract … BindAsync(HttpContext, ParameterInfo)`. *Scope:* common (prefer a simpler instance/class design when the generic-constraint or interface-evolution reason does not apply).

## Defaults

- **Require explicit opt-in for operations that weaken protection, expose data, or broaden side effects.** *Trigger:* a feature enables risky behavior such as anonymous access, directory browsing, antiforgery bypass, or global policy relaxation. *Decision:* leave it off unless the caller invokes a named API or sets an option. *Why:* safe defaults protect unaware applications; explicit calls create an auditable point of responsibility. *E.g.* directory browsing by default → `UseDirectoryBrowser()`. *Scope:* near-universal.
- **Treat changed defaults as compatibility breaks and pair them with an escape hatch.** *Trigger:* an existing API's default behavior would produce different routing, naming, validation, serialization, or policy results. *Decision:* gate the new behavior by version/compatibility mode or expose an option that restores the previous behavior. *Why:* callers often depend on defaults implicitly, so restoring prior behavior must be possible without redesigning their app. *E.g.* a silent serialization-default change → ship a schema transformer/option that restores the prior format. *Scope:* near-universal.
- **Do not silently install default providers or policies that change framework-wide behavior for apps that did not request them.** *Trigger:* a provider, policy, or feature would affect unrelated endpoints, authentication, authorization, caching, or hosting behavior globally. *Decision:* require an explicit builder call or options assignment for the global default. *Why:* framework-wide defaults are hard to discover and can turn local package adoption into global behavior change. *E.g.* implicit fallback authorization → `AuthorizationBuilder.SetFallbackPolicy(...)`. *Scope:* common (legitimate exception when the default is foundational to the selected app model).

## Consistency

- **Match the shape, receiver, naming, and metadata contracts of sibling APIs in the same feature family.** *Trigger:* a new registration method, endpoint convention, result type, metric, option, or metadata member sits beside established peers. *Decision:* use the same `Add*`, `With*`, attribute/metadata, marker-interface, units, and naming patterns callers already use nearby. *Why:* consistency lets callers transfer knowledge and lets tooling, filters, OpenAPI, and tests reason about new types the same way as existing ones. *E.g.* new typed HTTP results were expected to implement the same result and endpoint-metadata interfaces as existing typed results. *Scope:* near-universal.
- **Endpoint-convention extension methods should preserve the concrete builder type by using a generic `TBuilder` shape.** *Trigger:* an extension method applies endpoint conventions and is intended to work on route handlers, groups, or other endpoint builders. *Decision:* make the receiver and return type generic over `TBuilder` constrained to the endpoint-convention builder contract. *Why:* returning the concrete builder keeps fluent chaining and allows the same method to compose through grouped endpoints instead of only single route handlers. *E.g.* `ProducesProblem<TBuilder>` and similar endpoint-convention methods use the generic builder form so they work through grouping. *Scope:* near-universal.
- **Ship companion variants that callers will infer from adjacent APIs.** *Trigger:* adding an optional lookup, metadata form, overload, or constructor creates an obvious paired required form, attribute form, extension-method form, or constructor equivalent. *Decision:* add the companion shape at the same time unless there is a clear reason not to. *Why:* APIs are discovered by analogy; missing companions create surprise and force callers into inconsistent workarounds. *E.g.* a required endpoint-metadata lookup was added beside the optional metadata lookup. *Scope:* common (legitimate exception when the companion would be ambiguous, unsafe, or unsupported for the same inputs).

## Breaking changes & obsoletion

- **Evolve existing contracts by adding compatible members rather than changing signatures or return types in place.** *Trigger:* an existing virtual method, interface, factory, constructor, or property needs a richer return type or extra context. *Decision:* add a new member, overload, or default/virtual implementation that delegates to the old shape where possible. *Why:* source and binary compatibility are part of the contract; additive evolution lets new callers opt in without breaking existing implementations. *E.g.* server-factory evolution used new creation members rather than changing existing return types directly. *Scope:* near-universal.
- **Do not obsolete a still-valid API merely because a better or safer alternative exists.** *Trigger:* a replacement API is introduced while the old API still works correctly for non-trimming, non-AOT, or existing supported scenarios. *Decision:* leave the old API alone until there is evidence it causes real problems; when obsoleting, use a non-error obsolete attribute until actual removal and name the replacement. *Why:* premature obsoletion creates migration noise for working code; warnings should communicate real risk or scheduled removal. *E.g.* reflection-based overloads were kept non-obsolete when trim-safe alternatives were added because non-trimming callers did not need to change. *Scope:* common (legitimate exception when the old API is unsafe, misleading, or on a planned removal path).
- **Prefer analyzers or soft-conversion paths over binary-breaking contract changes when they can address the misuse.** *Trigger:* fixing a subtle error would require changing an interface, replacing a public type, or forcing all implementers to update. *Decision:* consider an analyzer, default member, adapter, or implicit conversion before making a binary-breaking change. *Why:* diagnostics can guide source changes without invalidating existing binaries, and conversions reduce transition friction when a better type replaces an older one. *E.g.* an analyzer was favored over a breaking interface change for a subtle configuration problem. *Scope:* common (legitimate exception when the old contract cannot represent required behavior safely or correctly).

## Generics

- **Cap generic overload/type families at a pragmatic framework-aligned arity and make type parameters carry observable behavior.** *Trigger:* an API family repeats only to support increasing numbers of generic inputs or result alternatives. *Decision:* stop at the established practical limit, add constraints when misuse can be rejected at compile time, and remove type parameters that do not affect parameters, returns, constraints, or behavior. *Why:* every arity becomes permanent surface area; unused or unconstrained type parameters make APIs harder to understand without improving type safety. *E.g.* an unbounded typed-result union family → `Results<TResult1, ..., TResult6>`. *Scope:* common (legitimate exception when a framework integration model, such as an identity store shape, requires higher arity).

## Ref-assembly hygiene

- **Review the API as reference-assembly-shaped public signatures, including behavior-affecting attributes and trim/AOT annotations.** *Trigger:* a proposal includes components, reflection/object-accepting overloads, generic type parameters, source-generator metadata, or linker-sensitive behavior. *Decision:* present only the public shape, include attributes such as component parameters and dynamic-access annotations, and pair unsafe reflection/object overloads with trim/AOT warnings plus a safe typed alternative. *Why:* reviewers need to see the actual contract callers and tools observe; attributes and annotations often define behavior as much as types and names do. *E.g.* an anonymous-object overload that reflects over properties was approved with trimmer warnings beside a dictionary-based alternative. *Scope:* near-universal.

## Type & assembly placement

- **Classify an assembly by dependency role before using its name as evidence.** *Trigger:* a proposed type targets an assembly whose name suggests implementation, extensions, or abstractions but whose dependency position says otherwise. *Decision:* treat high-incoming, low-outgoing assemblies as contract or primitive sinks even when they are not named `*.Abstractions`. *Why:* placement should minimize coupling for stable primitives; names are less reliable than dependency direction. *E.g.* `Microsoft.AspNetCore.Http.Features`. *Scope:* near-universal.
- **Place durable public contracts in the lowest contract or primitive assembly that consumers can reference without implementation dependencies.** *Trigger:* a proposed interface, abstract contract, metadata contract, or primitive DTO is intended for use by multiple implementations, frameworks, or applications. *Decision:* put it in the area's abstractions or primitive-sink assembly, not in the concrete implementation assembly. *Why:* stable contracts must be referenceable without forcing callers to carry a provider or feature implementation. *E.g.* `IAuthenticationHandler`. *Scope:* common (legitimate exception when the contract is intentionally feature-local).
- **Separate conceptual abstract bases from reusable algorithmic bases by what the base contributes.** *Trigger:* a proposed abstract base either describes a cross-stack concept or supplies reusable implementation machinery. *Decision:* put pure conceptual bases in abstractions and behavior-bearing reusable bases in the area's Core assembly. *Why:* conceptual contracts should stay dependency-light, while reusable algorithms belong in the shared engine layer used by implementations. *E.g.* `AuthenticationHandler<TOptions>`. *Scope:* near-universal.
- **Place framework defaults and behavior-heavy sealed concretes below their contracts.** *Trigger:* a proposed `Default*`, middleware, handler, provider, or sealed concrete supplies executable framework behavior. *Decision:* put it in the implementation, provider, or feature assembly rather than the abstractions assembly. *Why:* concrete behavior usually depends on services and policy choices that contract layers should not pull upward. *E.g.* `DefaultHttpContext`. *Scope:* common (legitimate exception when the sealed type is a contract DTO, context object, immutable result, or metadata payload).
- **Keep implementation dependencies flowing toward contracts, not from contracts to real implementations.** *Trigger:* a proposed abstraction or primitive assembly reference would point to a concrete feature/provider assembly. *Decision:* reject the dependency or move the referenced type into a lower primitive layer. *Why:* reversing the dependency direction makes every contract consumer transitively depend on implementation choices. *E.g.* `Microsoft.AspNetCore.Authentication.JwtBearer` depends on authentication contracts, not the reverse. *Scope:* near-universal.
- **Keep provider families layered as contracts, shared engine, then provider surface.** *Trigger:* a proposed provider family has reusable contracts, common execution machinery, and provider-specific behavior. *Decision:* put contracts/options shared by the family in the abstraction layer, shared bases/services in Core, and provider handler/options/events/registration in the provider assembly. *Why:* the shape lets new providers reuse the engine without moving provider-specific knobs into the contract layer. *E.g.* `JwtBearerHandler`. *Scope:* near-universal.
- **Do not treat namespace and assembly mismatch as a placement smell by itself.** *Trigger:* a proposed type's namespace differs from its containing assembly's name. *Decision:* evaluate dependency role and API discoverability instead of requiring namespace-to-assembly alignment. *Why:* public namespaces are optimized for caller experience, while assemblies are optimized for deployment and layering. *E.g.* `AntiforgeryServiceCollectionExtensions` in `Microsoft.Extensions.DependencyInjection`. *Scope:* near-universal.
- **Put registration and pipeline extensions in the shared discovery namespaces for their receiver type.** *Trigger:* a proposed extension method registers services, configures middleware, or maps endpoints. *Decision:* use `Microsoft.Extensions.DependencyInjection` for service registration and `Microsoft.AspNetCore.Builder` for application or endpoint builder extensions, regardless of the containing feature assembly. *Why:* callers discover these APIs from the receiver type's expected namespace, not from each feature assembly's implementation namespace. *E.g.* `UseAntiforgery`. *Scope:* near-universal.
- **Put feature-specific accessors on broad framework types in the feature namespace.** *Trigger:* a proposed extension method adds feature-specific access to a broad domain type such as `HttpContext`. *Decision:* place the accessor in the feature's namespace unless it represents a core concept of the broad type itself. *Why:* broad namespaces should contain core concepts; feature namespaces prevent optional capabilities from looking intrinsic. *E.g.* `AuthenticationHttpContextExtensions`. *Scope:* common (legitimate exception when the accessor is core HTTP or endpoint state).
- **Ship always-present foundations with the shared framework and optional integrations as packages.** *Trigger:* a proposed assembly contains foundational APIs, optional integrations, or third-party dependency carriers. *Decision:* put always-present framework APIs in the shared framework; ship optional integrations and external-dependency features as standalone packages. *Why:* foundational APIs should be universally available, while optional integrations should not impose extra dependencies on every application. *E.g.* `Microsoft.Extensions.Caching.StackExchangeRedis`. *Scope:* near-universal.

## Relationships between types

- **Name the framework default implementation as `Default<X>` and make it implement or derive from `X`.** *Trigger:* a proposed concrete type is the framework-supplied implementation of a public contract or conceptual base. *Decision:* name it `Default<X>`, derive from or implement `X`, and place it below the contract layer. *Why:* the pair makes the relationship obvious while preserving the contract/implementation split. *E.g.* `DefaultProblemDetailsFactory`. *Scope:* near-universal (legitimate exception when a primitive assembly intentionally ships both the contract and its low-level default).
- **Use the `X : IX` pair when a concrete type is the obvious public implementation of a service contract.** *Trigger:* a proposed concrete is the natural implementation of a same-domain injectable or extensibility interface. *Decision:* prefer matching names rather than unrelated type names. *Why:* the pair reduces lookup cost and makes dependency-injection registrations and extension points legible. *E.g.* `UrlHelperFactory`. *Scope:* common (legitimate exception when the concrete has multiple roles or the interface should not imply a single twin).
- **Join an existing provider inheritance family at the narrowest fitting base.** *Trigger:* a proposed provider has options and a handler in a family with established base options or base handlers. *Decision:* derive options and handlers from the most specific existing family base and keep the handler generic argument aligned with the provider options type. *Why:* shared knobs and shared execution behavior stay centralized while provider-specific behavior remains local. *E.g.* `JwtBearerHandler : AuthenticationHandler<JwtBearerOptions>`. *Scope:* near-universal.
- **Make result types participate in the result ecosystem they execute in and expose truthful metadata through marker interfaces.** *Trigger:* a proposed result type executes in an endpoint, MVC action, or similar result pipeline. *Decision:* implement the execution contract for that ecosystem plus the granular metadata/provider interfaces that accurately describe status, value, content type, file, nesting, or static endpoint metadata. *Why:* pipelines, tooling, and tests rely on known contracts; ad-hoc properties are less discoverable and less composable. *E.g.* `Ok<TValue>`. *Scope:* near-universal (legitimate exception when bridging between distinct result ecosystems is intentional).
- **Keep distinct result ecosystems separate unless the API is explicitly a bridge.** *Trigger:* a proposed result type could participate in both MVC action results and minimal-API HTTP results. *Decision:* choose the contracts for the ecosystem the type actually executes in, and add cross-ecosystem contracts only for deliberate bridging. *Why:* different pipelines interpret result contracts differently; accidental mixing creates unclear execution and metadata semantics. *E.g.* `ActionResult`. *Scope:* common (legitimate exception when the type is designed as an adapter).
- **Make behavioral attributes implement the framework interface that carries their behavior.** *Trigger:* a proposed public attribute affects routing, binding, filtering, authorization, CORS, metrics, API description, or endpoint metadata. *Decision:* implement the existing marker, provider, or factory interface the framework reads; leave purely descriptive annotations as plain attributes. *Why:* behavior should flow through declared contracts rather than hidden reflection conventions. *E.g.* `FromRouteAttribute`. *Scope:* near-universal.
- **Reuse the narrowest existing extension-point interface before introducing a new public contract.** *Trigger:* a proposed public type plugs into routing, binding, filtering, components, results, execution, metadata, or another established pipeline. *Decision:* implement the most specific existing extension-point interface that describes the behavior; add a new interface only when no existing contract fits. *Why:* using established contracts keeps features interoperable with the pipeline and avoids fragmenting the extension model. *E.g.* `IRouteConstraint`. *Scope:* near-universal.
- **Treat companion suffixes as coherence checks rather than mandatory boilerplate.** *Trigger:* a proposed feature exposes companion types such as `Options`, `Events`, `Context`, `Builder`, `Result`, `Metadata`, or `Defaults`. *Decision:* require the companions that the feature shape needs, and flag incoherent gaps such as unconfigurable options, events without state, hidden public constants, or behavioral metadata without an interface. *Why:* companion types should make a feature usable and inspectable, not merely complete a naming checklist. *E.g.* `RemoteAuthenticationContext`. *Scope:* common (legitimate exception when a companion is intentionally internal or the feature shape does not need it).
