📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What are Roslyn analysers and how do you use them to enforce rules in a team? Explain the compiler-as-a-platform idea (syntax trees, semantic model, operations), how built-in .NET analysers and code style rules are configured, BannedApiAnalyzers, and walk through writing and packaging a small custom DiagnosticAnalyzer.

Roslyn analysers in practice: configuration, banned APIs, and a minimal custom DiagnosticAnalyzer.

Senior Theory Advanced analyzers banned-api code-quality editorconfig roslyn

🎯 Key points

  • Roslyn exposes syntax trees, a semantic model and operations that analysers inspect
  • Analysers report diagnostics in the IDE and in every build, turning team rules into warnings or errors
  • Built-in CA and IDE rules are tuned with AnalysisLevel, .editorconfig severities and EnforceCodeStyleInBuild
  • BannedApiAnalyzers bans APIs from BannedSymbols.txt and reports RS0030
  • Custom analysers target netstandard2.0, enable concurrent execution and resolve symbols in a compilation start action
  • Compare symbols with SymbolEqualityComparer, not identifier text
  • Reference a Roslyn version no newer than consumers' compiler and package under analyzers/dotnet/cs
  • Roll rules out gradually and keep them precise to avoid suppression culture

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Compiler as a platform

Roslyn exposes the compiler's internal models through public APIs:

  • Syntax trees: the exact structure of source code (nodes, tokens, trivia like whitespace/comments). Immutable, full-fidelity.
  • Semantic model: meaning — which symbol an identifier binds to, types of expressions, overload chosen, constant values, nullability.
  • Symbols: INamedTypeSymbol, IMethodSymbol, IPropertySymbol… — declarations and references across the compilation (including referenced assemblies).
  • Operations (IOperation): a language-neutral, semantic tree of what the code does (invocations, assignments, conversions) — the best level for most analysers because it's simpler than syntax and works for C# and VB.

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.

2. Built-in analysers and code style

The .NET SDK ships analysers (Microsoft.CodeAnalysis.NetAnalyzers) enabled by default:

  • CAxxxx quality rules: e.g. CA1305 (culture), CA1848 (LoggerMessage), CA2000 (dispose), CA2007 (ConfigureAwait), CA2016 (forward CancellationToken), CA2200 (throw ex), CA1852 (seal internal types).
  • IDExxxx code style rules: 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).

3. BannedApiAnalyzers

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.

4. Writing a small custom 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:

  • Add a code fix provider that rewrites to await SaveChangesAsync(ct).
  • Test with Microsoft.CodeAnalysis.Testing (CSharpAnalyzerTest<TAnalyzer, DefaultVerifier>) using markup like {|ORD001:db.SaveChanges()|}.
  • Package: target 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.

5. A production example

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.

🔍 What the interviewer is checking

The interviewer checks whether you can turn team conventions into automated guardrails and understand Roslyn well enough to do it safely.

Green flags

  • Explains syntax trees, semantic model and operations, and why symbols beat text matching.
  • Starts with built-in analysers, .editorconfig and BannedApiAnalyzers before custom code.
  • Knows the authoring basics: netstandard2.0, concurrent execution, compilation start actions, packaging.
  • Talks about gradual rollout and false positives.

Red flags

  • Thinks analysers only run in the IDE.
  • Proposes regex scripts over source files for API bans.
  • Turns on everything as errors in one change.

📚 Learn in depth

1. Analyser lifecycle

  • The compiler loads analysers from referenced packages (analyzers/dotnet/cs/*.dll) and the SDK.
  • Initialize registers actions; the compiler calls them concurrently (hence EnableConcurrentExecution and thread-safe analysers).
  • Compilation start actions let you look up well-known symbols once (GetTypeByMetadataName) and register nested actions, which is much faster than per-node lookups.
  • Analysers run in the IDE continuously (on open documents or the whole solution, configurable) and in builds.

2. Choosing the right level

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

3. Performance rules for analysers

  • Avoid string comparisons on names where symbol comparisons work.
  • Cache well-known symbols per compilation.
  • Skip generated code unless needed.
  • Don't allocate heavily in callbacks.
  • Measure with /reportanalyzer in dotnet build -bl to see analyser times.

4. Severity and adoption strategy

  • Start with AnalysisLevel recommended and treat new code strictly; use .editorconfig in subfolders to relax legacy areas temporarily.
  • Fix with bulk code fixes (dotnet format analyzers --diagnostics CA2016).
  • Raise severities gradually to error once the codebase is clean.
  • Keep suppressions explicit with justifications.

5. Analysers vs other tools

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.

Try it yourself

  1. Set AnalysisLevel to latest-all on a project and review new warnings.
  2. Add BannedApiAnalyzers with a BannedSymbols.txt banning DateTime.Now.
  3. Create an analyser project from the "Analyzer with Code Fix" template and implement ORD001 with tests.
  4. Pack it and consume it from another project via a local NuGet feed.

💻 Code

<!-- 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

⚠ Common mistakes

1. Relying on code review for mechanical rules

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.

2. Enabling everything as errors at once

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.

3. Style rules not enforced in build

What happens: IDE rules only show in the editor. Why it is wrong: CI doesn't catch violations. Do instead: EnforceCodeStyleInBuild.

4. Blanket suppressions

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.

5. Slow custom analysers

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.

6. Wrong Roslyn version references

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.

7. No tests for analysers

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.

8. Writing analysers for what BannedApiAnalyzers can do

What happens: custom analyser just to forbid an API. Why it is wrong: unnecessary maintenance. Do instead: use BannedSymbols.txt.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions TreatWarningsAsErrors or AnalysisLevel. Why: Checks how analysers are wired into the build and CI.
    How to answer

    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.

  2. When: Candidate talks about gradual rollout of rules. Why: Rolling out nullable warnings is a similar gradual rule rollout.
    How to answer

    <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.

  3. When: Candidate mentions the semantic model. Why: Source generators use the same compiler platform.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Flaky date-dependent tests fixed by banning direct clock access
Situation
A booking platform with [600] tests had [15] tests that failed intermittently around midnight UTC and at daylight saving changes in Sydney.
Signals
CI failures that passed on rerun, clustered at specific times; bug reports of bookings landing on the wrong day for [Western Australia] customers.
Investigation
Searched failures and found domain code reading DateTime.Now and DateTime.UtcNow directly in [23] places, so tests could not control time.
Resolution
Introduced TimeProvider injection with FakeTimeProvider in tests, banned the clock APIs with BannedApiAnalyzers, and later wrote a custom ACME001 analyser limited to domain projects with a code fix.
Prevention
Made ACME001 an error in domain projects via .editorconfig, documented the rule, and added analyser unit tests to CI.

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.

↩ Usually asked after

More in Advanced Language Features

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me