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.
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,SpeakersandSponsorsdirectly underSource/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 carriesCommonandUsers, and.Shared/also carriesRoomsandAuthorization, 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: oneTicketsfolder underSource/Modules/Tickets/MMCA.Helpdesk.Tickets.Domain/,.Application/and.Shared/.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/andComponents/(MMCA.ADC.Conference.UI/),Controllers/(MMCA.ADC.Conference.API/) andPersistence/(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: besidePersistence/, its first level also carries aggregate names for external adapters,Events/Sessionize/andSessions/Scoring/. MMCA.ADC's ownCLAUDE.mdstates that shape as the rule for the repo, "external adapters sitting under their aggregate" (MMCA.ADC/AGENTS.md:100, whichMMCA.ADC/CLAUDE.mdimports).The aggregate carries the same plural name in every project of the module.
Speakersappears in the Conference module's Domain, Application, Shared,UI/Pages,UI/Services,API/ControllersandInfrastructure/Persistence/EntityConfigurationtrees, so a reader who knows the aggregate knows the path in every layer.No folder holds more than twelve direct code files.
FolderWidthTestsBase.MaxDirectFilesdefaults to 12 (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Governance/FolderWidthTestsBase.cs:23, test method at:31-33) and the rule body isArchitectureRules.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 theSource/andTests/trees (:42).The counting rules are part of the decision. Only files directly in a folder count (
:91,:98). A.razorcomponent counts once and its co-locatedX.razor.cscounts with it (:105-120, the pairing at:117-119); a.resxnever counts, since only.razorand.csfiles 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,wwwrootand.git(:78-82).All four repos run the gate, and every exemption is code.
FolderWidthTestssubclasses 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 itsRepoRoot. Only MMCA.Common overridesExemptFolderSuffixes, with three one-concept folders:MMCA.Common.Application/UseCases/Decorators, its test twin, andMMCA.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).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) andTreatWarningsAsErrorsis 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-868for 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 theMMCA.Common.Aspirenamespace 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-TFMPlatforms/bootstrap files (MMCA.ADC/.editorconfig:848-857,MMCA.Store/.editorconfig:837-846).A folder inside a module project is never named
Domain,Application,Infrastructure,APIorUI.ModuleNameConventions.GetModuleNamederives 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):Domainmatches at any index from 1 up (:41-43) and the other four match only at index 3 or later (:17,:48-50). ADomainsub-folder therefore shifts the derived module in any project other than Domain, becauseDomainwins 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 exampleMMCA.ADC.Conference.Application) they still yieldConference, 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 fromEntityTypeConfiguration.cs:77, the logical data-source name for unit-of-work routing (Infrastructure/Persistence/DataSources/EntityDataSourceRegistry.cs:181), all four through the internalInfrastructure/Persistence/NamespaceConventions.cs:20-21wrapper, and the module tag on every CQRS log line (Application/UseCases/Decorators/LoggingCommandDecorator.cs:95,LoggingQueryDecorator.cs:89). The derivation is locked byTests/Core/MMCA.Common.Shared.Tests/Conventions/ModuleNameConventionsTests.cs. This half of the convention is enforced by review only: no fitness rule checks folder names.The move is scripted, not hand-edited.
Tools/Scripts/move-namespace.ps1rewrites namespaces and usings repo-wide and across the consumers, andTools/Scripts/auto-add-usings.pyplusTools/Scripts/fix-build-usings.pysettle 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).A layout change ships as a breaking minor release with an old-to-new map. v1.183.0 dissolved
MMCA.Common.Infrastructure.Servicesand.Settingsinto 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 addedUPGRADING.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-moveSource/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
virtualdefault with no derivation (FolderWidthTestsBase.cs:23), and an exemption matches byrelativeFolder.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.
Resourcesandwwwrootare 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 fromEntityTypeConfiguration.cs:77), so the PostgreSQL schema is a fourth consumer of theNamespaceConventions.cs:20-21wrapper. 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:18andMMCA.Common.slnx:59(common-ArchitectureScorecard.md:69). - The 2026-10-01 revision's changelog, PublicAPI and
EntityTypeConfiguration.csanchors had moved: the v1.184.0 and v1.183.0 entries now spanMMCA.Common/CHANGELOG.md:1923-2026, and theFolderWidthTestsBasebaseline is six entries atPublicAPI.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.
- Decision 8: narrowed "a sub-folder carrying one of those names shifts the derived module". A
Domainsub-folder shifts it in any non-Domain project becauseDomainwins (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 inNonDomainLayerSegments(:17), such as Shared. - Trade-offs: dropped the bullet saying the MMCA.Common
FolderWidthTestsclass 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. - 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 spanMMCA.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-1955and:2010-2012(MMCA.Common v1.233.0,CHANGELOG.md:7).
Related
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).