Staff-level design of repo-wide .NET build governance: global.json, layered Directory.Build files, Central Package Management, nuget.config and lock files, analyser and warning policy, artefact layout, CODEOWNERS, shared pipelines, exception processes, build performance and metrics.
I'd treat the build as a product, with a contract that lives in files at the repo root:
global.json for the SDK.Directory.Build.props/.targets for framework, nullable, analysers and output layout.Directory.Packages.props with transitive pinning for versions.nuget.config with source mapping, plus lock files..editorconfig for style and analyser severities.Project files stay tiny. A small platform group owns those files through CODEOWNERS, and there's a documented exception process for overrides and suppressions. CI uses shared templates that run exactly what developers run locally. The warning policy ratchets stricter over time. SDK, framework and package upgrades arrive as automated, grouped pull requests. I'd measure it all (build time, warnings, drift, time to patch) so governance helps teams instead of just blocking them.
"I'd treat the build as a product with a contract that lives in files at the repo root:
global.jsonfor the SDK, layeredDirectory.Build.propsfiles for src, tests and samples with explicit imports,Directory.Packages.propswith transitive pinning,nuget.configwith source mapping plus lock files, and.editorconfig, so project files stay tiny. A small platform team owns those files through CODEOWNERS, with an exception process for overrides and suppressions, and shared CI templates run exactly what developers run locally, with affected-project builds and binary logs on failure. The warning policy ratchets rather than going all-or-nothing, withNoWarnonly when it's justified; the mistake I see is turning every analyser on as an error on day one, which just produces blanket suppressions. SDK, framework and package upgrades then arrive as automated, grouped pull requests.For example, after merging [14] repos into a monorepo of about [250] projects at [company], PR builds took [38] minutes and we had [seven] SDK versions in use. As [staff engineer for the platform], I had to design governance that twelve teams would actually adopt. I encoded the rules in root build files, gave the platform team ownership with an exceptions register, and introduced shared CI templates with affected-project builds and a nightly full build. I rolled strict warnings out layer by layer instead of all at once. As a result, typical PR builds dropped to [under 10] minutes, and the next critical transitive vulnerability was patched across every service in [one afternoon] instead of [weeks].
At this level the job is to make the right thing the easy thing, so I measure build time, warning counts, exceptions and time to patch, and governance can show it's helping teams rather than just blocking them."
| File | Decides |
|---|---|
global.json |
SDK version and roll-forward (latestFeature), msbuild-sdks versions |
Directory.Build.props |
TargetFramework (net10.0), LangVersion default, Nullable, ImplicitUsings, AnalysisLevel, TreatWarningsAsErrors, UseArtifactsOutput, ContinuousIntegrationBuild on CI, company metadata |
src/, tests/, samples/ Directory.Build.props |
Layer-specific settings, each importing the root with GetPathOfFileAbove |
Directory.Build.targets |
Logic needing final values (packaging defaults for libraries, guard targets) |
Directory.Packages.props |
All versions, GlobalPackageReference analysers, transitive pins |
nuget.config |
<clear />, internal feed with upstreams, package source mapping |
.editorconfig |
Code style and analyser severities |
CODEOWNERS |
Platform team reviews every file above |
Project files then contain only what's unique: SDK, output type, references.
AnalysisLevel=latest-recommended. Enable TreatWarningsAsErrors for new and clean projects first, using a per-layer property that teams opt into, then flip the default once most projects are clean. Every NoWarn needs a comment and a ticket.VersionOverride only via the exception process; Dependabot or Renovate grouped updates weekly; NuGet Audit warnings fail the build for high and critical severities.dotnet msbuild -getProperty) and fails if a project escapes the defaults, and a target that fails if a project sets Version on a PackageReference or targets an unsupported TFM.global.json SDK install, dotnet restore --locked-mode, dotnet format --verify-no-changes, Release build, tests, binary log uploaded on failure.Track and publish: median and p95 CI time, local incremental build time, warning count trend, number of VersionOverrides and NoWarns, distinct package versions, time from vulnerability advisory to patched main branch. Governance that can't show it is making things faster or safer loses support.
Consolidating 14 repos into a monorepo of about 250 projects, the first version simply copied each repo's settings. Builds took 38 minutes and there were seven SDK versions in use. Introducing the root contract (one global.json, layered props, CPM, source mapping, shared templates) and affected-project builds brought typical PR builds to under 10 minutes. A critical advisory for a transitive package was later patched across all services in one afternoon via a transitive pin, where it had previously taken weeks of per-repo PRs.
The contract maps directly onto controls auditors ask about: approved package sources, patch timeliness (Essential Eight "patch applications"), restricted administrative change (CODEOWNERS and branch protection), and an audit trail for exceptions. For systems that handle personal information under the Privacy Act, being able to show when a vulnerable dependency was removed matters.
Assesses staff-level thinking about build systems as organisational infrastructure.
Green flags: a clear file-based contract; layering and ownership; dependency, warning and lifecycle policies; exception handling; CI design including affected builds; metrics; incremental rollout and change management.
Red flags: only lists MSBuild properties; no ownership model; all-or-nothing rules; ignores build performance or developer experience.
| Goal | What it means in practice | How you prove it |
|---|---|---|
| Reproducible | Same commit gives the same output on any machine or agent | Pinned SDK, lock files, ContinuousIntegrationBuild, deterministic builds |
| Secure | Known package sources, fast patching, audit trail | Source mapping, NuGet Audit gate, time from advisory to patched main |
| Consistent | One set of defaults; exceptions are visible | Property audit in CI, exceptions register |
| Fast | Inner loop and PR builds stay quick as the repo grows | Median and p95 build times, incremental build time |
| Evolvable | SDK, TFM and package upgrades are routine | Upgrade lead time, number of projects on the current TFM |
Every file and rule below should serve one of these goals. If a rule doesn't, drop it.
repo/
|-- global.json SDK 10.0.1xx, rollForward latestFeature, allowPrerelease false
|-- nuget.config <clear/>, internal feed with upstreams, package source mapping
|-- Directory.Packages.props CPM, transitive pinning, GlobalPackageReference analysers
|-- Directory.Build.props net10.0, Nullable, AnalysisLevel, UseArtifactsOutput, lock files
|-- Directory.Build.targets guard targets, packaging defaults for libraries
|-- .editorconfig code style and analyser severities
|-- .github/CODEOWNERS build files -> @platform-build
|-- eng/ shared pipeline templates, scripts, docs, CHANGELOG.md
|
|-- src/
| |-- Directory.Build.props imports root; TreatWarningsAsErrors on
| `-- <team>/<service>/*.csproj 5 to 10 lines each
|-- tests/
| |-- Directory.Build.props imports root; IsPackable=false; test-only NoWarn
| `-- ...
`-- samples/
`-- Directory.Build.props imports root; relaxed analysers
| Layer | Owns | Who can change it |
|---|---|---|
| Root files | Organisation-wide defaults and security settings | Platform team, through CODEOWNERS review |
src/, tests/, samples/ props |
Settings for a kind of project | Platform team, with input from teams |
| Team folder props (optional) | A team's own conventions, inside the root rules | The team, as long as the property audit passes |
| Project file | References, output type, genuine exceptions | The owning team |
Each nested file must import its parent with GetPathOfFileAbove; the property audit in section 5 catches the ones that don't.
| Policy | Encoded as | Enforced by |
|---|---|---|
| One SDK | global.json |
setup-dotnet with global-json-file, dotnet --info in every job |
| One TFM | <TargetFramework> in the root props |
Guard target that fails for other TFMs without an exception property |
| One version per package | Directory.Packages.props |
NU1008 and NU1010 at restore; VersionOverride disabled in shared libraries |
| Known package sources | nuget.config source mapping |
NU1507 treated as an error |
| Reproducible restore | Lock files | dotnet restore --locked-mode in CI |
| Code style | .editorconfig |
dotnet format --verify-no-changes |
| Warnings | AnalysisLevel, TreatWarningsAsErrors per layer |
The build itself |
| Vulnerable packages | NuGet Audit | Audit warnings as errors on main for high and critical |
A rule that lives only on a wiki page drifts within weeks. A rule in a build file applies to every build.
stage 1: AnalysisLevel=latest-recommended everywhere, warnings visible, nothing fails
stage 2: TreatWarningsAsErrors=true for new projects and projects already at zero warnings
stage 3: teams opt in folder by folder (EnableStrictBuild=true in their props)
stage 4: strict becomes the default; remaining projects opt OUT with a ticket and expiry
stage 5: raise AnalysisLevel or add rules; repeat
NoWarn entry needs a comment with the reason and a ticket. Prefer .editorconfig severities scoped to folders over project-wide NoWarn.Property audit in CI. -getProperty evaluates only the project you name, so loop over every project file and fail on outliers:
for proj in $(git ls-files 'src/*.csproj' 'tests/*.csproj'); do
values=$(dotnet msbuild "$proj" -getProperty:TargetFramework -getProperty:TreatWarningsAsErrors \
-getProperty:Nullable -getProperty:ManagePackageVersionsCentrally)
echo "$proj $values" >> property-audit.txt
done
# then compare property-audit.txt with the expected defaults and the exceptions register
Print the table before and after every change to the build files, so reviewers see exactly which projects moved.
Guard targets. Small, cheap checks in Directory.Build.targets that fail the build locally and in CI, for example an unsupported TFM without AllowLegacyTargetFramework. Only write guards for things the SDK doesn't already enforce: with CPM, a Version on a PackageReference already fails with NU1008.
Exceptions register. A file in eng/ listing every VersionOverride, NoWarn, legacy TFM and opt-out, with owner, reason, ticket and expiry date. CI fails if an exception in code isn't in the register, and the register is reviewed quarterly.
PR opened
|
v
changed files --> project graph --> affected projects + everything that depends on them
|
v
shared template:
setup-dotnet (global.json)
restore --locked-mode (NuGet cache keyed on lock files)
format --verify-no-changes (affected projects only)
build -c Release -graph
test affected test projects (TRX + coverage)
upload binlog on failure
|
main branch and nightly: full build and full test suite
ProjectReference graph. Community tools such as dotnet-affected compute it, or a traversal project can be generated from the changed set. Keep a nightly full build as the safety net.dotnet commands developers run locally; no build logic exists only in YAML.| Lever | Effect | Watch out for |
|---|---|---|
| Correct incremental builds | Unchanged projects are skipped | Custom targets without Inputs and Outputs break it for everyone |
UseArtifactsOutput |
All output under artifacts/, easy to cache and clean |
Scripts that expect bin/ paths |
Static graph (-graph) |
Better scheduling of large builds; needed by some caching tools | Projects that call MSBuild tasks dynamically |
| Affected builds | PR builds scale with the change, not the repo | Needs a nightly full build |
| NuGet cache keyed on lock files | Fast restores | Cache poisoning if keys are too broad |
| Build caching plug-ins (for example Microsoft's MSBuildCache) | Reuse outputs across machines | Requires deterministic, well-declared targets |
| Solution filters for IDEs | Developers load only their slice | CI still needs the full graph |
Measure with binary logs: the Structured Log Viewer shows the slowest projects and targets. Publish build-time trends so regressions are noticed within days, not months.
eng/CHANGELOG.md, linked from release notes, so a team whose build broke can see what changed.| Upgrade | Cadence | Mechanism |
|---|---|---|
| SDK patch | Monthly | rollForward: latestFeature plus updated agent images; global.json bump PR |
| Packages | Weekly | Dependabot or Renovate, grouped by family |
Analyser packs and AnalysisLevel |
Quarterly | Canary, then ratchet |
| TFM | Each LTS (yearly evaluation of STS) | One-line root change, canary branch, team-by-team fixes |
| Preview SDKs (.NET 11 now) | Scheduled canary job | Separate branch with its own global.json, never main |
.NET 8 and .NET 9 reach end of support on 10 November 2026, so a governed repo should already have everything on net10.0; anything left is an exception with an owner and a date.
| Metric | Why |
|---|---|
| Median and p95 PR build time | Developer experience; the first thing teams complain about |
| Local incremental build time | Inner-loop speed |
| Warning count per team | Shows the ratchet working |
Number of exceptions (overrides, NoWarn, legacy TFMs) |
Should trend down |
| Distinct package versions | Drift indicator |
| Time from vulnerability advisory to patched main | Security posture; what auditors ask for |
| Percentage of projects on the current TFM | Lifecycle health |
.NET 11 is due in November 2026 as an STS release. Run it in a scheduled canary job with its own global.json to find breaking changes and new warnings early, but keep main on the .NET 10 SDK and net10.0 until you decide, as an organisation, whether to adopt the STS release or wait for the next LTS.
src/ and tests/, a root Directory.Build.props with UseArtifactsOutput, and layered props files that import the root.Directory.Packages.props, convert two projects to CPM, then add a Version to one PackageReference and read NU1008.net10.0, set one project to net8.0 and build; then add AllowLegacyTargetFramework to that project.dotnet build -bl, open the log in the Structured Log Viewer and find the slowest target.ProjectReference graph, which projects a PR build would need to rebuild and test.<!-- Directory.Build.props (root) -->
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<UseArtifactsOutput>true</UseArtifactsOutput>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<Company>Contoso Pty Ltd</Company>
</PropertyGroup>
<PropertyGroup Condition="'$(CI)' == 'true' or '$(TF_BUILD)' == 'true'">
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup>
</Project>
<!-- Directory.Build.targets (root): cheap guard rails -->
<Project>
<!-- Runs per target framework; the outer build of a multi-targeted project has no TargetFramework, so skip it -->
<Target Name="EnforceBuildPolicy" BeforeTargets="CoreCompile" Condition="'$(TargetFramework)' != ''">
<Error Condition="'$(TargetFramework)' != 'net10.0' and '$(AllowLegacyTargetFramework)' != 'true'"
Text="$(MSBuildProjectName) targets $(TargetFramework). Use net10.0 or register an exception (AllowLegacyTargetFramework)." />
</Target>
</Project>
# CODEOWNERS
/global.json @contoso/platform-build
/nuget.config @contoso/platform-build
/Directory.*.props @contoso/platform-build
/Directory.*.targets @contoso/platform-build
/.editorconfig @contoso/platform-build
GITHUB_ACTIONS sets CI=true and Azure Pipelines sets TF_BUILD=True; MSBuild compares strings case-insensitively, so the condition matches both. Libraries that still multi-target for older consumers set AllowLegacyTargetFramework in their project file, which makes the exception visible in review.
Mistake: governance by wiki.
Rules written in a document but not in build files drift within weeks, and nobody can tell which projects follow them.
Instead: encode every rule in the root build files, .editorconfig and CI templates, and keep the document as an explanation of those files.
Mistake: turning on every analyser as an error on day one.
A legacy repo instantly has thousands of errors, so teams add blanket NoWarn entries and the policy becomes meaningless.
Instead: ratchet: make warnings visible first, enforce on new and clean projects, roll out folder by folder, and track the count per team.
Mistake: no exception process. Teams with a genuine need work around the policy with local overrides that nobody can see, review or remove. Instead: provide a documented way to ask for an exception, record each one with an owner, reason and expiry date, and review the register quarterly.
Mistake: putting build logic in CI scripts.
Steps that exist only in YAML can't be run locally, so "works on my machine" failures multiply and developers can't reproduce CI.
Instead: keep build behaviour in MSBuild files and dotnet commands, and make CI templates thin wrappers that run the same commands developers run.
Mistake: ignoring build performance until it hurts. When PR builds reach 40 minutes, teams batch changes, skip tests or argue to split the repo. Instead: measure build times from day one, use affected-project builds with a nightly full build, and treat regressions as bugs.
Mistake: expensive custom targets without Inputs and Outputs.
They run on every build and stop MSBuild from skipping up-to-date projects, slowing down all twelve teams.
Instead: give every custom target Inputs and Outputs, keep guard targets cheap, and check new targets in a binary log before merging.
Mistake: nested props files that don't import the root. A team's folder file silently replaces the organisation's defaults, and their projects escape the policy. Instead: require an import of the parent in every nested file, and run a property audit in CI that fails when a project's key settings differ from the defaults.
Mistake: one person owning the build files. Knowledge and review capacity sit with one engineer, changes have no changelog, and everything stalls when they're away. Instead: give ownership to a small platform team through CODEOWNERS, keep a build changelog and document the contract.
Mistake: upgrading the SDK, TFM and every package in one release. When something breaks, nobody can tell which change caused it, and the rollback is huge. Instead: upgrade on separate cadences (SDK monthly, packages weekly in groups, TFM per LTS) with a canary step for each.
Mistake: governance that only blocks. If the rules only fail builds and never make work easier, teams lose trust in them and push to leave the monorepo. Instead: publish metrics that show the benefits (faster builds, quicker patching), offer help channels, and make the right thing the easy default.
At staff level the job is to make the right thing the easy thing for twelve teams at once. That means encoding policy in the build itself, layering it so teams have room where it's safe, and running it as a product with owners, a changelog, metrics and an exception process with expiry dates. The trade-offs are explicit: central control gives consistency and fast patching but couples teams; ratcheting rules gives adoption but slower convergence; affected builds give speed but need a nightly full build as a safety net. A good design can also be explained to auditors and new starters in ten minutes, because every rule maps to a file.
I'd add a Directory.Packages.props at the repo root that sets ManagePackageVersionsCentrally to true and lists one PackageVersion per package. Then I'd strip the Version from every PackageReference, or restore fails with NU1008. First I inventory the current versions with dotnet list package and choose one version for each package, usually the highest that the tests pass with. Analysers go in as GlobalPackageReference items. Transitive pinning lets the central file also patch vulnerable transitive packages. VersionOverride covers genuine exceptions, but only with review. The pitfalls: nested Directory.Packages.props files hiding the root one, NU1507 when there are several feeds without source mapping, and upgrades that become "big bang" unless Dependabot or Renovate update the central file in small batches.
Directory.Build.props and Directory.Build.targets are ordinary MSBuild files that the SDK imports automatically into every project below them. For each project, MSBuild searches upwards and uses the nearest one it finds. .props is imported early, before the project's own content, so it's for default properties and items: target framework, Nullable, analysers, package metadata. Projects can still override those values. .targets is imported late, after the project, so it's for targets and logic that need final values. Only the nearest file is picked up. To layer them, the inner file imports the outer one with GetPathOfFileAbove. I debug them with dotnet msbuild -preprocess or a binary log.
The pipeline restores three times, builds in Debug, runs dotnet format in a way that silently fixes files instead of failing, and publishes from a different build than the one it tested. I'd use global.json for the SDK, restore once with --locked-mode, build once in Release with --no-restore, then test and publish with --no-build. I'd run dotnet format --verify-no-changes so formatting drift fails the build, collect test results and coverage, and cache the NuGet folder.
How to tell it (STAR)
**Situation:** After merging [14] repos into a monorepo of about [250] projects at [company], PR builds took [38] minutes and we had [seven] SDK versions and wildly different package versions. **Task:** As [staff engineer for the platform], I had to design build governance that twelve teams would actually adopt. **Action:** I encoded the rules in root build files (SDK pin, layered props, central package versions with transitive pinning, source mapping), gave the platform team ownership through CODEOWNERS, added an exception process, and introduced shared CI templates with affected-project builds. Strict warnings rolled out layer by layer instead of all at once. **Result:** Typical PR builds dropped to [under 10] minutes, distinct package versions fell by [N%], and the next critical transitive vulnerability was patched across every service in [one afternoon] instead of [weeks].
Last updated 1 Nov 2026.