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

📚 Interview question

How would you enable nullable reference types across a large existing .NET codebase without stopping feature work?

Adopting nullable reference types at scale: what <Nullable> does, annotations and warnings, flow analysis and attributes, a project-by-project or file-by-file migration plan, handling public APIs and EF Core, and making nullable warnings errors.

Senior RealWorldScenario Advanced build-system code-quality csharp migration nullable-reference-types

🎯 Key points

  • <Nullable>enable</Nullable> turns on annotations (string? versus string) and nullable warnings (CS86xx); it changes no run-time behaviour
  • Nullable can also be set to annotations or warnings only, and #nullable enable/disable works per file or region
  • Migrate bottom-up: leaf libraries and shared domain types first, so consumers get accurate annotations
  • Turn it on per project or per file, fix warnings, then make nullable warnings errors with WarningsAsErrors=nullable
  • Use attributes such as [NotNullWhen], [MaybeNull] and [MemberNotNull] for cases flow analysis can't see; avoid the ! operator except at genuine boundaries
  • Public library APIs: annotations are part of the contract, so changing them can break consumers' builds
  • EF Core uses nullability to infer required columns, so enabling it can change generated migrations
  • Track progress with warning counts per project in CI, and stop new files being added with nullable disabled

⏱ 30-second 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.

🗣 Full answer (senior)

How I would answer this in an interview

"<Nullable>enable</Nullable> makes reference types non-nullable by default and turns on the CS86xx warnings, purely at compile time; it can also be set to annotations or warnings only, or per file with #nullable enable. My approach is to measure warnings per project in CI, migrate bottom-up from shared and domain libraries so consumers get accurate annotations, then go project by project or file by file in small pull requests, locking each clean project with WarningsAsErrors=nullable and requiring new files to be nullable-enabled. I use attributes like [NotNullWhen] and [MemberNotNull] where flow analysis needs help, and the mistake I see is silencing warnings with !, which only hides the bug.

For example, at [company] null reference exceptions were our top production error, and turning nullable on across our [120] projects produced about [40,000] warnings. My task was to lead the rollout without stopping feature work for [eight] teams. I measured warnings per project in CI, migrated shared libraries first, then had teams adopt nullable file by file as they touched code, locking each project once it was clean. I also reviewed the EF Core migrations that nullability changed, because non-nullable properties become required columns, and treated annotation changes on public APIs as contract changes. Over [two quarters] every project was nullable-enabled, and null reference exceptions in production fell by [about 70%].

Nullable adoption is really a change-management problem. Annotations are compile-time promises, so I still validate at boundaries, and the build, a warnings dashboard and analysers are what stop it sliding back."


What it does

Setting Effect
enable Annotations and warnings on
warnings Warnings on, but all types treated as "oblivious" for annotations
annotations You can write ?, but no warnings
disable Legacy behaviour (default for old projects)

It's compile-time only. The IL contains attributes (NullableAttribute) that other compilers and tools read; the runtime ignores them. A string parameter can still receive null from a caller with nullable disabled, so public entry points still need ArgumentNullException.ThrowIfNull.

Migration plan for a large codebase

  1. Baseline: build with <Nullable>warnings</Nullable> in CI only (or on a branch) and count CS86xx warnings per project. That's the size of the job.
  2. Order: start with leaf projects (shared kernel, domain, utilities), then infrastructure, then applications. Annotating a dependency first means consumers see accurate warnings instead of oblivious ones.
  3. Granularity:
    • Small projects: <Nullable>enable</Nullable> in the project file, fix everything in one PR.
    • Large projects: add #nullable enable at the top of files as you touch them (the "boy scout rule"), and require it for new files.
  4. Lock in progress: when a project hits zero warnings, add <WarningsAsErrors>$(WarningsAsErrors);nullable</WarningsAsErrors> so new nullable warnings fail the build.
  5. Finish: once every project is clean, move <Nullable>enable</Nullable> and the warnings-as-errors setting to Directory.Build.props and delete the per-file directives.

