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
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.
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.
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
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.
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
That is the whole file. The folder name Domain becomes the project name, and
$(NamespacePrefix).Domain becomes both RootNamespace and AssemblyName. Add a type:
namespaceAcme.Domain;
publicsealedrecordCustomerId
{
CustomerId(string value) => Value =value;
publicstring Value { get; }
publicstatic CustomerId Create(string value) =>
string.IsNullOrWhiteSpace(value)
?thrownew ArgumentException("A customer id cannot be empty.", nameof(value))
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>
<NamespaceRemoveSuffixRemove="Infrastructure" />
</ItemGroup>
Step 6 — Add a test project with an even shorter project file
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:
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
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.
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.
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.
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
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:
# 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 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.
Concern
Where it lives
Enforced by
Framework, language version, nullable, analyzers
global.json + the SDK import
The build
Package versions, and a family moving as one
Directory.Packages.props
The build (NU1010) for presence; review for the property style
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.