↑↓ to navigate Enter to open "…" all these words ANDOR to combine

Architecture Decision Record

ADR-109: Feature-by-Folder Layout as an Enforced Convention

Status

Accepted (2026-09-03; folder inventory and IDE0130 exception list refreshed 2026-09-19). Revised 2026-10-01: citations re-anchored. Revised 2026-10-06: module-name consumers moved to the per-engine data-source engines, PostgreSQL added. Revised 2026-10-07: Decision 8 narrowed to the sub-folder names that actually shift the derived module, the stale exemption-prose trade-off dropped, anchors refreshed after the v1.233.0 release.

Context

The rubric's section 5 asks that "code is organized by feature/capability, so a change touches one cohesive slice", with features "grouped by use case ..., not by horizontal technical folders" (Website/docs-src/governance/ArchitectureEvaluationCriteria.md:211, :214). ADR-015 already gates the contents of a slice: SliceCohesionTestsBase (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Cqrs/SliceCohesionTestsBase.cs) fails a build when a handler is stranded from its command or query contract.

Nothing gated the shape of the tree until 2026-09-02, when a folder-width fitness function and a repo-wide reorganization shipped together (MMCA.Common/CHANGELOG.md:2009-2012, :2042-2046). That reorganization, and a second pass the next day (:1950-1956), were both marked Breaking, because in this workspace namespaces follow folders: moving a file renames a namespace, and a namespace is public API on a package family released in lockstep (ADR-016). Two consecutive MMCA.Common releases (v1.183.0 and v1.184.0) were therefore layout releases, and they are what forced UPGRADING.md into existence (MMCA.Common/CHANGELOG.md:1997-1998).

The convention itself is written down only in the workspace CLAUDE.md and in the rubric text above. The bill it produces (public namespace renames, a scripted consumer sweep, an UPGRADING protocol, and doc citations that rot underneath it) is recorded nowhere as a decision. This record states it.

Decision

