Skip to content
Purview Dev logo

Project Responsibilities

Experimental Engineering Playbook Reviewed 2026-10-15 purview-dev/engineering-playbook Star on GitHub architecture-tests best-practices coding-agents conventions csharp documentation dotnet engineering msbuild-sdk tunit

Experimental. This project is an experiment: the public API, defaults and packaging can change between prereleases, and there is no production support guarantee.

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

<Responsibility>[.<Implementation>]

Persistence, Persistence.SqlServer, Providers.GitHub, Adapters.Email are all well-formed. The responsibility decides the rules; the qualifier is a naming choice and never changes them.

This is a catalogue of responsibilities, not a list of projects you must create. A small application that combines Core, Application and Persistence in one assembly has not broken the rule — it has one project carrying three responsibilities, which is usually correct. The catalogue tells you what each responsibility is so that you can tell when splitting one out would buy something.

The authoritative definition is .config/architecture-policy.json, described by the JSON Schema the SDK publishes at architecture-policy.schema.json. The tables on this page are checked against it — see How it is enforced.

Two failures, equally common and opposite.

The catch-all project. Infrastructure, Services, Helpers, Common. These name a category, so nothing can be excluded from them. Every technical class is plausibly infrastructure, every coordinating class is plausibly a service, and both grow until no one can say what the project is for. A responsibility name can be violated; a category name cannot, which is exactly why it is useless.

Architecture bought in advance. Four projects, a repository interface and a mapping layer around one table and one caller. Nothing in it is wrong in isolation; all of it is cost paid at every future change against a failure that never arrives.

A catalogue addresses both: it gives each project a name that can be wrong, and it makes “do I need this project?” a question with an answer rather than a matter of taste.

A → B means A depends on B at compile time. It is not a runtime call, a DI registration, an HTTP request or an Aspire orchestration edge.

ResponsibilityCategoryAlso known asMay depend on
CoredomainDomainShared
Abstractionsdomain—Core, Shared
ApplicationapplicationUseCasesCore, Abstractions, Contracts, Shared
Contractsapplication—Core, Shared
Persistenceinfrastructure—Core, Abstractions, Application, Shared
ProvidersinfrastructureIntegrationsCore, Abstractions, Application, Shared
Adaptersinfrastructure—Core, Abstractions, Application, Shared
Compositioncomposition—Core, Abstractions, Application, Contracts, Persistence, Providers, Adapters, Shared
APIhost—Core, Abstractions, Application, Contracts, Composition, ServiceDefaults, Shared, Persistence, Providers, Adapters
Workerhost—Core, Abstractions, Application, Contracts, Composition, ServiceDefaults, Shared, Persistence, Providers, Adapters
Migratorhost—Core, Abstractions, Persistence, Composition, ServiceDefaults, Shared
AppHosthost—API, Worker, Migrator, WebApp, Shared
ServiceDefaultshost—Shared
WebApphost—Contracts, ServiceDefaults, Shared
Sharedshared—(nothing)
ResponsibilitySplit it out whenLeave it combined when
CoreA domain model exists at allThe app is a thin utility with no domain rules
AbstractionsA contract has two implementations, or a consumer must not see the implementationOne implementation, no cross-boundary consumer — keep the interface beside it
ApplicationWorkflows are reused by more than one hostIt would only forward CRUD without adding behaviour
ContractsA client must compile against the contract without taking the serverThe only consumer is the API host
PersistencePersistence exists — it is what Core must not referenceSingle-project utility writing to a file
ProvidersTwo platforms with different SDKs or lifecyclesOne platform, or small clients sharing a lifecycle
AdaptersA capability has more than one technology behind itOne capability, one technology
CompositionMore than one host composes the same implementationsOne host — compose in the host
MigratorSchema changes are applied by a deliberate deployment stepDevelopment-only, migrate on startup
ServiceDefaultsMore than one host shares operational defaultsOne host
SharedA domain-independent primitive is used by two or more responsibilitiesAlmost always — it is the easiest project to create and the hardest to keep coherent

Diagram A — the responsibility catalogue

Section titled “Diagram A — the responsibility catalogue”

Grouped by category. No project here is mandatory.

