# Architests > Enforce a repository's architecture policy as TUnit tests. Architecture tests for .NET. A repository declares its responsibilities and the dependency directions they may and may not use in a policy file, then derives one test class from PurviewArchitectureTests and gets the standard rule set as separate TUnit tests. The project-level rules read declared ProjectReferences; the type-level rule reads the compiled assemblies' metadata, so a reference that only arrives transitively or through a package is caught too, and nothing is loaded or executed. The set covers dependency direction, sibling independence, composition boundaries, forbidden packages and framework references, cycles, test references and project naming, with reviewed per-project waivers and an extension point for bespoke rules. - Repository: https://github.com/purview-dev/architests - Package: https://www.nuget.org/packages/Purview.Architecture.Testing - Project page: https://purview.dev/projects/architests/ - Documentation: https://purview.dev/docs/architests/ - Full machine-readable content: https://purview.dev/projects/architests/llms-full.txt # Purview Architecture Testing Enforce a repository's architecture policy as TUnit tests, so the rules are defined once rather than copied into every repository's test project. Adoption is a policy file at `.config/architecture-policy.json` and one derived test class: ```csharp [InheritsTests] public sealed class ArchitectureTests : PurviewArchitectureTests; ``` [Get started](getting-started/) ## Guides - [Getting started](getting-started/) — adopt the package in a repository, step by step. - [Policy reference](policy-reference/) — every field, every rule id, every default. - [Type-level rules](type-level-rules/) — how the assembly check works and how an assembly is attributed to a project. - [Extending](extending/) — add your own rule to the standard set. ## Reference - [The policy schema](https://github.com/purview-dev/architests/blob/main/architecture-policy.schema.json) - [The sample](https://github.com/purview-dev/architests/tree/main/src/samples/ArchitectureSample) - [The package README](https://github.com/purview-dev/architests/blob/main/src/src/ArchitectureTesting/README.md) ## Agent skills ```bash npx skills add purview-dev/architests ``` --- # Extending A repository can add a bespoke check without a second architecture framework. Implement `IArchitectureRule` and return the same `ArchitectureViolation` type as the built-in rules: ```csharp using Purview.Architecture.Testing; [InheritsTests] public sealed class ArchitectureTests : PurviewArchitectureTests { protected override IReadOnlyList AdditionalRules => [new NoConsoleLoggingRule()]; sealed class NoConsoleLoggingRule : IArchitectureRule { public string Rule => "noConsoleLogging"; public IReadOnlyList Evaluate(ArchitectureContext context) => context.Projects.Values .Where(project => !project.IsTestProject) // ...inspect the project or its assembly... .Select(project => new ArchitectureViolation(Rule, project.Name, "Console logging is banned here.")) .ToArray(); } } ``` ## What the context gives you `ArchitectureContext` carries the three inputs a rule can need: | Member | What it is | | -- | -- | | `Policy` | The parsed `ArchitecturePolicy`, so a rule reads the same definition the built-in rules do. | | `Projects` | The project graph, keyed by project name, with declared references, packages, framework references and analyzers. | | `Assemblies` | The compiled assemblies, keyed by project name, each with the external types it references. Empty when `assemblyRules.enabled` is false. | ## What you get for free An additional rule participates in everything the built-in rules do: - **Its own `[Test]`.** Each rule id becomes a case of `AdditionalRule_GivenTheRepository_RespectsThePolicy`, so a failure names the rule. - **`SkipRule`.** Override `SkipRule` and return `true` for the rule's `Rule` id. - **Waivers.** A waiver whose `rules` names the rule's id is honoured automatically, so a reviewed, per-project departure needs no code. - **The same reporting.** `ArchitecturePolicyEvaluator.Report` renders the violations, and the failure's `.Because(...)` lists every one. ## Keep the rule in a fixture The evaluator takes an in-memory graph, so a rule is testable as a small fixture rather than a repository on disk: ```csharp var evaluator = new ArchitecturePolicyEvaluator( policy, projects, assemblies, [new NoConsoleLoggingRule()] ); await Assert.That(evaluator.EvaluateAdditionalRule("noConsoleLogging")).IsEmpty(); ``` That is exactly how the package's own rules are tested. --- # Getting started ## 1. Add the package ```text dotnet add package Purview.Architecture.Testing ``` Add it to a test project that already references the projects you want to govern, or create a dedicated `Architecture.UnitTests` project. The test project must reference the production projects, directly or transitively, because the type-level rule reads their compiled assemblies from the test output. ## 2. Write the policy Create `.config/architecture-policy.json` at the repository root. The smallest useful policy names each responsibility and what it may depend on: ```json { "$schema": "https://raw.githubusercontent.com/purview-dev/architests/main/architecture-policy.schema.json", "version": 2, "sourceRoots": ["src"], "responsibilities": [ { "name": "Domain", "category": "domain", "purpose": "The domain model, its invariants and its rules.", "mayDependOn": [], "mustNotDependOn": ["Application", "Persistence", "Composition"] }, { "name": "Application", "category": "application", "purpose": "Coordinates the use cases and workflows.", "mayDependOn": ["Domain"], "mustNotDependOn": ["Persistence", "Composition"] }, { "name": "Persistence", "category": "infrastructure", "purpose": "Durable storage and persistence-specific configuration.", "mayDependOn": ["Domain", "Application"], "mustNotDependOn": ["Composition"] }, { "name": "Composition", "category": "composition", "purpose": "The composition root that selects and wires the implementations.", "mayDependOn": ["Domain", "Application", "Persistence"], "mustNotDependOn": [] } ] } ``` A project name is `[.]`, and only the **first segment** is classified: `Persistence.SqlServer` and `Persistence` are both `Persistence`. Qualification is a naming choice, never a different set of rules. `sourceRoots` defaults to `["src"]`. Getting it wrong finds no projects, which the `graphDiscovery` rule reports rather than letting every other rule pass vacuously. ## 3. Add the test class ```csharp using Purview.Architecture.Testing; [InheritsTests] public sealed class ArchitectureTests : PurviewArchitectureTests; ``` That is the whole suite. Each rule is a separate `[Test]`, so a failure names the rule rather than collapsing the set into one assertion. > **`[InheritsTests]` is required.** TUnit does not walk base types during discovery, so without the > attribute the class compiles, the run is green, and nothing is enforced — the worst failure this > package can have. TUnit's analyzer reports `TUnit0030`; treat it as an error. ## 4. Run it ```bash dotnet test ``` If `graphDiscovery` fails, `sourceRoots` is wrong. If `naming` fails, a project name does not map to a responsibility — add an alias or a waiver. If `dependencyMatrix` or `assemblyDependencyMatrix` fails, the message names the offending reference, the direction the dependency should run, and the likely fix. ## 5. Turn on the type-level rule The project-level matrix only sees declared `ProjectReference`s. To also catch a type that arrives transitively or through a package, enable the assembly check: ```json "assemblyRules": { "enabled": true } ``` An empty `assemblyRoots` scans the test output directory, which already holds every project the test project references. See [Type-level rules](../type-level-rules/) for how an assembly is attributed to a project. ## Overrides ```csharp [InheritsTests] public sealed class ArchitectureTests : PurviewArchitectureTests { protected override string? RepositoryRoot => "/path/to/repo"; } ``` `RepositoryRoot` defaults to probing upward from the test binary for the nearest policy file. The search **stops at the repository boundary** (`.git`) rather than continuing into parent folders — walking past it would happily find a sibling checkout's policy and validate this repository against someone else's rules. `SkipRule` opts a whole repository out of a rule. Prefer a [waiver](../policy-reference/#waivers): it is per project, carries a reason and a review date, and is itself checked. --- # Policy reference The policy is `.config/architecture-policy.json`. It is validated against the schema embedded in the package before any rule runs, so a field the reader understands but the schema rejects is a bug. The authoritative definition is [`architecture-policy.schema.json`](https://github.com/purview-dev/architests/blob/main/architecture-policy.schema.json). ## Top-level fields | Field | Type | Default | Meaning | | -- | -- | -- | -- | | `$schema` | string | — | Pointer to the published schema, so an editor validates as you type. | | `version` | integer ≥ 1 | — | The document version. The current version is **2**. | | `description` | string | — | Free text. | | `conventions` | object | — | `qualifiedNameSeparator` and `dependencyArrowMeaning`, for humans and editors. | | `sourceRoots` | string[] | `["src"]` | Repo-relative folders holding the projects. An empty array scans the repository root. | | `responsibilities` | object[] | — | **Required**, at least one. The responsibility catalogue. | | `discouragedProjectNames` | object | — | Names that describe a category, with a `reason`. | | `assemblyRules` | object | — | The type-level check. Off unless `enabled` is true. | | `waivers` | object[] | `[]` | Deliberate, reviewed departures. | ### `version` 2 Version 2 removed the `rules` object (`noCycles`, `noProductionToTestReferences`, `implementationsMustNotReferenceComposition`, `siblingImplementationsIndependent`), whose flags were deserialised but never consulted. Use `SkipRule` or a waiver to opt out of a rule. A version-1 policy that set `rules` must delete the block; a version-1 policy that did not still validates. ## Responsibilities | Field | Type | Required | Meaning | | -- | -- | -- | -- | | `name` | string | ✅ | A single PascalCase segment, with no dots. | | `aliases` | string[] | | Recognised alternative names; an established project using one is not renamed. | | `category` | enum | ✅ | `domain`, `application`, `infrastructure`, `composition`, `host` or `shared`. | | `purpose` | string ≥ 20 | ✅ | Why the responsibility exists; quoted in failure messages. | | `mayDependOn` | string[] | ✅ | Responsibilities this one may reference. An empty array means it depends on nothing. | | `mustNotDependOn` | string[] | ✅ | Responsibilities this one must never reference. | | `forbiddenPackagePrefixes` | string[] | | NuGet identifier prefixes that must not appear, e.g. `Microsoft.EntityFrameworkCore`. | | `forbiddenFrameworkReferences` | string[] | | Framework reference prefixes that must not appear, e.g. `Microsoft.AspNetCore.App`. | | `siblingIndependence` | boolean | | When true, qualified implementations of this responsibility must not reference one another. | | `separateProjectWhen` | string ≥ 10 | | Documentation only. | | `mayRemainCombinedWhen` | string ≥ 10 | | Documentation only. | ## Assembly rules | Field | Type | Default | Meaning | | -- | -- | -- | -- | | `enabled` | boolean | `false` | Whether the type-level check runs. It reads compiled assemblies, so it needs a build to have produced them. | | `assemblyRoots` | string[] | `[]` | Repo-relative folders to scan. An empty array scans the test output directory. | | `assemblyGlobs` | string[] | `["*.dll"]` | File globs matched under each root. | ## Waivers | Field | Type | Required | Meaning | | -- | -- | -- | -- | | `project` | string | ✅ | The project the waiver applies to, exactly as it appears on disk. | | `mapsTo` | string | | The responsibility the project actually has. Keeps every dependency rule in force while excusing the name. | | `rules` | string[] | ✅ | The rule ids this waiver excuses. At least one. | | `reason` | string ≥ 40 | ✅ | Why the departure is acceptable. | | `reviewedOn` | date | | When the departure was last reviewed. | Any rule id may be waived, including `siblingIndependence` and `assemblyDependencyMatrix`. An unknown id is a `waiverIntegrity` failure, so a typo cannot silently excuse nothing. ## Rules | Rule id | Catches | | -- | -- | | `graphDiscovery` | No production projects were discovered, so every other rule would pass vacuously. Not waivable. | | `naming` | A production project whose name maps to no responsibility or alias. | | `discouragedName` | A project named after a category, so nothing can be kept out of it. | | `dependencyMatrix` | A declared `ProjectReference` the matrix forbids or does not permit. | | `assemblyDependencyMatrix` | A type reference the matrix forbids or does not permit, including one that arrives transitively or through a package. | | `siblingIndependence` | A qualified implementation referencing a qualified sibling. | | `compositionBoundary` | A non-composition, non-host project referencing a project whose category is `composition`. | | `forbiddenPackage` | A package matching a forbidden prefix for the responsibility. | | `forbiddenFramework` | A `` matching a forbidden prefix for the responsibility. | | `cycle` | A cycle in the project dependency graph, reported readably. | | `testReference` | A production project referencing a test project. | | `waiverIntegrity` | A waiver that is stale, unjustified, malformed, or excuses an unknown rule. Not waivable. | ### Dependency matrix semantics Both `dependencyMatrix` and `assemblyDependencyMatrix` apply the same three-way rule: 1. **Same responsibility is always allowed.** A qualified implementation referencing its unqualified sibling is the same box on the diagram. 2. **`mustNotDependOn` is a violation.** 3. **`mayDependOn` is permitted.** A reference the matrix neither permits nor forbids is a **violation**: silence on a dependency is not permission, it is a gap in the policy. Add the responsibility to `mayDependOn` if the dependency is intended. ### Test and production projects A project is a **test project** when its name ends with `Tests`, **or** its path contains a `tests` directory. Test projects are excluded from every rule — a test references everything by design. Everything else is a **production project**. --- # Type-level rules A project file only shows a declared `ProjectReference`. It cannot show a type that arrives transitively through another project, or a type pulled in by a package. The `assemblyDependencyMatrix` rule closes that gap: it reads the compiled assemblies and applies the **same** `mayDependOn` / `mustNotDependOn` matrix to the types each one actually references. It is off by default, because it needs a build to have produced the assemblies: ```json "assemblyRules": { "enabled": true, "assemblyRoots": [], "assemblyGlobs": ["*.dll"] } ``` ## How it reads - **Metadata only.** The reader opens each assembly with `System.Reflection.Metadata` and walks the `TypeReference` table. Nothing is loaded, executed, or resolved through a probing path, so a type initialiser never runs and a missing dependency never throws. - **The `TypeReference` table is the dependency set.** The compiler emits a row for every external type a member signature, base type, interface, generic argument, attribute or method body touches — including a type used only inside a method body. - **References resolve against the scanned set.** A reference is attributed to a project only if the defining assembly is itself one of the scanned project assemblies. A package is never mistaken for a project: `Microsoft.Extensions.Logging.Abstractions` is not `Abstractions`. ## How an assembly is attributed to a project 1. **The SDK marker.** `Purview.BuildSdk` writes a global `public static partial class Assembly` into every production project, so an assembly whose name is unrelated to its project — an explicit `AssemblyName` — still maps exactly. This is the same marker the generated assembly identifier exists for. 2. **Exact name.** A repository that opts the marker out, or a project the SDK did not build, maps when its assembly name is its project name. An assembly that maps to no project is a package and is ignored. ## Where the assemblies come from An empty `assemblyRoots` scans the test output directory (`AppContext.BaseDirectory`), which already holds every project the test project references, directly or transitively. Set `assemblyRoots` to repo-relative folders only if the assemblies live somewhere else; `obj` output is always skipped so a reference assembly never stands in for the real one. ## When it finds nothing Enabled with no production assemblies found is a **failure**, not a silent pass — the same guard `graphDiscovery` provides for projects. Build the repository before running the tests, or point `assemblyRoots` at the right folder. ## Qualified implementations At the type level, a qualified implementation collapses into its responsibility, exactly as it does at the project level: a type in `Persistence.SqlServer` referencing a type in `Persistence` is a same-responsibility reference and is allowed.