Doing it well

  • Model reality: if a value really can be missing, make it ? and handle it. Don't sprinkle ! to silence warnings.
  • Use the attributes flow analysis understands: [NotNullWhen(true)] on TryGet patterns, [MemberNotNull(nameof(_x))] on initialisation helpers, [return: NotNullIfNotNull(nameof(input))].
  • required members and primary constructors make it easier to guarantee non-null properties on DTOs.
  • EF Core: with nullable enabled, non-nullable string properties become required (NOT NULL) columns. Enabling it on an entity project can generate a migration that alters columns. Review it, and annotate entities to match the real schema.
  • Public libraries: adding or changing annotations is visible to consumers who have nullable enabled and can create new warnings for them. Treat it as an API change and note it in release notes.

Production example

A 120-project insurance platform had about 40,000 nullable warnings when enabled. The team migrated over two quarters: shared libraries first in dedicated PRs, then per-file adoption in applications during normal feature work, with a CI dashboard of warnings per project. Projects were locked with WarningsAsErrors=nullable as they reached zero. Production NullReferenceExceptions in error logs fell by roughly 70% over the period, and the EF Core review caught three entities whose annotations didn't match the database.

Best practices, performance and security

  • No run-time cost: annotations are metadata only.
  • Validate inputs at trust boundaries regardless (API requests, deserialised payloads, data from other systems).
  • Don't mass-apply !; it turns compile-time warnings back into run-time exceptions.

🔍 What the interviewer is checking

Probes experience leading a cross-cutting code-quality change.

Green flags: explains what nullable does and doesn't do; plans bottom-up, incremental adoption; locks progress with warnings as errors; uses attributes instead of !; mentions EF Core and public API impacts.

Red flags: big-bang enable; ! everywhere; thinks it adds run-time checks.

📚 Learn in depth

1. Two contexts: annotations and warnings

Nullable reference types are controlled by two independent switches. The <Nullable> property and the #nullable directive set them together or separately:

Setting Annotation context Warning context What you get
disable off off Legacy C#: every reference type is "oblivious" and string? is an error
annotations on off You can write string? and annotate APIs, but the compiler doesn't warn
warnings off on Warnings for obvious null misuse in the code, but your own types stay oblivious
enable on on Full feature: string means non-null, string? means maybe-null, and CS86xx warnings

The same values work as directives inside a file: #nullable enable, #nullable disable, #nullable enable warnings, #nullable disable annotations and #nullable restore (go back to the project setting). That is what makes file-by-file and region-by-region migration possible.

2. It is a compile-time feature only

 source:   public string Name { get; set; }        public string? Notes { get; set; }
              |                                        |
 compiler: null-state analysis, CS86xx warnings  <-- all of the checking happens here
              |
 IL:       [Nullable(1)] string Name               [Nullable(2)] string Notes
           (metadata for other compilers and tools; the runtime ignores it)
              |
 runtime:  both are just System.String references. Null can still arrive.

Null can still reach a non-nullable string from:

  • callers compiled with nullable disabled, or from other .NET languages;
  • reflection, serialisers, ORMs and model binders that set properties directly;
  • default structs and arrays (new string[10] holds ten nulls);
  • the ! operator, which tells the compiler to stop checking.

So validation at trust boundaries stays: ArgumentNullException.ThrowIfNull on public entry points, and model validation on API requests.

3. How flow analysis works

The compiler tracks a null state for every expression: not-null or maybe-null.

string? preferred = customer.PreferredName;   // maybe-null (declared string?)

if (preferred is null)                         // after this branch...
    return customer.LegalName;

return preferred.ToUpperInvariant();           // ...not-null here, no warning

It understands is null, is not null, == null, ??, ??=, ?., pattern matching and throw expressions. It does not follow calls into other methods unless those methods carry attributes that describe their behaviour, which is what section 5 is about.

