Roslyn analysers in practice: configuration, banned APIs, and a minimal custom DiagnosticAnalyzer.
Roslyn is the C# compiler exposed as an API: code becomes syntax trees, a semantic model that knows what every symbol means, and language-neutral operations. A Roslyn analyser is a plug-in that subscribes to those and reports diagnostics in the IDE and in every build, so team rules become compiler warnings or errors instead of review comments. Start with what ships with the SDK: the .NET analysers (CAxxxx) and code style rules (IDExxxx), tuned with AnalysisLevel/AnalysisMode, .editorconfig severities and EnforceCodeStyleInBuild. BannedApiAnalyzers bans specific APIs from a BannedSymbols.txt file with no custom code. When you need a domain rule, write a DiagnosticAnalyzer targeting netstandard2.0, register operation or symbol actions, enable concurrent execution, test it, and ship it as a NuGet package or project reference.
Roslyn analysers are compiler plug-ins that inspect code during compilation and report diagnostics (errors, warnings, suggestions), optionally with code fixes. They let a team turn conventions into automatic, enforced rules in the IDE and CI. I'd explain the compiler-as-a-platform idea, how built-in analysers and code style rules are configured, BannedApiAnalyzers, and how to write and package a small custom analyser.
Roslyn exposes the compiler's internal models through public APIs:
INamedTypeSymbol, IMethodSymbol, IPropertySymbol… — declarations and references across the compilation (including referenced assemblies).Analysers register callbacks for syntax nodes, symbols, operations or whole compilations, and report Diagnostics. Code fix providers offer automatic rewrites. Source generators use the same APIs.
The .NET SDK ships analysers (Microsoft.CodeAnalysis.NetAnalyzers) enabled by default:
throw ex), CA1852 (seal internal types).var preferences, expression bodies, naming, using placement, file-scoped namespaces.Configuration:
<PropertyGroup>
<AnalysisLevel>latest-recommended</AnalysisLevel> <!-- none, default, minimum, recommended, all -->
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild> <!-- IDE rules also run in dotnet build -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
# .editorconfig
[*.cs]
dotnet_diagnostic.CA2016.severity = error
dotnet_diagnostic.CA1848.severity = warning
csharp_style_namespace_declarations = file_scoped:error
dotnet_style_qualification_for_field = false:warning
.editorconfig (hierarchical, per folder) or .globalconfig for solution-wide severities.Directory.Build.props to apply MSBuild settings to all projects.#pragma warning disable / [SuppressMessage] for justified exceptions (with a justification).Third-party packs: StyleCop.Analyzers, Roslynator, SonarAnalyzer, Meziantou.Analyzer, Microsoft.VisualStudio.Threading.Analyzers (async rules).
Microsoft.CodeAnalysis.BannedApiAnalyzers bans specific APIs with a message:
# BannedSymbols.txt (AdditionalFiles)
P:System.DateTime.Now;Use TimeProvider.GetLocalNow() so time can be tested.
M:System.Threading.Tasks.Task.Wait;Avoid blocking on tasks.
T:System.Net.WebClient;Use IHttpClientFactory.
M:Newtonsoft.Json.JsonConvert.SerializeObject(System.Object);Use System.Text.Json.
Using a banned symbol produces RS0030 with your message. It's the cheapest way to encode "we don't use X here" without writing an analyser.
Example rule: "Don't call DbContext.SaveChanges() synchronously in async methods."
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class SyncSaveChangesAnalyzer : DiagnosticAnalyzer
{
public static readonly DiagnosticDescriptor Rule = new(
id: "ORD001", title: "Use SaveChangesAsync in async methods",
messageFormat: "Call SaveChangesAsync instead of SaveChanges inside async method '{0}'",
category: "Usage", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [Rule];
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterOperationAction(ctx =>
{
var call = (IInvocationOperation)ctx.Operation;
if (call.TargetMethod.Name != "SaveChanges") return;
var dbContext = ctx.Compilation.GetTypeByMetadataName("Microsoft.EntityFrameworkCore.DbContext");
if (dbContext is null || !InheritsFrom(call.TargetMethod.ContainingType, dbContext)) return;
if (ctx.ContainingSymbol is IMethodSymbol { IsAsync: true } method)
ctx.ReportDiagnostic(Diagnostic.Create(Rule, call.Syntax.GetLocation(), method.Name));
}, OperationKind.Invocation);
}
private static bool InheritsFrom(ITypeSymbol? type, INamedTypeSymbol baseType)
{
for (; type is not null; type = type.BaseType)
if (SymbolEqualityComparer.Default.Equals(type, baseType)) return true;
return false;
}
}
Then:
await SaveChangesAsync(ct).Microsoft.CodeAnalysis.Testing (CSharpAnalyzerTest<TAnalyzer, DefaultVerifier>) using markup like {|ORD001:db.SaveChanges()|}.netstandard2.0, reference Microsoft.CodeAnalysis.CSharp (matching the minimum Roslyn version you support) with PrivateAssets="all", and pack the DLL into analyzers/dotnet/cs in the NuGet package (<IncludeBuildOutput>false</IncludeBuildOutput> + a None item with PackagePath). Publish to your internal feed (Azure Artifacts) and reference it from Directory.Build.props.Repeated production incidents came from missing cancellation tokens and synchronous EF calls in async code. We raised CA2016 to error, added Microsoft.VisualStudio.Threading.Analyzers (VSTHRD002/VSTHRD103), banned DateTime.Now and Task.Wait via BannedApiAnalyzers, and wrote a small ORD001 analyser for SaveChanges in async methods — all applied from Directory.Build.props and enforced in CI. Over a sprint the fixers resolved hundreds of findings automatically, and that class of incident stopped recurring.
The interviewer checks whether you can turn team conventions into automated guardrails and understand Roslyn well enough to do it safely.
Green flags
.editorconfig and BannedApiAnalyzers before custom code.netstandard2.0, concurrent execution, compilation start actions, packaging.Red flags
analyzers/dotnet/cs/*.dll) and the SDK.Initialize registers actions; the compiler calls them concurrently (hence EnableConcurrentExecution and thread-safe analysers).GetTypeByMetadataName) and register nested actions, which is much faster than per-node lookups.| API | Use for |
|---|---|
RegisterSyntaxNodeAction |
Purely syntactic rules (formatting, naming patterns) |
RegisterSymbolAction |
Declarations (types, methods, fields) — naming, attributes, accessibility |
RegisterOperationAction |
Semantic usage (calls, assignments, conversions) — most behavioural rules |
RegisterCompilationStartAction / EndAction |
Cross-file analysis, caching symbols |
| Semantic model queries | When you need types of arbitrary expressions |
/reportanalyzer in dotnet build -bl to see analyser times.AnalysisLevel recommended and treat new code strictly; use .editorconfig in subfolders to relax legacy areas temporarily.dotnet format analyzers --diagnostics CA2016).error once the codebase is clean.| Tool | Strength |
|---|---|
| Roslyn analysers | Compile-time, in-IDE feedback, code fixes, precise semantics |
| Architecture tests (NetArchTest) | Dependency rules between namespaces/projects |
| SonarQube/SonarCloud | Cross-repo dashboards, security hotspots, history |
| CodeQL | Security dataflow analysis across the codebase |
They complement each other; analysers give the fastest feedback loop.
AnalysisLevel to latest-all on a project and review new warnings.BannedSymbols.txt banning DateTime.Now.<!-- Acme.Analyzers.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<!-- Keep this no newer than the oldest compiler your consumers build with -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="all" />
</ItemGroup>
</Project>
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;
namespace Acme.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class SystemClockAnalyzer : DiagnosticAnalyzer
{
public const string Id = "ACME001";
private static readonly DiagnosticDescriptor Rule = new(
id: Id,
title: "Use TimeProvider instead of the system clock",
messageFormat: "'{0}' reads the machine clock; inject TimeProvider instead",
category: "Reliability",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Direct clock reads make code untestable and time-zone dependent.");
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterCompilationStartAction(start =>
{
// Resolve symbols once per compilation
INamedTypeSymbol dateTime = start.Compilation.GetSpecialType(SpecialType.System_DateTime);
INamedTypeSymbol? dateTimeOffset = start.Compilation.GetTypeByMetadataName("System.DateTimeOffset");
start.RegisterOperationAction(ctx =>
{
var read = (IPropertyReferenceOperation)ctx.Operation;
IPropertySymbol property = read.Property;
bool isClockType = SymbolEqualityComparer.Default.Equals(property.ContainingType, dateTime)
|| SymbolEqualityComparer.Default.Equals(property.ContainingType, dateTimeOffset);
if (isClockType && property.IsStatic && property.Name is "Now" or "UtcNow")
{
ctx.ReportDiagnostic(Diagnostic.Create(
Rule, read.Syntax.GetLocation(), $"{property.ContainingType.Name}.{property.Name}"));
}
}, OperationKind.PropertyReference);
});
}
}
<!-- Consuming project: custom analyser plus banned APIs -->
<ItemGroup>
<ProjectReference Include="..\Acme.Analyzers\Acme.Analyzers.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" PrivateAssets="all" />
<AdditionalFiles Include="BannedSymbols.txt" />
</ItemGroup>
# BannedSymbols.txt (reported as RS0030)
M:System.Threading.Tasks.Task.Wait;Use await instead of blocking
T:System.Net.WebClient;Use IHttpClientFactory
P:System.Environment.TickCount;Use Stopwatch or TimeProvider
What happens: reviewers repeatedly comment on the same issues. Why it is wrong: inconsistent enforcement and wasted review time. Do instead: encode rules as analysers or banned APIs.
What happens: latest-all with TreatWarningsAsErrors on a legacy codebase.
Why it is wrong: thousands of errors block delivery; teams disable analysers entirely.
Do instead: adopt incrementally with scoped .editorconfig severities.
What happens: IDE rules only show in the editor.
Why it is wrong: CI doesn't catch violations.
Do instead: EnforceCodeStyleInBuild.
What happens: #pragma warning disable at file level or global suppressions without justification.
Why it is wrong: hides real issues.
Do instead: narrow suppressions with justifications, reviewed like code.
What happens: analysers doing symbol lookups per node or heavy allocations. Why it is wrong: IDE lag and slower builds. Do instead: cache symbols in compilation-start actions and enable concurrent execution.
What happens: analysers built against a newer Roslyn than the consumers' SDK.
Why it is wrong: analyser fails to load (CS8032).
Do instead: target netstandard2.0 and the minimum supported Microsoft.CodeAnalysis version.
What happens: custom analysers shipped without tests.
Why it is wrong: false positives/negatives erode trust.
Do instead: use Microsoft.CodeAnalysis.Testing with positive and negative cases.
What happens: custom analyser just to forbid an API.
Why it is wrong: unnecessary maintenance.
Do instead: use BannedSymbols.txt.
Analysers are how a senior engineer scales code review. A rule that lives in a wiki is followed until the next deadline; a rule that is a build error is followed always, and costs no reviewer attention.
| Rule type | Cheapest tool |
|---|---|
| General .NET correctness and performance | Built-in CA rules with AnalysisLevel |
| Formatting and style | IDE rules plus EnforceCodeStyleInBuild, dotnet format |
| "Never call X" | BannedApiAnalyzers |
| Architecture and domain rules | Custom analyser (or architecture tests) |
The judgement calls are rollout and noise. Every false positive teaches developers to suppress, so a custom analyser must be precise, fast and paired with a clear message and, ideally, a code fix. Own the analyser like product code: tests, versioning, a changelog and a documented way to suppress it with justification.
Analysers are plug-ins for the Roslyn compiler. They report diagnostics in the editor and during the build. CA rules (CA1xxx to CA5xxx) are the SDK's code quality analysers, covering design, performance, reliability and security. The .NET analysers are enabled by default for net5.0+ projects (EnableNETAnalyzers), but only a default subset of rules reports as warnings; AnalysisLevel and AnalysisMode set how many apply. IDE rules (IDE0xxx) are code style rules, such as var usage or file-scoped namespaces. They show in the editor but only run in the build when EnforceCodeStyleInBuild is true, which is why style issues often pass CI. Severities for both are set in .editorconfig (or a .globalconfig) with dotnet_diagnostic.<id>.severity. Third-party analysers come in as NuGet packages. For a team, I put the levels in Directory.Build.props, the severities in one root config file, and tighten them step by step.
<Nullable>enable</Nullable> makes reference types non-nullable by default: string? means a value can be null and string means it can't. The compiler then warns when code might dereference null. It's purely compile-time, so nothing changes at run time. In a big codebase I migrate bottom-up: shared libraries and domain types first, because their annotations flow out to everyone else. I go project by project, or file by file with #nullable enable, and give each step a small pull request so feature work carries on. When a project is clean I make its nullable warnings errors with WarningsAsErrors=nullable, so it can't slip back. I use attributes such as [NotNullWhen] where flow analysis needs help and keep ! for real boundaries. I also watch for two things: EF Core migrations, where nullability decides whether columns are required, and public APIs, where annotations are part of the contract.
A source generator is a compiler plug-in that reads your code (usually a marker attribute on a partial declaration) and adds new C# files to the compilation; it can never change or delete your code. .NET ships several: System.Text.Json ([JsonSerializable] on a JsonSerializerContext) for reflection-free serialisation, [GeneratedRegex] for regex compiled to C# at build time, [LoggerMessage] for allocation-free structured logging, [LibraryImport] for P/Invoke marshalling, and in ASP.NET Core the configuration binding and Request Delegate generators that replace reflection in Bind/Get<T> and minimal APIs. They improve startup, cut allocations and make code trim and Native AOT safe. Inspect output under Dependencies, Analyzers in the IDE, or set EmitCompilerGeneratedFiles to write the files to obj.
How to tell it (STAR)
**Situation:** our [booking platform] CI went red most mornings because of time-dependent tests, and we had a production bug with bookings on the wrong day. **Task:** I wanted a fix that would not rely on reviewers remembering a rule. **Action:** I introduced TimeProvider, moved tests to FakeTimeProvider, then banned DateTime.Now with BannedApiAnalyzers and wrote a small Roslyn analyser with a code fix for domain projects. **Result:** flaky time tests dropped to [zero] over [three months], the date bug class disappeared, and new code could not reintroduce it because the build failed.
Last updated 30 Oct 2026.