Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions .agents/skills/purview-architecture-testing/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
---
name: purview-architecture-testing
description: >-
Use when adding, changing or debugging architecture tests in a .NET solution - dependency-direction
rules, forbidden framework references, project naming checks, cycle detection - or when a boundary
keeps being broken in review and should fail the build instead. Adopts Purview.Architecture.Testing
and a single policy file rather than hand-maintained lists.
license: MIT
metadata:
author: Purview-Dev
version: "2.0"
---

# Purview architecture testing

A boundary that only review enforces is a boundary that erodes. The job of this skill is to turn a
rule a reviewer keeps repeating into a test that fails the build, **without** producing a wall of
brittle assertions that everyone disables six months later.

Prerequisite: know what the projects are *for*. See `purview-architecture`.

## 1. Only test a rule that is real

Before writing anything, answer: **has this rule been broken, or is it certain to be?**

A test for a dependency nobody would plausibly add is cost with no benefit. Good candidates:

- A direction that has already been violated once.
- A boundary that is load-bearing - Core staying free of EF Core, Composition being referenced by
nothing.
- A rule a newcomer cannot infer from the code.

Bad candidates: anything the compiler already enforces, and style preferences dressed up as
architecture.

## 2. Adopt the package; define the policy once

The failure mode of architecture tests is a dependency matrix written twice - once in a wiki table and
once in assertions - which then disagree. The prose is the copy that drifts, because nothing fails
when it is wrong.

So do not write the rule engine. `Purview.Architecture.Testing` ships it, so the matrix, naming,
sibling-independence, composition, forbidden-package, cycle, test-reference and waiver rules are
defined once and shared. Adoption is two files.

**A policy** at `.config/architecture-policy.json`, pointing at the published schema so an editor
validates it as you type:

```json
{
"$schema": "https://raw.githubusercontent.com/purview-dev/build-sdk/main/architecture-policy.schema.json",
"version": 1,
"sourceRoots": ["src"],
"responsibilities": [
{
"name": "Core",
"aliases": ["Domain"],
"category": "domain",
"purpose": "The domain model, its invariants and its rules.",
"mayDependOn": ["Shared"],
"mustNotDependOn": ["Application", "Persistence", "Composition"],
"forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Microsoft.AspNetCore"]
}
],
"waivers": []
}
```

**One derived test class:**

```csharp
[InheritsTests]
public sealed class ArchitectureTests : PurviewArchitectureTests;
```

That is the whole of the adoption. Each rule is a separate test, so a failure names the rule rather
than collapsing the set into one assertion, and the package validates the policy against its schema
first - a policy that does not match the schema may be silently missing a rule.

The build-sdk ships a skill for the package itself, `purview-buildsdk-architecture-tests`, which is
authoritative on its API and rule ids. Read it rather than this page when you are extending or
debugging the package.

Then add a test that the documentation matches the policy, so the table in the wiki is falsifiable
too. Full worked example:
[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md).

## 3. Read project files, and assemblies

Two sources, because they catch different things:

| Source | Catches | Misses |
| -- | -- | -- |
| `.csproj` `ProjectReference` | The reference a reviewer actually added | A dependency arriving transitively |
| Compiled assembly references | A dependency that arrived indirectly | Intent — it cannot tell direct from transitive |

The package's rules read the **project files**, because a declared `ProjectReference` is what a
reviewer adds and what the policy governs. Add a small hand-written test that reads the **compiled
assemblies** for the leak a project file cannot show - a namespace from the wrong layer reaching a
public type.

Classify a project by the **first segment** of its name, so `Persistence.SqlServer` and `Persistence`
are both Persistence - qualification is a naming choice, never a different rule. The package does
this for you; a hand-written assembly test must do it too.

## 4. The rules worth having

| Rule | What it catches |
| -- | -- |
| Dependency matrix | Core referencing Persistence; Application referencing a concrete provider |
| Nothing references Composition | A circular implementation choice |
| Sibling independence | `Providers.GitHub` reaching into `Providers.AzureDevOps` |
| Forbidden package prefixes | EF Core in Core; ASP.NET hosting in a domain project |
| Cycle detection | A graph that MSBuild rejects with an unreadable message |
| Catch-all names | `Infrastructure`, `Services`, `Helpers` appearing as project names |
| No production → test reference | A test helper leaking into shipped code |

The package provides all of these. Do not re-implement one; change the policy instead.

## 5. Write failures someone can act on

This is what decides whether the suite survives. A failure must name:

1. The offending project.
2. The rule violated.
3. The specific reference that broke it.
4. The direction the dependency *should* run.
5. The likely fix.

```text
'Application' (Application) references 'Persistence.SqlServer' (Persistence), but Application
must not depend on Persistence. Invert it: Persistence may depend on Application, or introduce
an abstraction that Application owns and Persistence implements.
```

Not `Expected collection to be empty`. Collect every violation and report them as a numbered list -
one test run should show all of them, not the first. The package already reports this way; keep the
same standard in any rule you add.

## 6. Waivers, not suppressions

A repository that predates the policy will violate it. Deleting the test or adding a blanket
`#pragma` makes the policy a lie. Record the departure in the policy instead:

```json
{
"project": "Infrastructure",
"mapsTo": "Adapters",
"rules": ["naming"],
"reason": "Holds one adapter, predates the catalogue; renaming churns the wiki and docs for no gain.",
"reviewedOn": "2026-10-15"
}
```

The package tests the waivers themselves: each must name a project that still exists, a rule, and a
substantive reason. A waiver for a deleted project is dead weight that hides the next one.

Critically, a waiver excuses **the narrowest thing possible** - usually the name. `mapsTo` keeps the
project classified so every dependency rule still applies to it. Prefer a waiver to
`SkipRule`, which removes a rule for the whole repository with no record.

## 7. Conventions

- TUnit, matching the repository's other tests, adopted through `PurviewArchitectureTests` with
`[InheritsTests]`.
- Arrange/Act/Assert, one behaviour per test.
- Let the package find the repository root - it probes upward for the policy and stops at the `.git`
boundary. Override `RepositoryRoot` only when the policy does not sit above the test binary.
- Keep the policy file free of anything a rule cannot read; a field no rule consults is a rule that
looks enforced and is not.

## Checks before you answer

- Is every rule you added one that has been, or will be, broken?
- Is the matrix defined once, in the policy, with the documentation checked against it?
- Does each failure name the fix, not just the fact?
- Did you record a waiver rather than weaken a rule for existing code?

## See also

- `purview-architecture` — what the projects are for, and how they are named
- `purview-buildsdk-architecture-tests` — the package's own skill, authoritative on its API
- [Architecture as Tests](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Architecture-As-Tests.md)
146 changes: 146 additions & 0 deletions .agents/skills/purview-architecture/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
---
name: purview-architecture
description: >-
Use when deciding the shape of a .NET solution that uses Purview libraries — how many projects,
whether to add a repository or CQRS layer, where validation lives — or before generating substantial
implementation code. Establishes project context first, then applies DRY, KISS and architecture
proportional to the actual requirements rather than to an enterprise reference diagram.
license: MIT
metadata:
author: Purview-Dev
version: "1.0"
---

# Purview architecture

The job of this skill is to stop two failures that cost far more than they look:

- **Architecture bought in advance.** Four projects, a repository interface and a mapping layer for an
application that has one table and one caller.
- **A recommendation made without context.** Advice about database-side GUID generation given to a
stateless CLI, or a Clean Architecture split proposed for a 300-line utility.

So: establish context, then choose proportionally. Both steps are mandatory, and the first one is the
one that gets skipped.

## 1. Establish context before deciding

Before recommending a structure, adding a project, or generating substantial code, you must know four
things. **Read them out of the repository first** — asking a user a question the code already answers
is the most common way this skill is applied badly.

| Dimension | Read it from | Ask only if |
| -- | -- | -- |
| Complexity (small / medium / large) | Number of functional areas, integrations and external consumers — not file count | The requirements are genuinely not written down anywhere |
| Project type | SDK attribute, `OutputType`, `Microsoft.NET.Sdk.Web`, an AppHost project, a `Main` | The repository is empty |
| Persistence | `PackageReference` to an EF Core provider, `DbContext`, a connection string, a `Purview.Containers` resource | Persistence is being introduced now |
| Telemetry | `Purview.Telemetry.SourceGenerator`, `ILogger` injection, `ActivitySource`, an OTLP exporter | Nothing is configured and you are about to add it |

Identifier generation is a **conditional** dimension: resolve it only when persistence exists *and*
GUID keys are in play. See `purview-persistence`.

Judge complexity on requirements, not size. A 2,000-line CLI with one output format is small. A
900-line service that reconciles three upstream systems and owns money movement is not.

**Do not re-ask.** A dimension answered by repository configuration, existing source, a project file,
an earlier turn in the conversation, or a recorded architectural decision is answered. Ask concise,
targeted questions only for the unknowns that change your recommendation — and if nothing changes,
ask nothing.

