Skip to content
Purview Dev logo

Getting Started

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.

Build the smallest repository that satisfies every rule in the playbook, in the order you would actually create the files. Each step says what it buys you, so you can stop when you have taken what you need. The finished result is a repository whose defaults come from one MSBuild SDK, whose test projects are wired by their filenames, whose architecture is asserted by a test, and whose documentation is a wiki the build already knows how to find.

A convention adopted by copying somebody else’s finished repository is a convention you have not read. Building the repository in the order the rules depend on each other means every file has a reason by the time you write it, and the build tells you immediately when a step is wrong. The order is not arbitrary: the SDK import must come before anything that derives from it, the package versions must exist before the first project restores, and the solution must exist before the pipeline can be pointed at it.

There are two versions in play here, and the file pins both:

{
"sdk": {
"version": "10.0.401",
"rollForward": "disable",
"allowPrerelease": false
},
"msbuild-sdks": {
"Purview.BuildSdk": "1.0.6"
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
}

sdk.version pins the .NET SDK — the dotnet that runs the build. rollForward: "disable" means exactly that version and nothing else, so a machine with several SDKs installed cannot pick a different one. msbuild-sdks pins Purview.BuildSdk, the MSBuild SDK that supplies the conventions: it is what makes <Import Sdk="Purview.BuildSdk" Project="Sdk.props" /> resolvable, because MSBuild restores it as a NuGet package and reads its .props/.targets. test.runner selects the Microsoft Testing Platform, which is the runner TUnit expects.

The SDK writes this file for you when it is missing, but what it writes is only the msbuild-sdks and test blocks — it does not write an sdk block at all, so there is no sdk.version and no rollForward. Until you add those two lines by hand, a machine with several .NET SDKs installed resolves latest-wins, and the build that passes locally is not necessarily the build CI runs. See build-sdk: global.json bootstrapping.

Step 2 — Declare the versions the SDK injects, in Directory.Packages.props

Section titled “Step 2 — Declare the versions the SDK injects, in Directory.Packages.props”

Central Package Management is on by default, so every PackageReference the SDK adds has to have a matching PackageVersion or restore fails with NU1010. The versions below are the ones this repository builds with today — check them against the SDK’s current release before you commit, as templates/README.md says.

<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<!-- Version Definitions -->
<PropertyGroup Label=".NET Versions">
<MSExtensionVersion>10.10.0</MSExtensionVersion>
</PropertyGroup>
<PropertyGroup Label="Purview Versions">
<PurviewTelemetryVersion>5.0.4</PurviewTelemetryVersion>
</PropertyGroup>
<PropertyGroup Label="Testing Versions">
<TUnitVersion>1.75.0</TUnitVersion>
</PropertyGroup>
<!-- Packages -->
<ItemGroup Label="Purview">
<PackageVersion Include="Purview.Telemetry.SourceGenerator" Version="$(PurviewTelemetryVersion)" />
</ItemGroup>
<ItemGroup Label="Microsoft Extensions">
<PackageVersion Include="Microsoft.Extensions.Telemetry.Abstractions" Version="$(MSExtensionVersion)" />
</ItemGroup>
<ItemGroup Label="Other">
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.402" />
</ItemGroup>
<ItemGroup Label="Testing">
<PackageVersion Include="TUnit" Version="$(TUnitVersion)" />
<PackageVersion Include="TUnit.Core" Version="$(TUnitVersion)" />
<PackageVersion Include="TUnit.Mocks" Version="$(TUnitVersion)" />
<PackageVersion Include="Bogus" Version="35.6.5" />
</ItemGroup>
</Project>

Purview.Telemetry.SourceGenerator, Microsoft.Extensions.Telemetry.Abstractions and Microsoft.SourceLink.GitHub are injected by the SDK itself — telemetry and SourceLink are on by default. The rest are the test stack: TUnit is the framework, TUnit.Mocks the substitute library, Bogus the test-data generator. See Testing and Test Data.

Versions are properties, packages are items

Section titled “Versions are properties, packages are items”

The shape above is the convention, and it is worth following from the first file rather than retrofitting later: a version shared by a family of packages is declared once as a property, and every PackageVersion in that family references it.

The failure it prevents is the partial bump. TUnit, TUnit.Core and TUnit.Mocks ship together and must move together; bump two of the three by hand and restore still succeeds, because NuGet has no idea they are related. What you get is a mismatch that surfaces at run time, in a test run, as something that looks like a product bug. With one $(TUnitVersion) there is no half-done upgrade to miss in review — the family moves, or it does not. The same applies to an EF Core provider against EF Core, an Aspire client against its hosting package, and every Purview.* product whose packages are released as a set.

Two rules keep it from becoming indirection for its own sake:

  • Declare a property when the version governs a family, or when the packages belong to a product that releases them together — even where you reference only one of them today, because the property is where the second one will look. A package with no family, like Bogus or Microsoft.SourceLink.GitHub, keeps its literal version inline.
  • Group with Label. The file then reads as a short list of decisions — .NET, Purview, testing, everything else — rather than one long alphabetical block in which a stale version is invisible.

This is a convention, not something the build checks: nothing fails if you inline every version. It is on the reviewer, and it is cheap to keep because the file is read far more often than it is written.

Step 3 — Import the SDK, and name the namespace prefix

Section titled “Step 3 — Import the SDK, and name the namespace prefix”

src/Directory.Build.props:

<Project>
<PropertyGroup>
<NamespacePrefix>Acme</NamespacePrefix>
</PropertyGroup>
<Import Sdk="Purview.BuildSdk" Project="Sdk.props" />
</Project>

src/Directory.Build.targets:

<Project>
<Import Sdk="Purview.BuildSdk" Project="Sdk.targets" />
</Project>

NamespacePrefix must be set before the import, because the SDK derives RootNamespace from it during .props evaluation. From this point every project beneath src/ gets the target framework, language version, nullable context, analyzers, code-style enforcement and assembly identity without declaring any of them. See Repository Shape.

Where the file goes. MSBuild walks up from each project to the nearest Directory.Build.props, so the import can sit at the repository root or at src/. This repository and the multi-project Purview repositories put it at src/, which keeps the root for repository-level configuration only. The SDK’s own repository-root discovery still resolves correctly from there, because it asks git.

src/Acme.slnx:

<Solution>
<Folder Name="/Solution Items/">
<File Path="../.editorconfig" />
<File Path="../Directory.Packages.props" />
<File Path="../global.json" />
<File Path="../.config/purview-build.json" />
<File Path="Directory.Build.props" />
<File Path="Directory.Build.targets" />
</Folder>
<Folder Name="/src/">
<Project Path="src/Domain/Domain.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Domain.UnitTests/Domain.UnitTests.csproj" />
<Project Path="tests/SharedTestingInfra/SharedTestingInfra.csproj" />
</Folder>
</Solution>

The Solution Items folder is the list of files a reader of the solution needs and that a build would otherwise not surface. .slnx is the XML solution format; .gitattributes pins it to LF.

Step 5 — Create a project with a two-line project file

Section titled “Step 5 — Create a project with a two-line project file”

src/src/Domain/Domain.csproj:

<Project Sdk="Microsoft.NET.Sdk">
</Project>

That is the whole file. The folder name Domain becomes the project name, and $(NamespacePrefix).Domain becomes both RootNamespace and AssemblyName. Add a type:

namespace Acme.Domain;
public sealed record CustomerId
{
CustomerId(string value) => Value = value;
public string Value { get; }
public static CustomerId Create(string value) =>
string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("A customer id cannot be empty.", nameof(value))
: new CustomerId(value);
public static CustomerId Hydrate(string value) => new(value);
public override string ToString() => Value;
}

Create validates, Hydrate trusts. That split is the subject of Contracts and Data Exchange.

One trap, immediately. The SDK strips a list of layer suffixes (Core, Infrastructure, Persistence, Abstractions, …) from RootNamespace, so a project called Infrastructure declares its types in Acme, not Acme.Infrastructure. If you want the suffix kept, remove it from the list after the import:

<ItemGroup>
<NamespaceRemoveSuffix Remove="Infrastructure" />
</ItemGroup>

Step 6 — Add a test project with an even shorter project file

Section titled “Step 6 — Add a test project with an even shorter project file”

src/tests/Domain.UnitTests/Domain.UnitTests.csproj:

<Project Sdk="Microsoft.NET.Sdk">
</Project>

The filename does the wiring. The SDK sees UnitTests, so it adds the TUnit runner, coverage, the [Category: Unit] assembly attribute, and a ProjectReference to the project the name targets — resolved by probing ../Domain/, ../../Domain/, ../src/Domain/ and ../../src/Domain/, and skipped silently when none of them exists. A test class then lives in the namespace of the type it tests:

namespace Acme.Domain;
public sealed class CustomerIdTests
{
[Test]
public async Task Create_GivenAnEmptyValue_Throws()
{
// Arrange
// Act
Action act = () => CustomerId.Create("");
// Assert
await Assert.That(act).Throws<ArgumentException>();
}
}

src/tests/SharedTestingInfra/SharedTestingInfra.csproj:

<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="../../src/Domain/Domain.csproj" />
</ItemGroup>
</Project>

The name is one of a recognised set, so the SDK treats it as a shared testing project: it gets the test packages but not the runner, stays a library, and is referenced automatically by the test projects beside it. This is where TestBuilder lives — see Testing and Test Data.

Step 8 — Point the shared pipeline at the solution

Section titled “Step 8 — Point the shared pipeline at the solution”

.config/purview-build.json:

{
"$schema": "https://raw.githubusercontent.com/purview-dev/build/main/purview-build.schema.json",
"Build": {
"Solution": "src/Acme.slnx",
"TestRoot": "src/tests",
"TestPatterns": "*Tests.csproj",
"TestFilter": "/*/*/*/*[Category=Unit]",
"RunPack": false,
"ValidatePack": false
},
"Release": {
"Mode": "None"
}
}

Paths are anchored to the repository root, not to the configuration file, so moving the file into .config/ does not change what it points at.

RunPack and ValidatePack are off because this repository ships no package. The shared workflow defaults both to true, so leaving them unset would give a green local build and a failing pipeline. See Verification Model.

TestPatterns is left at its default, *Tests.csproj, on purpose. Discovery and tiering are two mechanisms, and only one of them should carry the tier: TestPatterns chooses which project files are discovered, TestFilter chooses which discovered tests run. Leave discovery wide and the day you add Domain.IntegrationTests it is kept out of the pull-request tier by its category alone, with no second edit. Narrow TestPatterns and you have to remember both. See Verification Model.

.github/workflows/pr.yml:

name: PR
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, ready_for_review]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build and test
uses: purview-dev/build/.github/workflows/purview-build.yml@main
with:
run-pack: false
validate-pack: false
test-filter: "/*/*/*/*[Category=Unit]"
secrets: inherit