Folder structure is a governed architectural property, not a matter of taste: an aggregate or feature names a folder, a folder holds at most twelve direct code files, namespaces follow folders, and a layout change ships as a breaking release with a migration map.

  1. The aggregate names the first folder level in Domain, Application and Shared. MMCA.ADC's Conference module carries Activities, Categories, Events, Partners, Questions, SessionAssets, Sessions, Speakers and Sponsors directly under Source/Modules/Conference/MMCA.ADC.Conference.Domain/, and all nine names recur under .Application/ and .Shared/. The match is not exact in the other direction: .Application/ also carries Common and Users, and .Shared/ also carries Rooms and Authorization, none of which has a Domain folder, so the rule is that an aggregate names a folder in every layer that has code for it, not that the three layers hold identical folder sets. MMCA.Helpdesk's single-module seed shows the minimal case: one Tickets folder under Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Domain/, .Application/ and .Shared/.

  2. In UI, API and Infrastructure the first level is a technical root and the aggregate sits beneath it. The roots in use are Pages/, Services/ and Components/ (MMCA.ADC.Conference.UI/), Controllers/ (MMCA.ADC.Conference.API/) and Persistence/ (MMCA.ADC.Conference.Infrastructure/), with the aggregate folders one level down: UI/Pages/Speakers, UI/Services/Speakers, API/Controllers/Speakers, Infrastructure/Persistence/EntityConfiguration/Speakers. Infrastructure is a documented partial exception: beside Persistence/, its first level also carries aggregate names for external adapters, Events/Sessionize/ and Sessions/Scoring/. MMCA.ADC's own CLAUDE.md states that shape as the rule for the repo, "external adapters sitting under their aggregate" (MMCA.ADC/AGENTS.md:100, which MMCA.ADC/CLAUDE.md imports).

  3. The aggregate carries the same plural name in every project of the module. Speakers appears in the Conference module's Domain, Application, Shared, UI/Pages, UI/Services, API/Controllers and Infrastructure/Persistence/EntityConfiguration trees, so a reader who knows the aggregate knows the path in every layer.

  4. No folder holds more than twelve direct code files. FolderWidthTestsBase.MaxDirectFiles defaults to 12 (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Governance/FolderWidthTestsBase.cs:23, test method at :31-33) and the rule body is ArchitectureRules.FoldersStayNarrow(repoRoot, maxDirectFiles, exemptFolderSuffixes) (Rules/Governance/ArchitectureRules.FolderWidth.cs:34), whose failure message tells the author to "split it by feature or aggregate" (:69-70). It walks the filesystem rather than IL, "because the defect is a layout one" (:9-11), over both the Source/ and Tests/ trees (:42).

  5. The counting rules are part of the decision. Only files directly in a folder count (:91, :98). A .razor component counts once and its co-located X.razor.cs counts with it (:105-120, the pairing at :117-119); a .resx never counts, since only .razor and .cs files are code units (:107-112); generated files (*.g.cs, *.generated.cs, *.Designer.cs) never count (:123-126). Whole trees whose shape nobody chose are skipped by path segment: bin, obj, Migrations, Platforms, Resources, node_modules, wwwroot and .git (:78-82).

  6. All four repos run the gate, and every exemption is code. FolderWidthTests subclasses the base in MMCA.Common (Tests/Architecture/MMCA.Common.Architecture.Tests/Governance/FolderWidthTests.cs:16), MMCA.ADC (Tests/Architecture/MMCA.ADC.Architecture.Tests/Governance/FolderWidthTests.cs:10), MMCA.Store (Tests/Architecture/MMCA.Store.Architecture.Tests/Governance/FolderWidthTests.cs:9) and MMCA.Helpdesk (Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/FolderWidthTests.cs:8), each supplying only its RepoRoot. Only MMCA.Common overrides ExemptFolderSuffixes, with three one-concept folders: MMCA.Common.Application/UseCases/Decorators, its test twin, and MMCA.Common.Domain/Interfaces (FolderWidthTests.cs:26-31). ADC, Store and Helpdesk run against the empty default (FolderWidthTestsBase.cs:29). The base is public API and is baselined under the ADR-015 gate (Testing.Architecture/PublicAPI.Shipped.txt:127-129, :363, :584-585).

  7. Namespaces follow folders, so a folder move is a compile error until every namespace moves with it. The shared analyzer baseline sets dotnet_style_namespace_match_folder = true:warning (MMCA.Common/.editorconfig:90) and TreatWarningsAsErrors is on repo-wide (MMCA.Common/Directory.Build.props:7), so IDE0130 fails the build. The exceptions are named per path: six in MMCA.Common (.editorconfig:860-868 for the reasons), three of them the flat public surfaces consumers subclass (Testing.Architecture/Bases/ at :869, Testing.Architecture/Rules/ at :872, Testing.UI/Infrastructure/ at :875), two of them Aspire extension folders whose methods stay in the MMCA.Common.Aspire namespace a host already has a using for (:878, :881), and the sixth a test fake that carries a consumer-shaped namespace because the convention under test keys on namespace shape (Tests/Presentation/MMCA.Common.API.Tests/Fakes/ at :884), and one each in ADC and Store for the MAUI head's per-TFM Platforms/ bootstrap files (MMCA.ADC/.editorconfig:848-857, MMCA.Store/.editorconfig:837-846).

  8. A folder inside a module project is never named Domain, Application, Infrastructure, API or UI. ModuleNameConventions.GetModuleName derives the owning module from the namespace, taking the segment before the first layer segment (MMCA.Common/Source/Core/MMCA.Common.Shared/Conventions/ModuleNameConventions.cs:38-51): Domain matches at any index from 1 up (:41-43) and the other four match only at index 3 or later (:17, :48-50). A Domain sub-folder therefore shifts the derived module in any project other than Domain, because Domain wins wherever it appears (:41-45). The other four take the first match, so under a project whose own layer segment already sits at index 3 (for example MMCA.ADC.Conference.Application) they still yield Conference, and they shift the module only in a project whose layer is not in that list, such as Shared (:17, :48-50). That name is load-bearing: it is the SQL Server schema (Infrastructure/Persistence/DataSources/Engines/SQLServerDataSourceEngine.cs:101), the PostgreSQL schema (PostgreSQLDataSourceEngine.cs:100), the Cosmos container (CosmosDataSourceEngine.cs:95), each engine reached from EntityTypeConfiguration.cs:77, the logical data-source name for unit-of-work routing (Infrastructure/Persistence/DataSources/EntityDataSourceRegistry.cs:181), all four through the internal Infrastructure/Persistence/NamespaceConventions.cs:20-21 wrapper, and the module tag on every CQRS log line (Application/UseCases/Decorators/LoggingCommandDecorator.cs:95, LoggingQueryDecorator.cs:89). The derivation is locked by Tests/Core/MMCA.Common.Shared.Tests/Conventions/ModuleNameConventionsTests.cs. This half of the convention is enforced by review only: no fitness rule checks folder names.

  9. The move is scripted, not hand-edited. Tools/Scripts/move-namespace.ps1 rewrites namespaces and usings repo-wide and across the consumers, and Tools/Scripts/auto-add-usings.py plus Tools/Scripts/fix-build-usings.py settle the usings the compiler then reports. The framework's own changelog names the script as the consumer sweep (MMCA.Common/CHANGELOG.md:1953-1955, :2010-2012).

  10. A layout change ships as a breaking minor release with an old-to-new map. v1.183.0 dissolved MMCA.Common.Infrastructure.Services and .Settings into the features they implement and split the UI capability, service and component grab-bags (CHANGELOG.md:2016-2034), and added the folder-width rule (:2042-2046). v1.184.0 split the eight remaining flat public namespaces (:1950-1956, :1960-1991) and added UPGRADING.md (:1997-1998). There is no dual-namespace grace release and no [Obsolete] shim, because C# cannot forward a type across namespaces inside one assembly (MMCA.Common/UPGRADING.md:10-12); the first-party consumers are swept in the same release (UPGRADING.md:32-33).