Two things it deliberately doesn't track well:

  • Fields across method calls. After calling another method, the compiler can't know whether _cache was set to null in it. Copy the field into a local before checking it.
  • Array elements and collection items. list[0] has the annotation of the element type, regardless of what you stored.

4. The warnings you will see most

ID Example Typical fix
CS8618 public string Name { get; set; } with no constructor setting it required, a constructor parameter, or string? if it really can be missing
CS8602 customer.Address.Postcode where Address is Address? Check for null, or use ?. and handle the null result
CS8600 string s = dict.GetValueOrDefault(key); Declare string?, or use TryGetValue
CS8601 order.Notes = request.Notes; where Notes is string? Make the target nullable, or supply a default with ??
CS8603 return _cache.Find(id); from a method returning Customer Return Customer?, or throw when not found
CS8604 Send(customer.Email) where Email is string? Check before the call, or change the parameter
CS8625 Process(null) where the parameter is string Pass a real value, or make the parameter nullable
CS8619/CS8620 List<string?> passed where List<string> is expected Fix the generic type argument

WarningsAsErrors=nullable and NoWarn=nullable are shorthands that cover the whole nullable warning set, so you don't need to list IDs.

5. Attributes for what flow analysis can't see

From System.Diagnostics.CodeAnalysis:

Attribute Where Tells the compiler
[NotNullWhen(true)] out parameter of a TryGet method When the method returns true, the argument is not null
[MaybeNullWhen(false)] out T in a generic TryGet When it returns false, the value may be default
[NotNull] parameter After the call returns, the argument is not null (for example a guard method)
[AllowNull] / [DisallowNull] property or parameter Setter accepts null but getter never returns it, or the reverse
[MaybeNull] return or generic T May return default even though T is non-nullable
[return: NotNullIfNotNull(nameof(input))] return value Returns non-null whenever input is non-null
[MemberNotNull(nameof(_client))] initialisation helper method After it runs, _client is set
[MemberNotNullWhen(true, nameof(Value))] bool property such as HasValue When true, Value is not null
[DoesNotReturn] / [DoesNotReturnIf(false)] throw helpers and asserts Code after the call is unreachable
public sealed class ConnectionHolder
{
    private SqlConnection? _connection;

    [MemberNotNull(nameof(_connection))]
    private void EnsureOpen()
    {
        _connection ??= new SqlConnection(_connectionString);
    }

    public Task<int> CountAsync()
    {
        EnsureOpen();
        return _connection.ExecuteScalarAsync<int>("select count(*) from Claims"); // no warning
    }
}

6. Framework behaviour that changes when you enable it

This is where "compile-time only" stops being the whole story. Some libraries read the nullable metadata at run time:

Component Behaviour with nullable enabled What to do
EF Core Non-nullable reference properties become required (NOT NULL) columns, and navigations become required relationships Run dotnet ef migrations add after enabling it on an entity project and read the migration before merging
ASP.NET Core MVC and Razor Pages Non-nullable reference properties on models are treated as if they had [Required] during model validation Expect new 400 responses for previously optional fields; make optional fields string?, or set SuppressImplicitRequiredAttributeForNonNullableReferenceTypes while migrating
System.Text.Json (.NET 9+) Ignores annotations by default; RespectNullableAnnotations = true makes it throw when JSON null goes into a non-nullable member Turn it on deliberately, not as a side effect
Minimal APIs A non-nullable parameter is required, and a missing value returns 400 Declare optional parameters as nullable or give them defaults

The EF Core and MVC rows are why "enable nullable" on an entity or API project is a behaviour change that needs testing, not just a warning clean-up.