The two pack switches repeat Build:RunPack and Build:ValidatePack from the configuration, so the workflow and the JSON agree about what a green build means. The workflow is a caller; the pipeline itself lives in one shared repository. See Verification Model and Release Flow.

mkdocs.yml:

site_name: Acme
site_description: Developer documentation for Acme
repo_url: https://github.com/acme/acme
edit_uri: edit/main/docs/wiki/
docs_dir: docs/wiki
theme:
name: material
plugins: [search, techdocs-core]
markdown_extensions: [admonition, attr_list, md_in_html, pymdownx.details, pymdownx.superfences]

Then docs/wiki/index.md, docs/wiki/Home.md and docs/wiki/_Sidebar.md, and the pages behind them. _Sidebar.md is the navigation list, so a new page that is not in it is a page nobody finds. See Documentation Convention.

Step 11 — Make the repository legible to an agent

Section titled “Step 11 — Make the repository legible to an agent”

Add AGENTS.md at the root: purpose, layout table, conventions, completion checklist, local commands. Then install the SDK’s own skills with npx skills add purview-dev/build-sdk, so an agent gets that guidance without you writing it. Installation is explicit - no build writes into an agent directory. See Agent Enablement.

Step 12 — Add the local tools and the commands that run them

Section titled “Step 12 — Add the local tools and the commands that run them”