flowchart TB
subgraph domain["Domain"]
Core["Core<br/>(aka Domain)"]
Abstractions["Abstractions"]
end
subgraph application["Application"]
Application["Application<br/>(aka UseCases)"]
Contracts["Contracts"]
end
subgraph infrastructure["Infrastructure - a category, not a project"]
Persistence["Persistence"]
Providers["Providers<br/>(aka Integrations)"]
Adapters["Adapters"]
end
subgraph composition["Composition"]
Composition["Composition"]
end
subgraph hosts["Hosts"]
API["API"]
Worker["Worker"]
Migrator["Migrator"]
AppHost["AppHost"]
ServiceDefaults["ServiceDefaults"]
WebApp["WebApp"]
end
subgraph shared["Shared"]
Shared["Shared"]
end

Diagram B — compile-time dependency direction

Section titled “Diagram B — compile-time dependency direction”

Every arrow means depends on. Note that nothing points into Core except by permission, and nothing points at Composition at all.

flowchart RL
Abstractions --> Core
Application --> Core
Application --> Abstractions
Application --> Contracts
Contracts --> Core
Persistence --> Core
Persistence --> Abstractions
Persistence --> Application
Providers --> Abstractions
Adapters --> Abstractions
Composition --> Application
Composition --> Persistence
Composition --> Providers
Composition --> Adapters
API --> Composition
API --> Contracts
Worker --> Composition
Migrator --> Persistence
WebApp --> Contracts
AppHost -.->|"orchestrates, not a reference"| API
AppHost -.->|"orchestrates, not a reference"| Worker
flowchart TD
A["New implementation of a responsibility"] --> B{"More than one<br/>implementation?"}
B -- no --> C["Use the generic name<br/>Persistence, Providers, Adapters"]
B -- yes --> D{"Do they differ in<br/>dependencies, lifecycle<br/>or deployment?"}
D -- no --> E["One project, several implementations inside<br/>still the generic name"]
D -- yes --> F["Qualify each one<br/>Persistence.SqlServer, Persistence.PostgreSql"]
F --> G{"Is there genuinely<br/>shared code?"}
G -- yes --> H["Add the unqualified project too<br/>Persistence + Persistence.SqlServer"]
G -- no --> I["Qualified projects only<br/>no empty parent"]

Three rules fall out of that tree, and all three are departures from what people usually assume:

  • Do not qualify a single implementation. Persistence is correct even though it happens to use SQL Server. Rename it when a second provider arrives, not in anticipation.
  • Do not create an empty parent. Persistence.SqlServer and Persistence.PostgreSql with no shared code need no Persistence project.
  • Multiple implementations do not require multiple assemblies. Two small adapters with the same dependencies can live in one project.
flowchart RL
Persistence["Persistence<br/>(SQL Server inside - not in the name)"] --> Core
Providers["Providers<br/>(one platform)"] --> Abstractions
Adapters["Adapters"] --> Abstractions
Abstractions --> Core
Composition --> Persistence
Composition --> Providers
Composition --> Adapters
API --> Composition

The unqualified Persistence appears only because there is shared mapping code. Providers has no unqualified project, because GitHub and AzureDevOps share nothing.

flowchart RL
PersistenceShared["Persistence<br/>shared mappings"] --> Core
PersistenceSql["Persistence.SqlServer"] --> PersistenceShared
PersistencePg["Persistence.PostgreSql"] --> PersistenceShared
ProvidersGitHub["Providers.GitHub"] --> Abstractions
ProvidersAzdo["Providers.AzureDevOps"] --> Abstractions
Abstractions --> Core
Composition --> PersistenceSql
Composition --> PersistencePg
Composition --> ProvidersGitHub
Composition --> ProvidersAzdo
API --> Composition
ProvidersGitHub -. "forbidden: siblings stay independent" .-x ProvidersAzdo

Domain for Core, UseCases for Application and Integrations for Providers are recognised and need no change. Do not rename an established project merely to match canonical terminology — the rename costs more than the consistency is worth.

These names are discouraged as project names, because each names a category rather than a responsibility: Infrastructure, Services, Managers, Models, Helpers, Common, Utils, Utilities.

Infrastructure is the one worth calling out. It is a useful category — it covers Persistence, Providers and Adapters — and a poor project, because everything technical qualifies.