7. Sizing and ordering the migration

 Step 1: measure                    Step 2: order by dependency
 --------------------------         -------------------------------------
 dotnet build                        Shared.Kernel  (leaf, no deps)   <-- first
   -p:Nullable=enable                Domain         -> Shared.Kernel
   -flp:"logfile=nrt.log;           Infrastructure -> Domain
         warningsonly"              Api / Workers  -> everything       <-- last
 count CS86xx per project
  1. Measure. Build once with -p:Nullable=enable in a CI job, which applies to every project without changing a file, and count CS86xx warnings per project from the log. That number is the size of the job, and the per-project breakdown shows where to start.
  2. Order by dependency. Annotate the projects at the bottom of the graph first. If an application is migrated before its libraries, every call into those libraries is oblivious: there are no warnings, and you'll get a second wave of them when the library is annotated.
  3. Pick the granularity per project.
    • Small or leaf projects: <Nullable>enable</Nullable> in the project file and fix it in one focused pull request.
    • Large application projects: leave the project at disable, add #nullable enable to each file as it's touched, and require it for new files.
    • Alternative for large projects: set the project to enable and add #nullable disable to the top of every existing file with a script. New files are then nullable by default, and the task becomes "remove the directive from one file at a time". This is often easier to track, because the remaining work is a simple count of files containing #nullable disable.
  4. Lock it in. When a project reaches zero, add <WarningsAsErrors>$(WarningsAsErrors);nullable</WarningsAsErrors>, so a new warning fails its build.
  5. Finish. Move <Nullable>enable</Nullable> and the warnings-as-errors setting to Directory.Build.props, delete the per-project settings and any leftover directives, and add a check in CI that no #nullable disable remains outside generated code.

8. Legacy code and third-party libraries

  • Oblivious APIs. Calling a library compiled without nullable gives no warnings either way. Wrap important ones behind a small annotated adapter, so your code has an accurate contract.
  • Generated code (files with an <auto-generated> header or names such as .g.cs and .designer.cs) is treated as nullable disabled unless it contains a #nullable directive, so it doesn't add to the count.
  • Two-phase initialisation (new followed by Init()) fights the analysis. Fix it with constructor initialisation or [MemberNotNull], not with !.
  • DTOs that are filled by a serialiser: use required members so construction sites must set them, and string? for genuinely optional fields.
  • = default! or = null! is acceptable only for members a framework always sets, such as EF Core DbSet<T> properties. Each one is a promise the compiler can't check.

9. Diagnosis

Symptom Likely cause Check
No warnings at all after enabling Setting overridden later, or the file has #nullable disable dotnet msbuild -getProperty:Nullable and search the file for the directive
Warnings appear in a project nobody changed A dependency was just annotated, so calls are no longer oblivious Look at the dependency's recent pull requests
A new EF Core migration alters many columns Entity project switched to nullable and properties didn't match the schema Compare the migration with the database and annotate the entities to match it
APIs start returning 400 for optional fields MVC implicit [Required] on non-nullable properties Make those properties nullable
NullReferenceException despite zero warnings !, default!, reflection, or an oblivious caller Search for ! near the stack frame; add boundary checks

10. Performance and security

  • No run-time cost. The annotations are metadata; nothing is inserted into the generated code. Build time is barely affected.
  • Security. Nullable is not input validation. An attacker's JSON can still send null; validate request models and keep guard clauses on public APIs.
  • The ! operator turns a compile-time warning back into a possible run-time exception. Review each one, and consider an analyser rule or a review checklist that flags new uses.

11. .NET 11 awareness

.NET 11 (due in November 2026, an STS release with 24 months of support, currently in preview and release candidates) doesn't change how nullable reference types work. The migration approach in this answer applies to .NET 10 LTS and .NET 11 alike; check the release notes of each SDK upgrade for new nullable-related warnings or analyser defaults.

12. Try it yourself

  1. Create a class library, add <Nullable>enable</Nullable>, and paste in CustomerDirectory from the code example.
  2. Remove [NotNullWhen(true)] from TryGet and see CS8602 appear in DisplayName.
  3. Add <WarningsAsErrors>nullable</WarningsAsErrors> and confirm the warning now fails the build.
  4. Put #nullable disable at the top of the file and see the warnings disappear; replace it with #nullable restore.
  5. In an existing solution, run dotnet build -p:Nullable=enable -flp:"logfile=nrt.log;warningsonly" and count warnings per project to size a real migration.
  6. In an ASP.NET Core project, add a non-nullable string property to a request model, post JSON without it and note the 400 response. Change it to string? and try again.