Step 13 runs just, and just lint-check runs CSharpier. Neither exists yet, so create both now.

.config/dotnet-tools.json is the tool manifest, so CSharpier is restored at a pinned version rather than installed globally per machine:

{
"version": 1,
"isRoot": true,
"tools": {
"csharpier": {
"version": "1.3.0",
"commands": ["csharpier"],
"rollForward": true
},
"purview.build": {
"version": "0.5.3",
"commands": ["purview-build"],
"rollForward": false
}
}
}

Purview.Build belongs here too, for the same reason CSharpier does: the manifest pins the version, and dotnet tool restore is the one command that installs it. A repository should never hand-roll dotnet tool install --tool-path for it.

Run dotnet tool restore once after adding it.

Justfile at the repository root is the list of supported commands. The point of it is that every command a contributor needs is discoverable from just --list, so start with the four the next step uses:

set quiet
solution_file := "./src/Acme.slnx"
build_configuration := "Debug"
pipeline_tool := "dotnet purview-build"
# Builds the solution
build *args:
dotnet build "{{ solution_file }}" --configuration "{{ build_configuration }}" {{ args }}
# Runs the tests
test filter="/*/*/*/*/" *args:
dotnet test --solution "{{ solution_file }}" --configuration "{{ build_configuration }}" \
--treenode-filter={{ filter }} {{ args }}
# Checks formatting with CSharpier
lint-check:
dotnet csharpier check .
# Fixes formatting with CSharpier
lint-fix:
dotnet csharpier format .
# Runs the PR pipeline (restore, build, lint, tests) through the shared tool
pipeline-pr *args:
dotnet tool restore
{{ pipeline_tool }} {{ args }}