The distinction is the one people get wrong most often, and it is not about the vendor.

  • A Provider integrates a named external platform: GitHub, Azure DevOps. The platform is part of the requirement — “sync issues from GitHub”.
  • An Adapter implements an application-defined capability using some technology: send email, store a blob. The capability is the requirement; the technology is interchangeable.

The same vendor can be either. Azure Blob Storage behind IFileStore is an Adapter. The Azure DevOps work-item API, where the product explicitly integrates Azure DevOps, is a Provider.

flowchart TD
subgraph req["What the requirement says"]
R1["'Sync issues from GitHub'<br/>the platform is named"]
R2["'Send the user an email'<br/>the capability is named"]
end
R1 --> P["Provider<br/>Providers.GitHub"]
R2 --> A["Adapter<br/>Adapters.Email implements IEmailSender"]
P --> PA["Owns: client, auth, rate limits,<br/>provider models, error translation"]
A --> AA["Owns: one technology behind<br/>an application-defined interface"]
PA --> N1["Swapping it changes the product"]
AA --> N2["Swapping it changes nothing the user sees"]

Composition is the only project allowed to know every implementation, and nothing is allowed to depend on it. That is what keeps implementation choice in one place.

Solid arrows are compile-time references. Dashed arrows are runtime resolution, which runs in the opposite direction and is the whole point of the arrangement.

flowchart TD
Abstractions["Abstractions<br/>IConventionRepository"]
Application["Application"]
PersistenceSql["Persistence.SqlServer"]
Composition["Composition"]
API["API"]
Application -->|depends on| Abstractions
PersistenceSql -->|implements| Abstractions
Composition -->|references| PersistenceSql
Composition -->|references| Application
API -->|references| Composition
Composition -.->|"registers at runtime"| Resolve["IConventionRepository resolves<br/>to the SQL Server implementation"]
Application -.->|"receives at runtime"| Resolve

Application never references Persistence.SqlServer. It receives an implementation it cannot name.

Persistence owns migration definitions. Migrator executes them. Splitting those two is what stops an API startup path from mutating a production schema.

flowchart TD
Persistence["Persistence<br/>DbContext, mappings, migration definitions"]
Migrator["Migrator<br/>executes migrations, controlled seeding"]
API["API"]
DB[("Database")]
Persistence -->|"migrations live here"| Migrator
Migrator -->|"applies schema, deliberate step"| DB
API -->|"reads and writes only"| DB
API -.->|"must NOT apply migrations at startup in production"| DB
RuleEnforced by
Every production project maps to a responsibility or a recorded waiverArchitectureTests
Declared project references respect the dependency matrixArchitectureTests
Sibling implementations stay independentArchitectureTests
Nothing depends on Composition except hostsArchitectureTests
Forbidden framework packages stay out of a responsibilityArchitectureTests
The project graph has no cyclesArchitectureTests
Catch-all project names are rejectedArchitectureTests
Production code does not reference a test projectArchitectureTests
Every waiver names a real project, a rule, and a substantive reasonArchitectureTests
This page’s tables match the policy filePolicyDocumentationTests
The policy matches its JSON Schema, and the schema can actually rejectPurviewArchitectureTests, PolicySchemaTests
A responsibility is split only when it is justifiedReview

The rules themselves are not written here: they ship in Purview.Architecture.Testing, and ArchitectureTests derives from the package’s PurviewArchitectureTests base class, so the matrix, naming, sibling-independence, composition, forbidden-package, cycle, test-reference and waiver rules are defined once and shared. The tests read .config/architecture-policy.json. Nothing on this page is a second source of truth — a consistency test fails if the tables drift from the policy.

.config/architecture-policy.json is described by a JSON Schema (draft 2020-12) that the SDK publishes at architecture-policy.schema.json, and the file points at it so an editor validates as you type:

{
"$schema": "https://raw.githubusercontent.com/purview-dev/build-sdk/main/architecture-policy.schema.json",
"version": 1,
"sourceRoots": ["src"]
}

The schema lives in the SDK’s repository rather than in a copy beside the policy, because a second copy is a second source of truth and the two drift. It is also embedded in Purview.Architecture.Testing, so validation needs no network and cannot disagree with the reader.