Full decision procedure: `references/project-context-discovery.md`.

## 2. Choose proportionally

| Scale | Shape | What you still do not add |
| -- | -- | -- |
| Small | One project, or two when a test project needs a non-trivial subject. Direct implementations. | Repository over `DbContext`, CQRS, a mapping layer, speculative interfaces |
| Medium | Explicit boundaries where complexity already hurts — usually a domain project and a host | Event sourcing, a generic infrastructure framework, a second infrastructure implementation nobody asked for |
| Large | Modularity, independent infrastructure implementations, enforceable boundaries (see the playbook's Architecture-As-Tests page) | Patterns adopted for symmetry rather than for a named problem |

Architecture is allowed to arrive late. A boundary added when the second caller appears is cheap; a
boundary added in anticipation of a second caller that never appears is permanent.

**Never add a project for symmetry.** "Domain, Application, Infrastructure, API" is a response to a
problem, not a starting position. If you cannot name the failure a layer prevents in this codebase,
do not add the layer.

Full guidance and the per-pattern justification tests: `references/proportional-architecture.md`.

## 3. Name what you do create

A project name states a **responsibility**, optionally qualified by an implementation:

```text
<Responsibility>[.<Implementation>]
```

The responsibility decides the rules; the qualifier never changes them. Three rules people usually
get wrong:

- **Do not qualify a single implementation.** `Persistence` is correct even when it uses SQL Server.
Rename when a second provider arrives, not in anticipation.
- **Do not create an empty parent.** `Persistence.SqlServer` + `Persistence.PostgreSql` with nothing
shared need no `Persistence` project.
- **Multiple implementations do not require multiple assemblies.** Two small adapters with the same
dependencies belong in one project.

Never use a *category* as a project name — `Infrastructure`, `Services`, `Managers`, `Models`,
`Helpers`, `Common`. Nothing can be excluded from a category, so it grows without limit. And never
rename an established project just to match canonical terminology; `Domain`, `UseCases` and
`Integrations` are recognised aliases.

Responsibility catalogue, dependency matrix and the naming decision tree:
`references/project-responsibilities.md`.

## 4. Check the direction

`A → B` means **A depends on B at compile time** — not a runtime call, a DI registration, an HTTP
request or an Aspire orchestration edge. Confusing those is the most common source of a wrong answer
about whether a dependency is allowed.

The rules that matter most:

- Core depends on nothing but `Shared`.
- Application never references a concrete `Persistence`, `Providers` or `Adapters` project.
- Composition references implementations; **nothing references Composition** except a host.
- Sibling implementations (`Providers.GitHub`, `Providers.AzureDevOps`) stay independent.
- Persistence owns migration *definitions*; `Migrator` owns their *execution*. Production API startup
does not apply schema migrations.

When a boundary is worth having, make it fail the build rather than the review — see
`purview-architecture-testing`.

## 5. DRY, applied where it pays

Reuse the Purview libraries when they remove a real source of truth, not for coverage:

| Concern | Library | Use it when |
| -- | -- | -- |
| Operation outcomes without exceptions for control flow | `Purview.Results` | Failures are expected and the caller must handle them |
| Primitive obsession, validated identity types | `Purview.ValueObjects` | A value has rules that would otherwise be re-checked at each boundary |
| Schema shared with a TypeScript client | `Purview.ZodSharp` | A contract is duplicated between C# and TypeScript today |
| Local dependencies for dev and integration tests | `Purview.Containers` | Tests need a real database, queue or emulator |
| Logging, tracing and metrics from one declaration | `Purview.Telemetry.SourceGenerator` | Always — see `purview-telemetry` |

Duplication that matters: validation repeated per entry point, a domain rule restated in the UI,
configuration bound in two places, two types that both claim to be the truth. Duplication that does
not: two similar-looking methods that answer to different owners.

## 6. Telemetry is not an advanced topic

Recommend instrumentation from the first commit, at every project size. A small application gets
small telemetry — it does not get none. See `purview-telemetry`.

## Checks before you answer

- Did you read the four context dimensions rather than ask for them?
- Can you name, for each project and each abstraction you are proposing, the failure it prevents here?
- Is anything in your proposal there only because a reference architecture has it?
- Did you include telemetry?

## See also

- `purview-telemetry` — instrumentation at every scale
- `purview-persistence` — storage and GUID-key guidance, only when persistence is real
- [Engineering Principles](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Engineering-Principles.md)
- [Adopting This At Your Scale](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Adopting-This-At-Your-Scale.md)
Loading
Loading