ADR-0013 — Namespaces follow features, not roles
- Status: Accepted (2026-09-27), implemented in 2.0.0.
- Deciders: framework maintainers.
- Supersedes: sections 2 and 3 of
docs/package-conventions.mdas they stood in 1.x, and theBsft.Hostingexception of section 4. The conventions now describe this layout.
Context
What was measured
A CodeCharter measurement on 2026-09-26 (graph_cycles, level namespaces, Bsft.sln)
found 74 namespace cycles with 238 of 894 namespaces in one of them. Every cycle sits
inside a single package, and none crosses a project boundary. The first attempt to
remove them (an earlier pull request on Bsft.Auth.ApiKeys) treated the house conventions as
given and fixed the code around them. Looking at the edges type by type showed
that most cycles are not defects in the code. The conventions produce them:
Bsft.Auth.ApiKeys: 6 edges close the cycle. One is a real design issue (ApiKeyService, a facade that dispatches the commands, filed inServices/below the handlers it calls). The other five come from the layout.Bsft.Auth.Idp: 15 namespaces are in the cycle. Ignore the wiring at the root and the contracts thatBsft.Auth.Idp.Abstractionsshares a namespace with, and no cycle is left between implementation types. Idp already cuts by feature (Login,Challenge,Enrollment), and that cut is sound.
So the question is not how to remove 74 cycles. It is whether the assumptions that produce them hold up against the conventions the .NET ecosystem follows.
The assumptions, tested against .NET guidance
| House assumption | .NET guidance and practice | Assessment |
|---|---|---|
One namespace per role folder: .Services, .Commands, .Endpoints, .Stores, .Models, .Handlers (sections 2 and 3). |
The C# guide says to "organize namespaces by feature, not by type kind: place an interface, its implementations, and supporting types together" (Program organization). The Framework Design Guidelines template is <Company>.(<Product>\|<Technology>)[.<Feature>][.<Subnamespace>]. |
Contradicts. A feature such as "rotate a key" is split across four namespaces, so those namespaces necessarily depend on one another in both directions. This is the main source of the cycles. |
The DI entry point (AddBsft<Feature>, MapBsft<Feature>) lives in the package's root namespace, next to the options and contracts (sections 3 and 4). |
For library authors Microsoft advises: "CONSIDER a namespace that clearly identifies your package offering" and "DO NOT use the Microsoft.Extensions.DependencyInjection namespace for non-official Microsoft packages". The examples declare ExampleLibrary.Extensions.DependencyInjection (Options pattern guidance for library authors). Microsoft's own packages keep the root namespace for the API and its options (Microsoft.Extensions.Caching.Memory.MemoryCacheOptions) and put AddMemoryCache in a separate DI namespace. |
Deviates. Our root namespace is both the top layer (wiring) and the bottom layer (options, defaults, contracts). That alone gives every package with feature namespaces a root ↔ feature cycle, with nothing wrong in the code. |
Options/, Internal/, Diagnostics/, Configurations/ and Entities/ are structural folders that add no namespace segment (section 3). |
The namespace matches the folder: IDE0130 ("Namespace does not match folder structure") is on by default (IDE0130). Microsoft's packages keep an options type in the folder of its namespace rather than in an Options/ folder. |
Deviates, and cheap to fix: a file can move to the folder that matches its namespace without changing any namespace. |
An .Abstractions package shares its namespace with the implementation (section 3, 2.0.0). |
This is exactly Microsoft's practice: Microsoft.Extensions.Logging.Abstractions declares Microsoft.Extensions.Logging. |
Conforms. Keep. |
| Every namespace cycle inside one assembly is a defect. | The Framework Design Guidelines treat namespaces as grouping for the consumer, and the assembly is the unit of dependency. Tools such as NDepend report cycles between namespaces, but that is one school of design, not a .NET convention. | Holds only partly. A cycle between features is a real finding. A cycle that comes from role folders or from wiring placed at the root measures the layout, not the design. |
Pilot: Bsft.Auth.ApiKeys laid out by feature
The pilot is only a simulation, not a code change. It maps every one of the 79 types of
src/Bsft.Auth.ApiKeys (as on main, a71bf1d9) to a feature namespace and recomputes the
namespace graph from textual type references. It is precise enough to judge the shape; a
build and a CodeCharter run would confirm it.
Today
src/Bsft.Auth.ApiKeys/
├── ApiKeysServiceCollectionExtensions.cs Bsft.Auth.ApiKeys
├── ApiKeysEndpointRouteBuilderExtensions.cs Bsft.Auth.ApiKeys
├── ApiKeyDefaults.cs Bsft.Auth.ApiKeys
├── Options/ (2) Bsft.Auth.ApiKeys (structural)
├── Internal/ (6) Bsft.Auth.ApiKeys (structural)
├── Abstractions/ (30) Bsft.Auth.ApiKeys.Abstractions
├── Commands/ (13) Bsft.Auth.ApiKeys.Commands
├── Services/ (21) Bsft.Auth.ApiKeys.Services
└── Endpoints/ (4) Bsft.Auth.ApiKeys.Endpoints
The namespaces form 1 cycle over 4 of 5 namespaces: root, .Commands, .Endpoints and
.Services.
By feature
src/Bsft.Auth.ApiKeys/
├── IApiKeyStore.cs, ApiKeyRecord.cs, ApiKeyStatus.cs, Bsft.Auth.ApiKeys
│ ApiKeyScopeKind.cs, ApiKeyActor.cs, ApiKeyView.cs, (the package's vocabulary:
│ IssuedApiKeyView.cs, ApiKeySecrets.cs, ApiKeyLimits.cs, the store seam and the
│ ApiKeyMatch.cs, ApiKeyListRequest.cs, ApiKeyRotation.cs, records it speaks in,
│ ApiKeyUsage.cs, ApiKeyDenialReason.cs, options, defaults)
│ BsftApiKeysOptions.cs, BsftApiKeysOptionsValidator.cs,
│ ApiKeyDefaults.cs
├── Management/ (32) Bsft.Auth.ApiKeys.Management issue, rotate, revoke, rename, list:
│ IApiKeyService + facade, the commands and
│ their handlers, issue policy and authorizer,
│ the HTTP endpoints and their request records
├── Authentication/ (5) Bsft.Auth.ApiKeys.Authentication resolving a presented key on a request
├── Secrets/ (3) Bsft.Auth.ApiKeys.Secrets key format, minting and hashing
├── Scopes/ (4) Bsft.Auth.ApiKeys.Scopes scope catalog and mapping
├── RateLimiting/ (4) Bsft.Auth.ApiKeys.RateLimiting
├── Usage/ (5) Bsft.Auth.ApiKeys.Usage usage recording and flushing
├── Auditing/ (6) Bsft.Auth.ApiKeys.Auditing
└── DependencyInjection/ (3) Bsft.Auth.ApiKeys.DependencyInjection AddBsftApiKeys, MapBsftApiKeys
Every folder is its namespace, and there are no structural folders.
Result
| Today | By feature | |
|---|---|---|
| Namespaces | 5 | 9 |
| Namespace edges | 12 | 21 |
| Namespaces in a cycle | 4 | 0 |
The layering follows from the layout, and no rule has to impose it:
DependencyInjection → Management, Authentication → Secrets, Scopes, Usage,
Auditing, RateLimiting → root. The root is a pure sink.
A first, naive mapping still had a cycle. It kept the store's argument records in their
feature folders and the key format inside Management. Resolving it is what showed the
package's real shared vocabulary: IApiKeyStore speaks in ApiKeyMatch,
ApiKeyListRequest, ApiKeyRotation and ApiKeyUsage, and Management and
Authentication both depend on the key format. So cycles in a feature layout point
at real design questions. Cycles in a role layout do not.
Cost for this package
46 of 48 public types change namespace:
| From | To | Types |
|---|---|---|
.Abstractions |
root | 14 |
.Abstractions |
.Management, .Auditing, .RateLimiting, .Scopes, .Authentication, .Usage |
16 |
.Commands, .Endpoints |
.Management |
8 |
.Services |
.Auditing, .Management, .RateLimiting, .Scopes, .Usage |
6 |
| root | .DependencyInjection |
2 (the host-facing AddBsftApiKeys and MapBsftApiKeys) |
In the repository, 14 files in 6 projects import an ApiKeys namespace
(Bsft.Auth.ApiKeys.EFCore, .Notifications, .Organization, Bsft.Apps.ApiKeys,
Bsft.Auth.DeviceFlow.ApiKeys, Bsft.Sample.WebApi), plus every host that calls
AddBsftApiKeys.
Size across the repository
The table counts the files in each role folder under src/, and how many declare a public
top-level type. For those public types a namespace change is a source break:
| Folder | Files | With a public type |
|---|---|---|
Services/ |
1591 | 319 |
Endpoints/ |
358 | 158 |
Models/ |
224 | 130 |
Commands/, Queries/ |
220 | 97 |
Stores/ |
238 | 76 |
Handlers/ |
73 | 23 |
Internal/ |
577 | 0 |
Options/ |
365 | 228 (namespace unchanged, only the file moves) |
Decisions
Each decision could have been taken on its own. They are listed with the choice that was taken.
D1 — Namespaces follow features. A package's sub-namespaces name what it
does (Management, Authentication, Login), not what kind of type they hold. The
role vocabulary of section 2 (Services/, Commands/, Endpoints/, Stores/,
Models/, Handlers/) is dropped as a namespace layer. A large feature may still sort
its files into sub-folders, and those follow D3. Alternative: keep the role folders and
accept their cycles as a property of the layout. Then the cycle measurement should be
dropped as a quality signal, because it cannot be met.
D2 — Where the DI entry point lives. Three options:
- (a)
<Package>.DependencyInjection, as the Microsoft guidance for library authors shows. The root is then a pure sink. Every host needs one moreusing, which is a breaking change for every consumer of every package. - (b) Keep it in the root namespace, as today. Nothing breaks for consumers. The
wiring edges stay as the one accepted exception to "no cycles", and they are
recognisable by name (
*ServiceCollectionExtensions,*EndpointRouteBuilderExtensions,*ApplicationBuilderExtensions). - (c)
Microsoft.Extensions.DependencyInjection, as many third-party libraries do. The Microsoft guidance says not to.
The proposal was (b) now and (a) at the next major version, so consumers would not
rewrite their using directives for a change they gain nothing from. Because the feature
namespaces themselves ship in a major version (2.0.0) that already asks every consumer to
rewrite using directives, (a) was taken in the same release: one migration instead of
two. The conventions rule bsft-di-entry-point-namespace checks it, and
bsft-single-service-collection-extensions is withdrawn, because its premise — one entry
point per feature namespace — ended when every entry point of a package moved into one
namespace.
D3 — The namespace matches the folder, without exceptions. The structural
folders go: options, validators and defaults sit in the folder of their namespace, which is
usually the root folder. Internal/ goes too: an internal helper lives in the feature that
uses it, and a helper shared across features is a feature of its own (as Secrets is in
the pilot). IDE0130 can then enforce the layout instead of a house rule. Entities/
and Configurations/ in the EfCore packages become ordinary namespaces.
D4 — .Abstractions keeps sharing its namespace with the implementation, as section 3
has said since 2.0.0. Under D1 an .Abstractions package uses the same feature
folders as its implementation, so a contract and its implementation meet in one
namespace. This is what a consumer expects from Microsoft's packages.
D5 — What counts as a finding. Given D1 to D4, a namespace cycle between two
features of one package is a finding. It is reported by graph_cycles and fixed in the
code, as in the pilot. With D2 (a) there is no accepted exception. The rule
bsft-feature-not-on-wiring from that pull request is withdrawn: it enforces the role layering this
ADR replaces.
Consequences
- Sections 2, 3 and 4 of
docs/package-conventions.mdare rewritten. The house rulebsft-namespace-follows-assemblystays, and IDE0130 runs in the build. - The migration was not staged by visibility as first planned. The whole tree moved in one
release, 2.0.0, with its own migration page (
docs/migration-2.0.0.md) and a script driven by the generated type map. - Idp needs little beyond D2 and D3, because it is already cut by feature. The role-folder packages (ApiKeys, Webhooks, Organization, …) carry most of the work.
- That pull request is not merged as it stands. Of its changes, the move of
ApiKeyServicebeside the commands is the only one that survives D1 unchanged (inManagement).
Open questions
- Is the feature cut of the pilot the one the maintainers of ApiKeys would choose? In
particular: should
Managementsplit into its HTTP surface and its commands, and shouldScopesandSecretsstay separate namespaces or merge into the root? - Resolved: the public namespace moves and D2 (a) shipped together in 2.0.0.