There is deliberately no Justfile in templates/: the recipes are the list of things your repository actually does, and a copied list of someone else’s is noise. The five above are the floor, not the shape.

just build # dotnet build (Debug)
just test # dotnet test
just lint-check # CSharpier check
just pipeline-pr # restore, build, lint, test through the shared Purview.Build tool

Then break a rule on purpose — add a ProjectReference from the domain to the layer above it, or rename a project file so it no longer matches its folder — and confirm the build refuses. A convention you have not watched fail is a convention you are guessing about.

Most of the files in steps 1–12 have a copy-paste version in templates/, with Acme standing in for your namespace prefix and a comment explaining the one thing about each file that is easy to get wrong. The templates README maps each template to its destination path and lists which files each profile needs.

Four things in the steps above are deliberately not templated, because a copy of them would be wrong on arrival: the solution file (its paths depend on your project list), the two .csproj files (they are empty by design), and the Justfile (see step 12). templates/README.md says which, and why.

Step 13 proved it by breaking a rule on purpose. The table below is the same statement for the whole repository: which concern lives where, and what refuses a change that breaks it.

ConcernWhere it livesEnforced by
Framework, language version, nullable, analyzersglobal.json + the SDK importThe build
Package versions, and a family moving as oneDirectory.Packages.propsThe build (NU1010) for presence; review for the property style
Assembly and namespace identityNamespacePrefix + the folder nameThe build
Code styleBootstrapped .editorconfigThe build, with the style policy validated
Test wiringThe .csproj filenameThe build
Test dataTestBuilder in SharedTestingInfraReview
ArchitectureDependency-direction tests + .config/architecture-policy.jsonThe build
Verification tiersTestFilter in purview-build.jsonThe pipeline
Documentationdocs/wiki + _Sidebar.mdThe build (DocumentationTests) for navigation and fences; review for the rest

This page is the floor, not the whole playbook. Adopting This at Your Scale decides which parts to take for a small, medium or large repository, and Worked Examples walks the reference implementation that results from taking all of them.