Standardize or Drift: One Defaults Package for All Your Solutions
The third solution is always the one that breaks you.
The first one had a Directory.Build.props somebody hand-rolled on a Friday. The second copied it because copying is faster than thinking. By the third, the props file has diverged in three small ways across the three repositories, the analyzer ruleset is somewhere between “off” and “on but quietly ignored”, the NuGet metadata is half-filled because nobody remembered which fields were required, and the LangVersion is whatever the most recent dev set when they got tired of waiting for the team to decide.
Nobody is wrong. Everybody is inconsistent.
The standard answer is “we’ll add a Directory.Build.props to every repo.” It works, until it doesn’t, because there is no upgrade path. Tighten a rule centrally and you’ve got a manual chore to apply it across N repos, in N pull requests, with N reviewers who have other things to do. Templates (dotnet new) get this even more wrong: they snapshot defaults at fork time and then never improve.
NetEvolve.Defaults is what happens when you stop accepting that trade-off and ship your build defaults as a versioned NuGet package. Every repo references it like any other dependency. Bumping the version is one PR per repo, fully tooled by Renovate or Dependabot, and the entire surface (MSBuild properties, targets, .editorconfig, analyzer rules, NuGet quality enforcement, GitVersion configuration) upgrades together. This article walks through what’s in the box, why a NuGet package is the right delivery vehicle, and where the approach stops scaling.
The Drift Problem Has a Name
Look at three .NET solutions that have been alive for more than a year. Solution A pinned LangVersion=12, has nullable on, ships no analyzers because “we’ll add them later.” Solution B is on LangVersion=preview, nullable warnings only, analyzers configured by hand against an older ruleset. Solution C copy-pasted its Directory.Build.props from A two years ago and nobody has touched it since.
That’s the drift. Not malice, not negligence. The .NET ecosystem accumulates configuration faster than humans naturally synchronize. Every solution becomes a small archaeological site. Auditing the diffs across them is an afternoon nobody schedules.
A Directory.Build.props per repo doesn’t fix this: it just localizes the drift. The file is still hand-maintained, still copy-pasted, still divergent. You haven’t centralized anything; you’ve added a layer that hides the divergence one step deeper.
Why a NuGet Package Is the Right Vehicle
A NuGet package is a versioned, signed, discoverable artifact with built-in upgrade tooling. That’s exactly the shape of the problem.
Reference the package with PrivateAssets="all" and three things happen at build time. NuGet pulls build/NetEvolve.Defaults.props and injects it into the build before Directory.Build.props, so the defaults set the floor. build/NetEvolve.Defaults.targets runs after Microsoft.Common.targets, so post-build customizations get applied. The .editorconfig ships as build content, so the IDE picks it up without anyone copying a file. Analyzer DLLs register through the standard analyzer-package convention, so diagnostics light up live in the editor.
The whole installation looks like this:
<ItemGroup>
<PackageReference Include="NetEvolve.Defaults" Version="x.x.x" PrivateAssets="all" />
<PackageReference Include="NetEvolve.Defaults.Analyzer" Version="x.x.x" PrivateAssets="all" />
</ItemGroup>
PrivateAssets="all" is the load-bearing attribute. It tells NuGet “build-time only, never propagate this to anyone who consumes my package”. You don’t want your opinions leaking into other people’s projects, and you don’t want your downstream consumers locked into your build defaults. The setting is also what makes the analyzer rules quiet when your package is being consumed downstream: they only run inside your build.
The upgrade story is the part that templates can never match. Bump the version once in Directory.Packages.props, open a PR in each consumer, let Renovate or Dependabot bundle the chore. Tighter analyzer rules, updated .editorconfig, new MSBuild properties: they all land in one diff per repo. There is no manual cross-repo diff hunt anymore.
What’s Actually in the Box
The repository ships two packages with clearly separated responsibilities.
NetEvolve.Defaults is the foundational layer. Its Directory.Build.props declares the package itself as a source-only artifact with description, repository URL, project URL, package tags, and a CopyrightYearStart of 2024, the same metadata it then asks every consumer to fill in. Inside the NuGet build folder, props and targets get injected automatically, supporting both single-target and multi-target projects. The package ships template files for .editorconfig, .gitignore, .gitattributes, and .csharpierignore under src/NetEvolve.Defaults/configurations, the bits every repo wants identical and nobody enjoys hand-syncing. Modern C# is the baseline: nullable reference types on, implicit usings on, file-scoped namespaces, and the C# 13 language features that ship with .NET 10. NuGet Audit is wired in for vulnerability scanning, and GitVersion handles semantic versioning so the version number falls out of git history instead of being argued about in stand-up.
NetEvolve.Defaults.Analyzer is the enforcement layer. Ten diagnostic rules (NED0001 through NED0010) catch the NuGet package metadata problems that always surface six months too late: when someone realises the package on nuget.org has no description, the repo link is broken, the authors field is missing, and nobody put a license expression in the project file before the legal team noticed. Each rule emits a Roslyn diagnostic with a fix description and a link to a usage doc explaining the why, not just the what.
The rule set covers the metadata fields you regret losing later:
- NED0001: Missing PackageId. Without it the package can’t be created or published; consumers can’t reference what doesn’t exist.
- NED0002: Missing Package Title. The human-readable name shown in nuget.org search.
- NED0003: Missing Package Description. The blurb that decides whether anyone clicks.
- NED0004: Missing Package Tags. Discovery.
- NED0005: Missing PackageProjectUrl. The “where does this thing live” link.
- NED0006: Missing Repository URL. The “where does the source live” link, separate from project URL by NuGet convention.
- NED0007: Missing Authors. Attribution.
- NED0008: Missing Company. Organisational ownership for compliance and licensing.
- NED0009: Missing CopyrightYearStart. Lets the targets file compute a sensible
Copyrightstring instead of last-year-or-this-year hand-coded. - NED0010: Missing Recommended Analyzer Package. Build-config rule for all projects (not just packable ones): surfaces consumers that pulled in
NetEvolve.Defaultsbut forgot the matching analyzer.
These are all Warning severity. Real misery comes from finding any one of them missing in a package that’s already on nuget.org with three hundred downloads. That’s exactly where a build-time analyzer earns its keep.
What Customization Still Looks Like
Centralised defaults only work if they remain overridable. The package follows the standard MSBuild override rules: any setting can be tightened, loosened, or replaced in the consuming project’s own .csproj or Directory.Build.props. Want preview language features in one repo only? Override <LangVersion>preview</LangVersion>. Need to disable nullable for a legacy project the package can’t refactor for you? Set <Nullable>disable</Nullable> locally. Targeting a custom framework matrix? <TargetFrameworks>net8.0;net9.0</TargetFrameworks> wins over whatever the package set.
The escape hatches exist precisely so the defaults can be opinionated. A package that tries to be infinitely configurable becomes its own configuration problem. NetEvolve.Defaults stays opinionated and lets you override one property at a time when you really need to.
Where the Approach Wins
The biggest win isn’t initial setup. It’s the next tightening pass.
A centralised analyzer rule that catches missing repository URLs on every package the org ships, applied across thirty repositories, by bumping a single version in Directory.Packages.props. That is the kind of leverage hand-edited build files cannot match.
Other concrete wins compound from there. CI/CD environments get auto-detected and optimised: the package recognises a CI build and tunes accordingly, so you don’t have to remember which <ContinuousIntegrationBuild>true</ContinuousIntegrationBuild> flag to set where. NuGet Audit runs as part of the default build, so vulnerability scanning is on by default rather than opt-in by remembering. GitVersion produces a deterministic version number from git history and tags, ending per-repo bikeshedding about whether the next bump is 1.4.0 or 1.3.6. And the .editorconfig baseline lives in one place, applied to every repo, evolved through the same upgrade flow as the props and targets.
Where It Stops Scaling
The honest pushback: this is opinionated. It encodes one organisation’s view of how a .NET project should be built, packaged, and versioned. Pull it into a team with different opinions (manual semantic versioning instead of GitVersion, looser analyzer policies, different package-metadata expectations) and you spend more time disabling things than you save by adopting it.
GitVersion is the sharpest coupling. If you run a manual-tag versioning shop, NetEvolve.Defaults’ assumption that GitVersion drives the version number fights you. Analyzer noise on legacy repos is the second friction point: dropping ten new diagnostics onto a brownfield codebase will produce dozens of warnings, and you’ll need a controlled <NoWarn> escape hatch, or a phased adoption where you fix the diagnostics in waves rather than all at once.
The third caveat is reach. A defaults package works inside one organisation’s value system. It does not translate cleanly across organisations with different conventions. That’s fine. The point isn’t to write the defaults package; it’s to write your defaults package and treat it as production infrastructure.
When to Roll Your Own
The scaling threshold is honestly low. One or two solutions? A hand-maintained Directory.Build.props is enough. Three to ten? Centralised defaults pay back in months. The upgrade-flow leverage alone covers the setup. Ten or more solutions, or distributed teams across multiple time zones? Drift is otherwise terminal, and a defaults package is no longer a nice-to-have.
The principle generalises. Build configuration is production code. It deserves the same treatment: versioned, packaged, tested, upgraded through the same channels as your runtime dependencies. The friction of doing this right is one weekend of work the first time. The friction of not doing it compounds quarter over quarter for the life of every repository that depends on the drifted version.
NetEvolve.Defaults is one concrete answer to that problem. It’s not the only possible shape: a private feed, an internal SDK, a Directory.Build.props in a git submodule all solve some part of it. But shipping defaults as a normal NuGet package is the lowest-friction way to get the upgrade flow right, and that flow is the part that quietly determines whether the defaults stay current or rot.
Standardise or drift. Pick one before the third solution forces the choice on you.
Build configuration is production code. Version it, package it, upgrade it through the same channels as the runtime dependencies it sits next to.

Comments