Rationale

  • A wide folder has stopped naming a feature. The rule's own statement of intent is that a folder "that accumulates dozens of direct code files has stopped naming a feature and started naming a technical bucket, which is exactly the horizontal layout vertical slicing exists to avoid" (ArchitectureRules.FolderWidth.cs:6-9). A count is a cheap proxy that a reviewer will not forget to apply.
  • Filesystem checks are cheap and exact. The rule reads directories instead of reflecting over assemblies (:9-11), so it costs nothing at test time and reports a repo-relative path an author can act on (:61, :74-75).
  • Written once, inherited four times. The base plus per-repo subclass shape is the ADR-015 pattern, so the cap, the counting rules and the skip list move for every repo in one framework release.
  • Namespace-follows-folder is what makes layout enforceable at all. Without IDE0130 at warning under TreatWarningsAsErrors, "the folder names the feature" would be a review convention that decays silently. With it, the compiler holds the tree and the namespace in agreement.
  • Exemptions belong in code. A deliberately flat folder is listed as a string in the repo's own subclass with a documented reason (FolderWidthTests.cs:20-31), so the exception is reviewable in a diff rather than being an unwritten habit.

Trade-offs

  • Layout changes are public API breaks on a lockstep package family. Because the namespace is the folder, a reorganization is a breaking release for every consumer (ADR-016), with no deprecation window: the old namespaces stop existing in the release that introduces the new ones (UPGRADING.md:10-12). Two consecutive framework releases, v1.183.0 and v1.184.0, were exactly that.
  • Documentation citations rot underneath the moves, and they did. The 2026-09-03 ADR audit classified 56 of its 84 needs-edit records as citation-only drift, almost all of it a folder segment the v1.183.0 to v1.185.0 moves introduced (Rules/, Bases/Governance/, Messaging/, Auth/, Services/Api/, Startup/Pipeline/) that the cited path no longer carried. The MMCA.Common scorecard's section 5 row was one of those, and it has since been repaired: it now cites the post-move Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Cqrs/ArchitectureRules.Slices.cs (governance/common-ArchitectureScorecard.md:69). Every one of those is a path-only break with the behavior unchanged, which is precisely what makes it easy to miss, and repairing them is a pass of its own, separate from the move that caused them.
  • The gate counts; it does not read names. Nothing verifies that a folder is named after an aggregate, that the plural name matches across projects, or that no sub-folder is called Domain. A repo can satisfy the twelve-file cap with twelve technical buckets, and Decision point 8 is carried by review.
  • The cap is a round number and the exemption match is loose. Twelve is a virtual default with no derivation (FolderWidthTestsBase.cs:23), and an exemption matches by relativeFolder.EndsWith(suffix) (ArchitectureRules.FolderWidth.cs:85-86), so a short suffix can exempt more folders than its author intended.
  • The skip list hides real trees. Resources and wwwroot are skipped outright by segment (:78-82), so a localization or asset folder can grow without bound even when its shape is authored rather than generated.
  • Namespace-follows-folder is not universal. Eight documented IDE0130 exclusions (MMCA.Common/.editorconfig:869-885, MMCA.ADC/.editorconfig:856-857, MMCA.Store/.editorconfig:845-846) mean that in those trees a folder move renames nothing, so the property a reader relies on holds almost everywhere rather than everywhere.

