Namespace Layout
Note
Status: implemented. An architecture test fails when an assembly's root namespace holds more than ten types, so each assembly's root keeps only its facade.
Table of contents
- Goal and scope
- Ubiquitous language
- What the rule found, and what it changed
- Functionality checklist
- Key design decisions
- Interfaces and responsibilities
- Error and boundary cases
- Testability
- How each assembly was split
Goal and scope
Add Group E — namespace layout to the architecture suite: a rule that fails when an assembly's root namespace holds more than a handful of types. A root namespace should carry the assembly's facade — the entry points a consumer calls — while everything else lives in a sub-namespace that names its role.
The suite already guards dependency direction (Groups A and B) and type shape (Group D). Nothing guards layout, so an assembly can grow into a flat list of thirty types without a single test noticing.
In scope: one rule over the shipped assemblies, and the refactoring it demands of the namespaces it catches.
Out of scope: a global cap on every namespace (D3), and coupling or cohesion metrics.
Ubiquitous language
| Term | Meaning |
|---|---|
| Root namespace | An assembly's own namespace — the one equal to its assembly name, such as DialogueDown.Visualization. |
| Sub-namespace | Any namespace beneath a root, such as DialogueDown.Visualization.Semantics. |
| Authored type | A non-nested type the project wrote, as opposed to one the compiler generated. |
| Facade | The small set of entry points a consumer of an assembly actually calls. |
What the rule found, and what it changed
Measured by reflecting over the built assemblies, counting authored non-nested types per namespace. The core was already the model — one type at the root, everything else named by its stage — and three assemblies were not:
| Assembly | Root types before the rule | Root types | Sub-namespaces added |
|---|---|---|---|
DialogueDown |
1 | 1 | — |
DialogueDown.Playbook |
7 | 7 | — |
DialogueDown.Runtime |
4 | 4 | — |
DialogueDown.Cli |
8 | 8 | — |
DialogueDown.ConfigurationLoader |
11 | 2 | Readers, Toml, Errors |
DialogueDown.Visualization.Live |
31 | 6 | Browsing, Serving, Files, Configuration |
DialogueDown.Visualization |
35 | 6 | Display, Render, Markdown, Script |
DialogueDown.Visualization was the furthest from the model despite looking
organized — see D1.
Functionality checklist
- [x] A rule fails when a root namespace holds more than the cap.
- [x] The failure message names each offending namespace, its count, the cap, and example type names, so the fix is obvious without a debugger.
- [x] The rule covers all seven shipped assemblies (
Architecture.AllAssemblies), including the CLI. - [x] Compiler-generated and nested types never reach the count.
- [x] The three offending assemblies are refactored into sub-namespaces that name their roles, and the rule passes.
Key design decisions
D1 — Count types, not files
A folder is not a namespace. DialogueDown.Visualization keeps display/ and
render/ folders — twenty-one files that look organized — but every one of
them declares the root namespace:
flowchart LR
subgraph Disk["Folders on disk"]
D["display/ — 12 files"]
R["render/ — 9 files"]
end
subgraph Ns["Namespace the compiler sees"]
N["DialogueDown.Visualization<br/>35 types"]
end
D --> N
R --> N
A rule counting files per folder would score this assembly as tidy and miss the worst offender in the repository. Counting types per namespace is the only measure that sees what a consumer sees.
D2 — Count authored types regardless of visibility
The obvious filter — count only public types — makes the rule almost a no-op
here, because the core is deliberately internal. DialogueDown.Script.Ast holds
49 types and none are public; DialogueDown.Visualization.Live holds 31 of
which 7 are. Visibility describes what a consumer may call, not how much a
namespace has to explain, so the count takes every authored type.
Nested types belong to their parent, and compiler-generated types are not the
author's layout, so both are filtered out — the same filtering
CoreTypeSizeTests already applies for its method count.
D3 — Cap root namespaces, not every namespace
A cap on every namespace ranks this repository backwards. Its two largest namespaces are its healthiest:
| Namespace | Types | Reading |
|---|---|---|
DialogueDown.Script.Ast |
49 | A node vocabulary — every AST type belongs at one level. Splitting it would invent categories the domain does not have. |
DialogueDown.Visualization |
35 | A layer that never got named parts. |
Both exceed any plausible cap, but only one is a problem. A global rule would have to exempt the first, and the exemption list would end up holding the largest namespaces while the rule policed only the small ones.
Keying on the root namespace separates them precisely. A root namespace is
where types land when nobody decided where they belong, so a crowded one is
evidence of a decision not taken. A deep namespace like Script.Ast is the
opposite: someone chose that name, and its size is cohesion rather than drift.
This also matches what an outside reader needs. The root namespace is the first
thing using DialogueDown.Visualization; shows them, and thirty-five unrelated
types is a poor first sentence.
D4 — Set the cap in the gap the measurements left
The cap is 10, chosen from where the assemblies actually sit rather than from taste. The largest healthy root namespace held 8 types; the smallest crowded one held 11. A cap of 10 falls in that gap, so it separates the two groups with headroom on both sides: the healthy CLI keeps room to grow by two, and every assembly that needed splitting is still caught.
A tighter cap would have left DialogueDown.Cli no headroom, so the next type
added to it would fail the build for no design reason. A looser one would have
let the configuration loader's eleven types through and made the rule agree with
the layout it exists to question.
D5 — No exemption list
D3 removes the need for one: the false positive a global cap would produce cannot occur for a root namespace. Adding an exemption hook now would invite the rule to be silenced rather than satisfied. If a genuine case ever appears, it is worth a design conversation and a note update — not a config entry added in passing.
D6 — Anchor the CLI by name, not by a type
Every other assembly is anchored with typeof(SomeType).Assembly, which the
compiler checks. The CLI cannot be: it exposes no public type — even its
entry point is the internal Program that top-level statements generate — and
it shares its internals only with DialogueDown.Cli.Tests.
The rule therefore takes a project reference for the build output and loads the
assembly by name. The alternative, widening InternalsVisibleTo to the
architecture suite, would grant it every internal type of the CLI to obtain one
piece of information the assembly's name already carries.
Interfaces and responsibilities
| Type | Responsibility | Collaborators |
|---|---|---|
NamespaceLayoutTests |
Group E. Asserts each assembly's root namespace stays within the cap and reports every offender in one failure. | Architecture |
Architecture |
(Existing.) Supplies the assemblies each rule scans. Gains the CLI assembly and an AllAssemblies collection so a rule can sweep them uniformly. |
— |
The rule is plain reflection and LINQ, like CoreTypeSizeTests. NetArchTest
cannot express it: its ICustomRule sees one type at a time with no view of a
type's siblings, and its slice conditions only check dependencies between
slices, never a slice's size. No .NET or Java architecture library ships a
namespace-population rule.
Error and boundary cases
| Case | Behavior |
|---|---|
| An assembly whose types all sit in sub-namespaces | Root count is 0 — passes. |
A namespace outside the assembly's own tree, such as the Microsoft.Extensions.DependencyInjection namespace the core uses for its service-collection extension |
Not a root namespace, so never counted. |
| Types the compiler generates (closures, iterators, records' backing types) | Filtered out. |
| Nested types | Attributed to their parent, not counted separately. |
| Several offenders at once | All reported in one failure message, ordered by size, so one run shows the whole job. |
Testability
The rule is itself a test, so the question is whether it can fail for the right reason. It was verified by running it against the tree before any refactoring, where it named exactly the three assemblies in What the rule found, and again after each refactoring step, where the fixed assembly dropped out of the message.
How each assembly was split
Each root namespace kept its facade and gave the rest a name.
| Assembly | Root keeps | Sub-namespaces |
|---|---|---|
DialogueDown.Visualization |
The facade (CompilationVisualizer, ReportProject, ConfigStatusOverlay) and the projection seam (INodeProjection, GraphWalk, NodeProjectionExtensions) |
Display, Render, Markdown, Script — the folders that already existed, now carrying namespaces |
DialogueDown.Visualization.Live |
The visualize command's entry points (IVisualizeRunner, VisualizeRunner, StaticMode, EmitMode) and the browser seam every mode uses (IBrowserLauncher, BrowserLauncher) |
Browsing (the browsable root and its listings), Serving (the served shell, its server, and the live session), Files (atomic writes, symlinks, watching), Configuration (creating a dialogue.toml) |
DialogueDown.ConfigurationLoader |
TomlConfigurationLoader and the ConfigurationSourceLocation a caller reads off an error |
Readers, Toml, Errors |
Browsing and Serving use the vocabulary the served shell settled on when the
launcher page was retired: there is one server, ServedShellServer, and
BrowseRoot is the boundary that confines every path to the browsable root.
Two choices are worth naming. DialogueDown.Visualization needed four
sub-namespaces rather than the two its folders suggested, because markdown/
and script/ were flat in the same way display/ and render/ were. And
DialogueConfigurationException moved to .Errors, following the convention
Group C already enforces on the core: the thrown hierarchy lives together, while
data describing a failure — ConfigurationSourceLocation — does not have to.
Namespace moves are source-compatible within the solution but change the public surface of the visualization assemblies and the configuration loader, so they are a breaking change for any outside consumer, recorded as such in the changelog.