Table of Contents

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

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.