Code review catches what a reviewer happens to notice; static analysis catches what a rule always checks, on every build, before a human ever opens the diff. This guide covers the .NET code quality toolchain end to end: the built-in Roslyn analyzers and their AnalysisLevel and AnalysisMode settings, configuring rule severity through .editorconfig, TreatWarningsAsErrors and WarningsAsErrors, dotnet format for automatic style enforcement, third-party analyzers like StyleCop, Roslynator and SonarAnalyzer, nullable reference types as a quality gate, architecture tests, and wiring all of it into a CI quality gate that actually blocks a bad merge instead of just decorating a build log with warnings nobody reads.
What Is Static Code Analysis in .NET?#
Static analysis inspects your code without running it, looking for patterns that are likely bugs (CA and CS diagnostics), style inconsistencies, and, with more advanced tools, structural violations like a layer reaching into another layer it should not know about. .NET's own compiler and SDK ship a substantial set of analyzers for free: code-quality analyzers (CAxxxx rules, covering security, performance, reliability and design), code-style analyzers (IDExxxx rules, covering formatting and idiom preferences such as var usage), and the C# compiler's own nullable-reference-type warnings (CS86xx). None of this requires a separate NuGet package on a modern SDK; it requires knowing which knobs turn each layer up or down.
How the Built-In Analyzers Work: AnalysisLevel and AnalysisMode#
Code-quality analysis is enabled by default for any project targeting .NET 5 or later, through the EnableNETAnalyzers property (true by default on the SDK; set it explicitly only for a project still targeting .NET Standard or .NET Framework). By default, only a small set of rules run as build warnings; analysis mode decides how much further that goes:
<PropertyGroup>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
</PropertyGroup>AnalysisMode accepts None (every rule off, opt in individually), Default (the SDK's small default set), Minimum, Recommended (a broader, still practical set), and All (every rule enabled, opt out of what you do not want). AnalysisLevel is the more commonly used property: it defaults to latest, so every SDK upgrade brings newer rules automatically, and it accepts a compound value like latest-Recommended to combine a version with a mode in one setting, or a pinned number like 8.0 to lock the rule set and stop new rules from appearing on SDK upgrades until you deliberately move it forward. Pinning is a legitimate choice for a large, established codebase that wants to absorb new rules on its own schedule rather than being surprised by them after global.json changes.
Getting Started: A Baseline Analyzer Configuration#
A reasonable starting point for a new project or one adopting analysis for the first time:
<PropertyGroup>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<Nullable>enable</Nullable>
</PropertyGroup>EnforceCodeStyleInBuild promotes IDExxxx code-style rules, which otherwise only appear as editor suggestions inside Visual Studio, into real build warnings, so a style violation is visible in a headless CI build and not just to whoever happens to have the file open locally. Put this block in Directory.Build.props so every project in a solution inherits the same baseline instead of drifting project by project.
EditorConfig: Configuring Rule Severity#
.editorconfig is where you tune individual rules once the baseline above is in place, using a consistent, three-level syntax. Set one rule's severity directly:
[*.cs]
dotnet_diagnostic.CA1822.severity = errorSet a whole category at once, which only affects rules already enabled by default in that category:
dotnet_analyzer_diagnostic.category-Performance.severity = warningOr set a blanket default for every enabled rule, as a floor beneath the two more specific forms above:
dotnet_analyzer_diagnostic.severity = suggestionPrecedence is rule-ID first, then category, then the blanket setting, so a specific dotnet_diagnostic entry always wins over a broader one. Valid severities are error, warning, suggestion, silent (no visible output, but still runs and can still be suppressed or escalated) and none (the rule does not run at all). Commit .editorconfig to source control at the repository root; it is the one file that keeps Visual Studio, Rider, VS Code and a headless CI build applying literally the same rules.
Treating Warnings as Errors#
Warnings a team ignores are warnings that stop meaning anything. TreatWarningsAsErrors turns every enabled warning into a build failure:
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsNotAsErrors>CS1591</WarningsNotAsErrors>
</PropertyGroup>WarningsNotAsErrors carves out specific exceptions, useful while you are still working through a backlog of one particular rule without blocking the build on everything else. The narrower WarningsAsErrors property does the opposite: instead of escalating everything, it escalates only the specific codes you list, which is the gentler on-ramp for a large, existing codebase that is not ready for a blanket TreatWarningsAsErrors yet:
<PropertyGroup>
<WarningsAsErrors>CS8600;CS8602;CS8603;CS8625</WarningsAsErrors>
</PropertyGroup>That specific set of codes escalates the core nullable-reference-type warnings, so a null that the compiler can prove might flow into a non-nullable parameter or return fails the build instead of only appearing as a warning someone can scroll past. If code-quality (CA) warnings specifically should not be swept up by a blanket -warnaserror build flag, CodeAnalysisTreatWarningsAsErrors set to false carves that category out independently of the C# compiler's own warning-as-error handling.
dotnet format: Enforcing Style Automatically#
dotnet format, built into the SDK since .NET 6, applies your .editorconfig style and, optionally, analyzer fixes across a project or solution:
dotnet format --verify-no-changes--verify-no-changes is the CI-friendly mode: it makes no edits and exits non-zero if anything would have changed, which is what actually blocks a pull request that was not formatted before pushing. Run it without that flag locally, or wire it into a pre-commit hook, to apply the fixes instead of just reporting them:
dotnet format --severity warn --exclude "**/Migrations/**"--severity controls the minimum severity dotnet format acts on, and --exclude keeps generated code, like EF Core migrations, out of scope, since reformatting generated files creates noisy diffs for no benefit. Because dotnet format restores, compiles and runs analyzers against the target project, only invoke it against code you trust, the same caution that applies to running any other build step.
Third-Party Analyzers: StyleCop, Roslynator and SonarAnalyzer#
The built-in analyzers cover correctness and a useful slice of style; third-party analyzer packages add depth in areas the SDK does not fully cover:
<ItemGroup>
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
<PackageReference Include="Roslynator.Analyzers" Version="5.0.0" PrivateAssets="All" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.34.0.3385" PrivateAssets="All" />
</ItemGroup>StyleCop.Analyzers enforces layout and documentation conventions (brace placement, using ordering, mandatory XML doc comments) that the built-in style analyzers mostly leave alone. Roslynator.Analyzers adds a very large set of refactoring-oriented rules and code fixes, many aimed at simplification and redundancy rather than strict style. SonarAnalyzer.CSharp focuses on bugs, code smells and security hotspots, drawing on the same rule engine SonarQube and SonarCloud use for server-side analysis, which makes it a reasonable choice when you want local, build-time results to match what a SonarQube dashboard would later report. PrivateAssets="All" on every one of these keeps the analyzer itself from becoming a transitive dependency of anything that references your project. Adopt one at a time; running all three with their full rule sets enabled on day one against an existing codebase usually produces more noise than signal, and it is easier to triage findings from a single new analyzer than three at once.
Nullable Reference Types as a Quality Gate#
<Nullable>enable</Nullable> turns on the compiler's nullable-reference-type analysis, which is static analysis in its own right: CS86xx warnings flag a dereference, assignment or argument the compiler cannot prove is non-null. Escalating the core codes to errors, as shown earlier with WarningsAsErrors, converts "the compiler suspects a bug" into "the build will not succeed until you address it," which is a meaningfully stronger guarantee than leaving them as warnings a busy reviewer might not notice in a large diff. The nullable reference types guide covers annotation syntax, generics and library-boundary considerations for adopting this in an existing codebase in depth; the quality-gate question here is simply whether the warnings block a merge or not.
Architecture Tests: Enforcing Structure, Not Just Style#
Analyzers check individual files; architecture tests check the relationships between them, catching a layering violation, like your domain layer taking a dependency on infrastructure, that no single-file rule can see. NetArchTest.Rules expresses these as ordinary assertions inside a normal test project:
using NetArchTest.Rules;
using Xunit;
public class ArchitectureTests
{
[Fact]
public void Domain_Should_Not_Depend_On_Infrastructure()
{
var result = Types.InAssembly(typeof(Order).Assembly)
.That()
.ResideInNamespace("Contoso.Domain")
.ShouldNot()
.HaveDependencyOn("Contoso.Infrastructure")
.GetResult();
Assert.True(result.IsSuccessful, string.Join(", ", result.FailingTypeNames ?? []));
}
}These tests run in the same dotnet test step as everything else, so a pull request that quietly introduces a forbidden dependency fails CI the same way a broken unit test would. ArchUnitNET, modeled on Java's ArchUnit, covers similar ground with a more extensive fluent rule API for larger codebases. The clean architecture guide covers the layering rules worth enforcing this way in practice; this section is about turning those rules into an automated, unskippable check.
Best Practices#
- Start every new project from
latest-Recommended, and raise an established codebase toward it gradually rather than jumping straight toAll. - Commit
.editorconfigat the repository root so every editor and CI enforce identical rules, with no per-developer drift. - Escalate nullable warnings to errors once a codebase's nullable annotations are accurate; a warning nobody is required to fix stops functioning as a safety net.
- Run
dotnet format --verify-no-changesin CI, not just as a local convenience, so formatting is enforced rather than merely suggested. - Adopt third-party analyzers one at a time, triaging the first analyzer's findings to a manageable baseline before adding a second.
- Write architecture tests for the boundaries you actually care about, not every conceivable rule; a handful of enforced, meaningful boundaries beats a large suite nobody maintains.
Common Pitfalls#
- Enabling
AnalysisMode=Allon a large existing codebase in one step, producing thousands of warnings nobody triages, which trains the team to ignore the warnings list entirely. - Setting
TreatWarningsAsErrorswithout first fixing existing warnings, which simply breaks the build for everyone until someone reverts it. - Treating
.editorconfigas a local-only file and never committing it, so CI and different developers' editors silently disagree about the rules. - Running
dotnet formatlocally but never in CI, which means unformatted code still merges whenever someone forgets to run it. - Stacking StyleCop, Roslynator and SonarAnalyzer with full default severity on day one, drowning genuine findings in style noise.
- Suppressing a warning with
#pragma warning disableinstead of fixing or deliberately excepting it, which hides the issue from every future reader of the file, not just the current build.
Choosing an Analyzer Stack#
| Tool | Focus | Runs in | Best fit |
|---|---|---|---|
Built-in .NET analyzers (CAxxxx) | Correctness, security, performance, design | dotnet build, no extra package | Every project, as the baseline |
C# nullable warnings (CS86xx) | Null-safety | dotnet build, compiler itself | Every project targeting modern C# |
| StyleCop.Analyzers | Layout, documentation conventions | dotnet build | Teams that want strict, consistent formatting and doc comments |
| Roslynator.Analyzers | Simplification, redundancy, refactoring | dotnet build | Teams that want proactive code-fix suggestions |
| SonarAnalyzer.CSharp | Bugs, code smells, security hotspots | dotnet build, or SonarQube/SonarCloud | Teams already using Sonar server-side, or wanting its rule set locally |
| NetArchTest.Rules / ArchUnitNET | Structural and layering rules | dotnet test | Enforcing architecture boundaries, not per-file style |
Frequently Asked Questions#
What is the difference between AnalysisLevel and AnalysisMode?#
AnalysisMode picks a named rule set (None, Default, Minimum, Recommended or All). AnalysisLevel is the more commonly set property: on its own it pins the SDK version whose rules you want, defaulting to latest, and it can also carry a compound value like latest-Recommended that sets both the version and the mode in a single property instead of setting AnalysisMode separately.
Should I set TreatWarningsAsErrors on a large, existing codebase right away?#
Not in one step. Start with the narrower WarningsAsErrors, escalating a short list of codes you have already fixed everywhere, and expand that list as the warning backlog shrinks. Flipping TreatWarningsAsErrors to true on a codebase with hundreds of existing warnings just breaks the build for everyone until someone reverts it under pressure.
Do I still need dotnet format if my editor formats on save?#
Yes, because not everyone's editor is configured the same way, and CI has no editor at all. dotnet format --verify-no-changes in CI is the only guarantee that formatting is actually enforced rather than merely encouraged, regardless of what any individual developer's local setup does.
Can I use StyleCop, Roslynator and SonarAnalyzer together?#
Yes, they are complementary rather than redundant: StyleCop focuses on layout and documentation, Roslynator on simplification and refactoring, and SonarAnalyzer on bugs and security. Add them one at a time and triage the findings, since enabling all three's full default rule sets simultaneously on an existing codebase usually produces more noise than a team can reasonably act on at once.
How is an architecture test different from a unit test?#
A unit test verifies behavior: given this input, does the code produce the expected output. An architecture test verifies structure: does this namespace depend on that one, does a class implement a required interface, does a layer stay isolated from another layer, independent of what any individual method actually does. Both run through the same dotnet test command and the same CI gate.
Summary#
- Built-in .NET analyzers are on by default from .NET 5 onward;
AnalysisLevelandAnalysisModecontrol how much further they go beyond the small default rule set. .editorconfigconfigures rule severity with a clear precedence: rule ID, then category, then a blanket default, and should be committed so every environment agrees.- Use
WarningsAsErrorsfor a gradual rollout andTreatWarningsAsErrorsonce a codebase's warning backlog is actually clean. dotnet format --verify-no-changesin CI is what makes formatting enforced rather than optional.- Add third-party analyzers like StyleCop, Roslynator and SonarAnalyzer one at a time, and use architecture tests to enforce structural boundaries analyzers cannot see.
- None of this matters without a CI gate that actually blocks a merge on failure; see the CI/CD guide for wiring it in.