💻 Code

<!-- Per project during migration -->
<PropertyGroup>
  <Nullable>enable</Nullable>
  <WarningsAsErrors>$(WarningsAsErrors);nullable</WarningsAsErrors>
</PropertyGroup>
using System.Diagnostics.CodeAnalysis;

namespace Insurance.Customers;

public sealed record Customer(Guid Id, string LegalName, string? PreferredName, string? Abn);

public sealed class CustomerDirectory
{
    private readonly Dictionary<Guid, Customer> _customers = [];

    public void Add(Customer customer)
    {
        ArgumentNullException.ThrowIfNull(customer); // callers without nullable can still pass null
        _customers[customer.Id] = customer;
    }

    public bool TryGet(Guid id, [NotNullWhen(true)] out Customer? customer) =>
        _customers.TryGetValue(id, out customer);

    public string DisplayName(Guid id)
    {
        if (!TryGet(id, out var customer))
        {
            return "Unknown customer";
        }

        // Flow analysis knows customer is not null here
        return customer.PreferredName ?? customer.LegalName;
    }
}
// A file in a large legacy project, migrated as it was touched
#nullable enable

namespace Insurance.Claims;

public sealed class ClaimSummary
{
    public required string ClaimNumber { get; init; }
    public string? AssessorNotes { get; init; }
}

⚠ Common mistakes

  • Mistake: turning it on for the whole solution in one pull request. A change with tens of thousands of warnings can't be reviewed, conflicts with every other branch and usually gets abandoned, or gets "fixed" with ! everywhere. Instead: measure first, then migrate project by project or file by file in small pull requests that merge alongside feature work.

  • Mistake: silencing warnings with the ! operator. ! tells the compiler to stop checking, so the warning disappears but the NullReferenceException stays, now hidden. Instead: model reality. Make the type string? and handle null when a value can be missing, and use attributes such as [NotNullWhen(true)] or [MemberNotNull] where the code is correct but the compiler can't see why. Keep ! for a few documented framework boundaries.

  • Mistake: starting with the applications. Calls into unannotated libraries are oblivious, so the application looks clean. When the libraries are annotated later, a second wave of warnings arrives in code that was already "done". Instead: migrate bottom-up: shared and domain libraries first, then infrastructure, then applications.

  • Mistake: removing run-time null checks because the type is non-nullable. Annotations are compile-time only. Null still arrives from callers without nullable, serialisers, reflection and other languages. Instead: keep ArgumentNullException.ThrowIfNull on public entry points and validate request models at trust boundaries.

  • Mistake: treating it as a warnings-only change for EF Core and ASP.NET Core projects. EF Core makes non-nullable properties required columns, and MVC treats them as [Required], so enabling nullable can generate a schema migration or start rejecting requests. Instead: after enabling it on an entity project, run dotnet ef migrations add and review the result. On API projects, mark optional request fields as nullable and run the API tests.

  • Mistake: not locking in progress. A project that reaches zero warnings drifts back as new code adds warnings nobody notices. Instead: add <WarningsAsErrors>$(WarningsAsErrors);nullable</WarningsAsErrors> as soon as a project is clean, and track the remaining warning count per project in CI.

  • Mistake: changing public library annotations silently. Consumers with nullable enabled get new warnings, or new errors if they treat warnings as errors, after a minor package update. Instead: treat annotation changes on public APIs as API changes: review them, note them in release notes and ship them in a planned version.

  • Mistake: using = null! to make CS8618 go away on every property. Each one is an unchecked promise, so a forgotten assignment becomes a run-time null. Instead: use required members or constructor parameters, make genuinely optional members nullable, and keep = null! for members a framework always sets, such as DbSet<T>.

  • Mistake: believing nullable adds run-time checks or slows the app. Neither is true. No checks are emitted and there is no run-time cost. Instead: explain it as compile-time analysis plus metadata, and keep explicit checks where they matter.

