Updated Published 5 min readBy Muhammad Shahid

C# Nullable Reference Types: Warnings and EF Core

Master C# Nullable Reference Types (NRT): compiler static analysis, null attributes ([NotNullWhen]), C# required properties, and EF Core mappings.

Part of Architecture

Quick answers

What is the difference between Nullable<T> and Nullable Reference Types (NRT)?
Nullable<T> (or T?) for value types is a real CLR struct (System.Nullable<T>) with runtime overhead. Nullable Reference Types (e.g. string?) are pure compile-time metadata annotations enforced by the Roslyn compiler during static analysis; at runtime, string and string? are identical System.String references.
When is using the null-forgiving operator (!) acceptable?
Use null! sparingly when you have verified that a value cannot be null through logic the Roslyn compiler cannot infer—such as EF Core navigation properties loaded via .Include(), or DI-injected test fixtures initialized in xUnit InitializeAsync(). Never use null! to bypass runtime validation of JSON payloads.
How do Nullable Reference Types impact EF Core migrations?
With <Nullable>enable</Nullable>, EF Core infers database column nullability directly from C# types: 'string Name { get; set; }' generates NOT NULL in SQL Server, while 'string? MiddleName { get; set; }' generates a nullable NULL column.
What does the C# 11 'required' keyword solve alongside NRT?
The 'required' keyword forces object callers and deserializers to initialize non-nullable properties during object creation, eliminating CS8618 'Non-nullable property must contain a non-null value when exiting constructor' warnings without fake defaults or null!.

Nullable Reference Types (NRT), introduced in C# 8 and refined through modern .NET, transform NullReferenceException from a terrifying runtime production crash into a deterministic compile-time check. By treating all reference types as non-nullable by default, the C# compiler forces developers to explicitly declare where null is a valid domain state using ?.

C# Nullable Reference Analysis:
├── string  Name       ──► Compiler guarantee: Must never be null (SQL: NOT NULL)
└── string? MiddleName ──► Nullable: Must perform null check before member access (SQL: NULL)

Runtime Reality:
Both compile to System.String at the IL level. 
NRT is compile-time static analysis metadata, NOT a new CLR runtime type.

New to this → start with Enabling NRT. Compiler attributes → C# Nullable Attributes. EF Core mapping → EF Core and NRT. Interview prep → If an interviewer asks.

Real-world analogy

Imagine a medical prescription pad:

  • A field with a red star (non-nullable string) means the pharmacist will refuse to dispense medication if the dosage is blank.
  • A field without a star (nullable string?) indicates optional allergy notes.
  • The null-forgiving operator (!) is a counterfeit approval stamp. It forces the pharmacist to accept the document without looking, but if the dosage was blank, the patient still suffers a crisis at runtime.

Enabling Nullable Reference Types in csproj

Ensure <Nullable>enable</Nullable> is configured in your project files or Directory.Build.props:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <WarningsNotAsErrors>CS8618</WarningsNotAsErrors> <!-- Optional: soften constructor warnings during migration -->
  </PropertyGroup>
</Project>

Essential C# Nullable Attributes

Often, helper methods perform null validation that the Roslyn compiler's static flow analysis cannot deduce on its own. Use System.Diagnostics.CodeAnalysis attributes to inform the compiler:

1. [NotNullWhen(true)] for Try-patterns

using System.Diagnostics.CodeAnalysis;

public static class CacheExtensions
{
    public static bool TryGetCachedUser(
        this IMemoryCache cache, 
        string key, 
        [NotNullWhen(true)] out UserDto? user)
    {
        if (cache.TryGetValue(key, out var raw) && raw is UserDto dto)
        {
            user = dto;
            return true;
        }

        user = null;
        return false;
    }
}

// Caller usage: No compiler warnings inside the if-block!
if (cache.TryGetCachedUser("user:101", out var user))
{
    // Compiler knows 'user' is guaranteed NOT NULL here!
    Console.WriteLine(user.Email.ToUpper());
}

2. [MemberNotNull] for Initialization Methods

public class OrderProcessor
{
    private PaymentClient _paymentClient; // Non-nullable field

    public OrderProcessor()
    {
        InitializeDependencies();
    }

    [MemberNotNull(nameof(_paymentClient))]
    private void InitializeDependencies()
    {
        _paymentClient = new PaymentClient();
    }
}

3. [NotNullIfNotNull(nameof(input))] for Transformers

[return: NotNullIfNotNull(nameof(input))]
public static string? SanitizeInput(string? input)
{
    return input?.Trim().ToLowerInvariant();
}

// If you pass non-null, compiler returns non-null:
string clean = SanitizeInput("  Admin  "); // Type is string, no warning!
string? maybe = SanitizeInput(null);       // Type is string?

Modern C# 11+ required properties and DTOs

In legacy C#, non-nullable properties on DTOs threw CS8618 unless assigned a dummy empty string ("") or null!. Modern C# solves this with the required keyword:

// DTO with C# required properties
public sealed record RegisterUserRequest
{
    // Required properties must be provided during object initialization
    public required string Email { get; init; }
    public required string FullName { get; init; }
    public string? PhoneNumber { get; init; } // Optional
}

// Deserializer (System.Text.Json) enforces required properties at runtime!

EF Core entity mapping with Nullable Reference Types

EF Core 8/9/10 maps C# NRT annotations directly to database constraints:

public class Customer
{
    public Guid Id { get; set; }

    // Generates: [FirstName] NVARCHAR(100) NOT NULL
    public string FirstName { get; set; } = string.Empty;

    // Generates: [LastName] NVARCHAR(100) NOT NULL
    public string LastName { get; set; } = string.Empty;

    // Generates: [MiddleName] NVARCHAR(100) NULL
    public string? MiddleName { get; set; }

    // Navigation property: Loaded via .Include(c => c.Orders)
    public List<Order> Orders { get; set; } = [];
}

Handling EF Core Navigation Warnings:

For 1-to-1 required relationships where EF Core instantiates an entity without loading children, declare the navigation as:

// EF Core binds this via reflection during queries
public CustomerProfile Profile { get; set; } = null!;

Here, null! is legitimate because EF Core navigation properties are populated by the query pipeline when included.

Common mistakes and pitfalls

  • Using null! to silence runtime validation warnings: Writing public string Email { get; set; } = null!; on an API request model tells the compiler Email is never null, but when a client sends { "email": null }, your code will crash on Email.Trim() at runtime. Use [Required] or FluentValidation.
  • Assuming NRT stops nulls from external libraries: If a legacy .NET Framework package or un-annotated library returns a reference, it may still return null even if typed as string. Guard external boundaries with defensive null checks.
  • Overusing ? on everything: Marking all properties as nullable (string?) defeats the benefits of NRT. Make domain properties non-nullable by default, and only use ? when null represents a valid business state (e.g. DeactivatedAt).

If an interviewer asks

30-second answer: Nullable Reference Types (NRT) is a C# compile-time static analysis feature that treats reference types as non-nullable by default. It catches potential NullReferenceException bugs at build time through Roslyn flow analysis and attributes ([NotNullWhen], [MemberNotNull]), while compiling to identical CLR IL without runtime performance overhead.

Strong answer: In production .NET architectures, enabling <Nullable>enable</Nullable> aligns compile-time type safety with database schema constraints in EF Core. We enforce non-nullability at system boundaries using C# required properties and FluentValidation, reserve ? exclusively for valid domain optionality, and avoid the null-forgiving operator (null!) except for EF Core navigation backings. This eliminates the "billion-dollar mistake" across the entire API and domain layer.