KeyPurpose
versionFormat version. Increment when a change would break a reader that understood the previous shape.
sourceRootsRepo-relative folders holding the projects; defaults to ["src"]. An empty array scans the repository root. A wrong value finds no projects, which the graph-discovery rule reports rather than letting every other rule pass vacuously.
responsibilities[]name, aliases, category, purpose, mayDependOn, mustNotDependOn, forbiddenPackagePrefixes, siblingIndependence, and the split/combine guidance
discouragedProjectNamesCategory names rejected as project names, with the reason
rulesRepository-wide switches not expressed per responsibility
waivers[]Deliberate departures — see below

Two schema choices do real work:

  • additionalProperties: false everywhere. A misspelled key is the most dangerous mistake in a policy file, because it reads as an absent rule rather than an error. mustNotDependOnn must fail, not silently disable a boundary.
  • minLength on a waiver’s reason. A waiver with a token justification is a suppression wearing a reason’s clothes.

The schema is enforced, not decorative: PurviewArchitectureTests validates the policy against the embedded schema on every run, and PolicySchemaTests asserts it rejects a document missing a required field, a waiver with a token reason, and a misspelled key. A schema that cannot fail proves nothing.

The rule engine ships as Purview.Architecture.Testing, so a repository does not copy the C#. Adoption is two files:

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

{
"$schema": "https://raw.githubusercontent.com/purview-dev/build-sdk/main/architecture-policy.schema.json",
"version": 1,
"sourceRoots": ["src"],
"responsibilities": [ ... ]
}

2. One test class:

[InheritsTests]
public sealed class ArchitectureTests : PurviewArchitectureTests;

That is the whole adoption. The base class contributes the full rule set — schema validation, dependency matrix, sibling independence, composition boundary, forbidden packages, cycles, naming, test references and waiver integrity — each as a separate test, so a failure names the rule.

[InheritsTests] is required. TUnit does not walk base types during discovery. Without the attribute the class compiles, the run is green, and nothing is enforced. TUnit’s analyzer reports TUnit0030 when it is missing; treat that as an error, not a suggestion.

Two optional overrides:

OverrideUse when
RepositoryRootThe policy does not sit above the test binary — the default probes upward for .config/architecture-policy.json
SkipRule(rule)A rule the repository has not adopted yet. Prefer a waiver: it is per project, carries a reason, and is itself checked

For bespoke rules, use the package’s engine directly rather than the base class — ArchitecturePolicyEvaluator returns typed ArchitectureViolation lists per rule, and takes an in-memory ProjectNode graph so a rule can be tested without a repository on disk. The package’s own skill, purview-buildsdk-architecture-tests, is authoritative on that API and on the rule ids.

A repository that predates the policy will violate it, and suppressing that silently would make the policy a lie. A waiver records the departure instead:

{
"project": "Infrastructure",
"mapsTo": "Adapters",
"rules": ["naming", "discouragedName"],
"reason": "...",
"reviewedOn": "2026-10-15"
}

A waiver excuses the name only. mapsTo keeps the project classified, so every dependency rule still applies to it. This repository’s own Infrastructure project is the worked example: it holds one adapter, so it is not the catch-all the rule is aimed at, and renaming it would churn the wiki, AGENTS.md and NamespaceRemoveSuffix for no gain.

flowchart TD
A["Discover *.csproj under src/"] --> B["Read ProjectReference and PackageReference"]
B --> C["Classify by name<br/>responsibility, alias, or waiver mapsTo"]
C --> D["Load .config/architecture-policy.json"]
D --> E["Evaluate rules<br/>matrix, siblings, composition, packages, cycles, names"]
E --> F{"Violations?"}
F -- yes --> G["Fail with project, rule, offending reference<br/>and the direction it should run"]
F -- no --> H["Pass"]
D --> I["PolicyDocumentationTests<br/>wiki tables match the policy"]
  • A small application. One or two projects. Use the catalogue to name them, not to multiply them. Core plus a host is a complete and correct architecture.
  • A medium application. Split out the responsibility whose boundary you keep tripping over — usually Persistence, so Core and Application stop seeing EF Core.
  • A large application. The full catalogue earns its place, and the tests stop the matrix drifting as teams change.
  • Any size. Copy the waiver mechanism before you copy the rules. A policy with no honest escape hatch gets suppressed wholesale the first time it is inconvenient.