🏛 Senior insight: trade-offs

Nullable adoption is a change-management problem more than a technical one. A senior engineer sizes it, orders it by dependency, makes each step small enough to merge alongside feature work, and uses the build (WarningsAsErrors=nullable, a CI warning dashboard, "new files must be nullable-enabled") to stop regression. They're honest about the limits: annotations are compile-time promises, so boundaries still validate, and EF Core and public APIs need explicit review. The payoff is fewer production null reference exceptions and APIs that document themselves.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions WarningsAsErrors=nullable. Why: Checks how the candidate locks in progress with warning policy.
    How to answer

    The file under review turns on TreatWarningsAsErrors and then hides 25 warnings with NoWarn. That list includes nullable warnings, obsolete-API warnings and a security rule, so the policy looks strict while silencing the warnings that matter most. I'd remove those suppressions. Rules that genuinely don't apply would get a severity in .editorconfig, scoped to the right folders and commented. Rules we can't fix yet would go in WarningsNotAsErrors, so they stay visible as warnings. I'd also pin AnalysisLevel so an SDK upgrade can't break the build overnight, apply the same settings locally and in CI, and ratchet a legacy codebase towards zero warnings rather than flipping everything at once.

  2. When: Candidate mentions EF Core required columns. Why: Enabling nullable can change EF Core migrations; checks safe migration practice.
    How to answer

    I generate migrations locally with dotnet ef migrations add, then make deployment a separate, reviewed step. The pipeline produces an artefact: either an idempotent SQL script (dotnet ef migrations script --idempotent) for a DBA to review, or a migration bundle (dotnet ef migrations bundle), a self-contained executable that applies pending migrations. That artefact runs once, before the new app version starts, with a dedicated deployment identity. I avoid Database.Migrate() at startup because several instances can race, the app needs DDL permissions it shouldn't have, and a failed migration turns into a crash loop.

  3. When: Candidate talks about using the compiler to prevent bugs. Why: Widens from nullable warnings to the rest of the analyser set.
    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.

🛠 Real-world problems: how you handle them

Nullable rollout across a 120-project platform
Situation
An insurance platform in Melbourne with 120 projects and eight teams. NullReferenceException was the top production exception type, and enabling nullable showed about 40,000 warnings.
Signals
Top error in Application Insights was NullReferenceException in claim and policy services; a trial enable on one branch produced 40,000 CS86xx warnings; teams worried about stalling feature delivery.
Investigation
Built the solution with Nullable=warnings in a CI job and exported warning counts per project. Mapped the project dependency graph to find leaf libraries, and sampled the top warnings to estimate the effort per project.
Resolution
Migrated shared libraries first in dedicated PRs, then applications file by file with #nullable enable during normal work. Locked each clean project with WarningsAsErrors=nullable and finally moved the settings to Directory.Build.props. Reviewed EF Core migrations generated after enabling nullable on entity projects.
Prevention
CI dashboard of nullable warnings per project, a rule that new files must be nullable-enabled, analysers flagging the ! operator in review, and boundary validation retained at API entry points.

How to tell it (STAR)

**Situation:** At [company], null reference exceptions were our top production error, and turning on nullable across our [120] projects produced about [40,000] warnings. **Task:** I led the rollout without stopping feature work for [eight] teams. **Action:** I measured warnings per project in CI, migrated shared libraries first, then had teams adopt nullable file by file as they touched code. Each project was locked with nullable warnings as errors once clean, and I reviewed the EF Core migrations that nullability changed. **Result:** Over [two quarters] every project was nullable-enabled, null reference exceptions in production fell by [about 70%], and we caught [three] entity mismatches with the database along the way.

↩ Usually asked after

More in Build System

Last updated 1 Nov 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me