Revision (2026-10-01)

No decision or rationale changed. Citations were re-anchored: the rubric section 5 text (ArchitectureEvaluationCriteria.md:211, :214), the v1.183.0 and v1.184.0 changelog entries (MMCA.Common/CHANGELOG.md:1588-1691), UPGRADING.md:10-12 and :32-33, the module-name consumers (EntityTypeConfiguration.cs:86, :107; LoggingCommandDecorator.cs:95; LoggingQueryDecorator.cs:89), the MMCA.Common scorecard section 5 row (common-ArchitectureScorecard.md:69), and MMCA.ADC's rule text, which now lives in MMCA.ADC/AGENTS.md:99. The folder-width public API entries have been promoted to Testing.Architecture/PublicAPI.Shipped.txt:120-122, :336, :436, :531-532. The Context sentence about "the last three releases" was a point-in-time statement and now names the two consecutive layout releases, v1.183.0 and v1.184.0.

Revision (2026-10-06)

  • Decision 8: the module name now reaches the database through the per-engine data-source engines (SQLServerDataSourceEngine.cs:101, PostgreSQLDataSourceEngine.cs:100, CosmosDataSourceEngine.cs:95, dispatched from EntityTypeConfiguration.cs:77), so the PostgreSQL schema is a fourth consumer of the NamespaceConventions.cs:20-21 wrapper. Behavior is unchanged.
  • Trade-offs: dropped the clause saying the scorecard section 5 row does not mention the folder-width gate; that row now cites FolderWidthTests.cs:18 and MMCA.Common.slnx:59 (common-ArchitectureScorecard.md:69).
  • The 2026-10-01 revision's changelog, PublicAPI and EntityTypeConfiguration.cs anchors had moved: the v1.184.0 and v1.183.0 entries now span MMCA.Common/CHANGELOG.md:1923-2026, and the FolderWidthTestsBase baseline is six entries at PublicAPI.Shipped.txt:127-129, :363, :584-585.
  • Anchors re-verified against current source (MMCA.Common v1.232.0).

Revision (2026-10-07)

Re-verified against current source. The twelve-file cap, the counting rules, the three Common exemptions, the six Common IDE0130 exceptions and the scripted move are unchanged; one Decision sentence overstated the code, one trade-off no longer held, and the changelog, .editorconfig, FolderWidthTests and AGENTS.md anchors moved.

  1. Decision 8: narrowed "a sub-folder carrying one of those names shifts the derived module". A Domain sub-folder shifts it in any non-Domain project because Domain wins (MMCA.Common/Source/Core/MMCA.Common.Shared/Conventions/ModuleNameConventions.cs:41-45); the other four take the first match (:48-50), so they shift it only in a project whose layer is not in NonDomainLayerSegments (:17), such as Shared.
  2. Trade-offs: dropped the bullet saying the MMCA.Common FolderWidthTests class summary describes the pre-v1.184.0 exemption set. The summary names exactly the three live exemptions (Tests/Architecture/MMCA.Common.Architecture.Tests/Governance/FolderWidthTests.cs:10-12), matching the list at :28-30.
  3. Anchors re-verified against current source: Common FolderWidthTests.cs:16 (class), :26-31 (override), :20-31 (override with its summary); FolderWidthTestsBase.cs:29; MMCA.ADC/AGENTS.md:100; MMCA.Common/.editorconfig:860-868, :869, :872, :875, :878, :881, :884, range :869-885; the v1.184.0 and v1.183.0 entries now span MMCA.Common/CHANGELOG.md:1948-2051 (v1.184.0 Breaking :1950-1956, splits :1960-1991, UPGRADING.md :1997-1998; v1.183.0 Breaking :2009-2012, dissolutions :2016-2034, folder-width rule :2042-2046), with the script named at :1953-1955 and :2010-2012 (MMCA.Common v1.233.0, CHANGELOG.md:7).

ADR-015 (the fitness-function tier this rule joins, and the public-API baseline that records FolderWidthTestsBase), ADR-016 (lockstep versioning: a layout change lands in every package at one version and every consumer bumps in one pass), ADR-059 (the module contract whose project-per-layer shape is the level above "feature by folder"), ADR-106 (the other convention whose cost is paid in the public API surface of a lockstep-released package family), ADR-053 (the dual-registry publish that carries a renamed namespace to consumers outside this workspace).