DEV_NET_CORE
GET_STARTED
.NETC# Language Foundations

Nullable reference C#

Overview

Nullable reference types are a C# language feature that helps developers express whether a reference type is expected to contain null.

Before nullable reference types, all reference types in C# could contain null, but the type system did not clearly communicate that intent. A method parameter declared as string name could still receive null, and a property declared as Customer Customer could still be uninitialized. This made NullReferenceException one of the most common runtime bugs in C# applications.

With nullable reference types enabled, C# distinguishes between:

Code
string name;   // Intended to be non-null
string? note;  // Intended to be nullable

This does not create a new runtime type. Both are still System.String at runtime. The difference is compile-time intent and compiler static analysis. The compiler tracks whether a reference is known to be non-null or maybe-null and produces warnings when code might dereference a null value or assign a maybe-null value to a non-nullable reference.

This topic matters because null-handling affects almost every layer of a .NET application:

  • DTOs and API contracts
  • ASP.NET Core request models
  • EF Core entities and database nullability
  • Domain models and value objects
  • Service interfaces
  • Repository return values
  • Configuration and options binding
  • External API integration
  • Unit tests and defensive programming

For interviews, nullable reference types are important because they test both language knowledge and production habits. A strong candidate should know that null-safety is not just about adding ? everywhere. It is about designing clear contracts, validating input at boundaries, initializing objects correctly, using compiler warnings instead of ignoring them, and choosing when null is appropriate versus when an empty object, empty collection, exception, or result type is better.

A good C# developer uses nullable reference types to make invalid states harder to represent, reduce runtime defects, and make APIs easier to understand.

Core Concepts

The null problem in C#

null means a reference does not point to an object instance.

For example:

Code
Customer? customer = null;

Console.WriteLine(customer.Name); // Possible NullReferenceException

The problem is not that null exists. The problem is unclear intent.

In many real systems, null can mean different things:

  • The value is missing.
  • The value was not loaded.
  • The value is optional.
  • The value is unknown.
  • The value has not been initialized yet.
  • The value does not apply to this case.
  • A lookup failed.
  • An external system returned incomplete data.

When code does not clearly model these meanings, bugs become likely.

A common interview point is that nullable reference types help document and enforce intent at compile time, but they do not remove null from the runtime.

Nullable reference types vs nullable value types

C# has two related but different nullability concepts.

Nullable value types use Nullable<T> under the hood:

Code
int? age = null;
DateTime? completedAt = null;

int? means Nullable<int>. It is a real runtime wrapper that can represent either a value or no value.

Nullable reference types are different:

Code
string? middleName = null;
Customer? customer = null;

string? and string are the same runtime type. The ? annotation is mainly compile-time metadata used by the compiler and tools to perform null-state analysis.

Comparison:

ConceptNullable value typeNullable reference type
Exampleint?string?
Runtime type changes?Yes, uses Nullable<T>No, still the same reference type
PurposeAllows value types to represent nullDescribes whether reference types may be null
Compiler analysisYesYes
Common useDatabase nullable columns, optional numeric/date valuesOptional objects, strings, DTO fields, lookup results

Enabling nullable reference types

Nullable reference types are only useful when the nullable context is enabled.

In a project file:

Code
<PropertyGroup>
  <Nullable>enable</Nullable>
</PropertyGroup>

You can also enable or disable it in a specific file or region:

Code
#nullable enable

public class Customer
{
    public string Name { get; set; } = string.Empty;
}

#nullable disable

Common nullable settings:

SettingMeaning
disableNullable annotations and warnings are disabled. This matches older C# behavior.
enableNullable annotations and warnings are enabled. This is recommended for new code.
warningsCompiler warns about possible null problems but nullable annotations are not fully enabled.
annotationsNullable annotations are enabled but warnings are disabled.

For new projects, use enable. For older projects, teams often migrate gradually by enabling nullable warnings first, fixing high-risk code, then enabling full nullable analysis.

Nullable annotations: T vs T?

When nullable reference types are enabled:

Code
string firstName = "Minh";
string? middleName = null;

string means the variable should not contain null.

string? means the variable may contain null, so callers must check it before dereferencing.

Example:

Code
public sealed class UserProfile
{
    public required string UserName { get; init; }
    public string? Bio { get; init; }
}

This communicates:

  • UserName is required and should not be null.
  • Bio is optional and may be null.

The compiler helps enforce this intent:

Code
UserProfile profile = new()
{
    UserName = "minh"
};

Console.WriteLine(profile.UserName.Length); // Safe
Console.WriteLine(profile.Bio.Length);      // Warning: Bio may be null

The correct handling is:

Code
if (!string.IsNullOrWhiteSpace(profile.Bio))
{
    Console.WriteLine(profile.Bio.Length);
}

Null-state analysis

The compiler tracks the null-state of reference variables.

The main states are:

StateMeaning
Not-nullCompiler believes the value is safe to dereference.
Maybe-nullCompiler cannot prove the value is non-null.

Example:

Code
static int GetLength(string? value)
{
    return value.Length; // Warning: value may be null
}

After a null check, the compiler updates the null-state:

Code
static int GetLength(string? value)
{
    if (value is null)
    {
        return 0;
    }

    return value.Length; // Safe
}

The compiler understands common null checks:

Code
if (customer != null)
{
    Console.WriteLine(customer.Name);
}

if (customer is not null)
{
    Console.WriteLine(customer.Name);
}

if (name is { Length: > 0 })
{
    Console.WriteLine(name.ToUpperInvariant());
}

It also understands many framework methods such as string.IsNullOrEmpty and string.IsNullOrWhiteSpace.

Nullable warnings are warnings, not runtime protection

Nullable reference types are not runtime validation.

This code may compile with warnings, but still run:

Code
string name = null!;

Console.WriteLine(name.Length); // Runtime NullReferenceException

The compiler helps identify risk, but it does not inject runtime null checks everywhere.

Important interview point:

Nullable reference types make null problems more visible at compile time. They do not guarantee that a reference can never be null at runtime.

Runtime nulls can still come from:

  • Reflection
  • Deserialization
  • Dependency injection misconfiguration
  • External APIs
  • Database data
  • Legacy code compiled with nullable disabled
  • Incorrect use of the null-forgiving operator
  • Unsafe code
  • Manual assignment of null

That is why null-safety requires both compiler support and good coding habits.

Common nullable warning codes

Interviewers may not expect memorization of every warning code, but knowing common categories is useful.

WarningTypical meaning
CS8600Converting null or possible null to a non-nullable type.
CS8601Possible null reference assignment.
CS8602Dereference of a possibly null reference.
CS8603Possible null reference return.
CS8604Possible null reference argument.
CS8618Non-nullable property or field is not initialized.
CS8625Cannot convert null literal to non-nullable reference type.

Example:

Code
public sealed class Customer
{
    public string Name { get; set; } // CS8618 if not initialized
}

Fix options:

Code
public sealed class Customer
{
    public required string Name { get; init; }
}

Or:

Code
public sealed class Customer
{
    public Customer(string name)
    {
        Name = name;
    }

    public string Name { get; }
}

Or, when a property is truly optional:

Code
public sealed class Customer
{
    public string? DisplayName { get; set; }
}

The best fix depends on the business meaning.

Required members and object initialization

Non-nullable properties must be initialized.

A common bad habit is using null! to silence warnings:

Code
public sealed class Customer
{
    public string Name { get; set; } = null!;
}

This tells the compiler, "Trust me, this will not be null." If that promise is wrong, the bug becomes a runtime failure.

A better approach is often required:

Code
public sealed class Customer
{
    public required string Name { get; init; }
    public string? PhoneNumber { get; init; }
}

Usage:

Code
var customer = new Customer
{
    Name = "Alice"
};

The compiler requires callers to initialize Name.

Constructor initialization is also strong:

Code
public sealed class Customer
{
    public Customer(string name)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        Name = name;
    }

    public string Name { get; }
}

Constructor initialization is often preferred in domain models because it lets you enforce invariants.

Use required when object initializer style is desirable, such as DTOs, options classes, and simple models.

Use constructors when the object has business rules or must be valid immediately after creation.

Null checks and guard clauses

Guard clauses validate assumptions early.

Example:

Code
public sealed class OrderService
{
    private readonly IOrderRepository _repository;

    public OrderService(IOrderRepository repository)
    {
        ArgumentNullException.ThrowIfNull(repository);
        _repository = repository;
    }
}

ArgumentNullException.ThrowIfNull is concise and communicates intent clearly.

For strings, use stronger validation when empty or whitespace is also invalid:

Code
public sealed class Product
{
    public Product(string name)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        Name = name;
    }

    public string Name { get; }
}

Guard clause habit:

Code
public async Task<CustomerDto> GetCustomerAsync(string customerId)
{
    ArgumentException.ThrowIfNullOrWhiteSpace(customerId);

    var customer = await _repository.GetByIdAsync(customerId);

    if (customer is null)
    {
        throw new KeyNotFoundException($"Customer '{customerId}' was not found.");
    }

    return MapToDto(customer);
}

This separates invalid input from not-found data.

Null-conditional operator: ?. and ?[]

The null-conditional operator safely accesses a member only when the left side is not null.

Code
int? length = customer?.Name?.Length;

If customer is null, the expression returns null instead of throwing.

For collections or arrays:

Code
string? firstTag = tags?[0];

Use this when null is acceptable and you want to continue with an optional result.

Avoid overusing it when null should be handled explicitly.

Weak example:

Code
_logger?.LogInformation("Order created");

If _logger should always be injected, this hides a dependency injection bug.

Better:

Code
ArgumentNullException.ThrowIfNull(logger);
_logger = logger;

Null-coalescing operator: ??

The null-coalescing operator provides a fallback value.

Code
string displayName = user.DisplayName ?? user.UserName;

This is useful when the fallback is a valid business default.

Example:

Code
public string GetGreeting(string? name)
{
    return $"Hello, {name ?? "guest"}";
}

It can also be used with throw expressions:

Code
public Customer GetRequiredCustomer(Customer? customer)
{
    return customer ?? throw new ArgumentNullException(nameof(customer));
}

Use ?? when the fallback is meaningful. Do not use arbitrary defaults just to avoid null warnings.

Bad example:

Code
decimal price = request.Price ?? 0;

If missing price is invalid, this silently changes invalid input into a valid value. A validation error would be better.

Null-coalescing assignment: ??=

The ??= operator assigns a value only when the variable is null.

Code
private List<string>? _tags;

public List<string> Tags => _tags ??= new List<string>();

This is useful for lazy initialization.

However, prefer initializing collections directly when possible:

Code
public List<string> Tags { get; } = new();

For most domain and DTO collection properties, an empty collection is better than a nullable collection.

Null-forgiving operator: !

The null-forgiving operator suppresses nullable warnings.

Code
string? value = GetValue();
Console.WriteLine(value!.Length);

It does not check anything at runtime. It only changes the compiler's null-state assumption.

Use it sparingly.

Appropriate uses include:

Test setup

Code
[Fact]
public void CreateOrder_Throws_WhenCustomerIsNull()
{
    Customer customer = null!;

    Assert.Throws<ArgumentNullException>(() => new Order(customer));
}

Framework initialization

Some frameworks initialize properties through reflection, binding, or lifecycle methods.

Code
public DbSet<Customer> Customers { get; set; } = null!;

Even here, use framework-specific best practices and avoid using null! casually.

EF Core navigation properties

Some required navigation properties are initialized by EF when entities are loaded.

Code
public Customer Customer { get; set; } = null!;

This can be acceptable, but should be intentional.

Bad use:

Code
public string Name { get; set; } = null!;

If you control construction, use required, a constructor, or a safe default instead.

The difference between optional and required data

A key null-safety habit is modeling business meaning.

Do not make a property nullable just because it is easy.

Ask:

  • Is the value truly optional?
  • Is it required but not loaded yet?
  • Is it required but initialized by a framework?
  • Does missing data represent a validation error?
  • Should a failed lookup return null, throw, or return a result object?
  • Would an empty collection be clearer than null?
  • Would an empty string be valid or invalid?

Example:

Code
public sealed class Employee
{
    public required string FullName { get; init; }
    public string? MiddleName { get; init; }
    public DateTime? TerminatedAt { get; init; }
}

Here:

  • FullName is required.
  • MiddleName is optional.
  • TerminatedAt is null when the employee is active or termination date is not applicable.

When null has multiple possible meanings, consider a clearer model.

Instead of:

Code
public DateTime? ProcessedAt { get; init; }

Use:

Code
public enum PaymentStatus
{
    Pending,
    Processed,
    Failed
}

public sealed class Payment
{
    public PaymentStatus Status { get; init; }
    public DateTime? ProcessedAt { get; init; }
}

The status removes ambiguity.

Avoid nullable collections when possible

A common production habit is:

Collections should usually be non-null and empty when there are no items.

Prefer:

Code
public List<OrderItem> Items { get; } = new();

Instead of:

Code
public List<OrderItem>? Items { get; set; }

Why?

  • Callers can iterate safely.
  • It avoids repeated null checks.
  • Empty collection clearly means "no items".
  • It reduces API ambiguity.

Example:

Code
foreach (var item in order.Items)
{
    total += item.Price;
}

If Items is nullable, every caller must decide what null means.

For API responses, prefer:

Code
{
  "items": []
}

Instead of:

Code
{
  "items": null
}

unless null has a specific business meaning.

Strings: null, empty, and whitespace

Strings need special care because null, "", and " " may have different meanings.

Common checks:

Code
if (name is null)
{
    // Missing
}

if (name == string.Empty)
{
    // Empty
}

if (string.IsNullOrWhiteSpace(name))
{
    // Missing, empty, or only whitespace
}

For required user input, string.IsNullOrWhiteSpace is usually better:

Code
public static User Create(string userName)
{
    if (string.IsNullOrWhiteSpace(userName))
    {
        throw new ArgumentException("User name is required.", nameof(userName));
    }

    return new User(userName);
}

Nullable annotation should match the contract:

Code
public void UpdateDisplayName(string? displayName)
{
    // Optional value; null may mean clear display name
}

public void Rename(string newName)
{
    ArgumentException.ThrowIfNullOrWhiteSpace(newName);
    Name = newName;
}

Method parameters: accept null only when meaningful

A method parameter should be nullable only if the method supports null as a valid input.

Good:

Code
public string FormatDisplayName(string firstName, string? middleName, string lastName)
{
    ArgumentException.ThrowIfNullOrWhiteSpace(firstName);
    ArgumentException.ThrowIfNullOrWhiteSpace(lastName);

    return string.IsNullOrWhiteSpace(middleName)
        ? $"{firstName} {lastName}"
        : $"{firstName} {middleName} {lastName}";
}

Bad:

Code
public string FormatDisplayName(string? firstName, string? middleName, string? lastName)
{
    return $"{firstName} {middleName} {lastName}";
}

The second version pushes invalid input into string formatting and may hide bugs.

Good habit:

  • Use non-nullable parameters for required inputs.
  • Validate required inputs at public boundaries.
  • Use nullable parameters only when null is part of the method contract.

Return values: null, exception, empty collection, or result type

Choosing the return type is one of the most important null-safety design decisions.

Return null for optional lookup

Code
public Task<Customer?> FindByEmailAsync(string email);

This communicates that the customer may not exist.

Call site:

Code
var customer = await repository.FindByEmailAsync(email);

if (customer is null)
{
    return NotFound();
}

Throw when absence is exceptional

Code
public async Task<Customer> GetRequiredAsync(Guid id)
{
    var customer = await repository.FindByIdAsync(id);

    return customer ?? throw new KeyNotFoundException($"Customer '{id}' was not found.");
}

Use this when the caller expects the value to exist.

Return empty collection for no results

Code
public Task<IReadOnlyList<Customer>> SearchAsync(string keyword);

Returning an empty list is usually better than returning null.

Use a result type for richer outcomes

Code
public sealed record Result<T>(
    bool IsSuccess,
    T? Value,
    string? Error);

Or a more precise design:

Code
public sealed record CustomerLookupResult(
    bool Found,
    Customer? Customer,
    string? ErrorMessage);

For production code, a well-designed result type can be better than null when you need to distinguish not found, validation failure, permission denied, and external system error.

Nullability and generics

Generics introduce additional complexity.

Example:

Code
public T? Find<T>(IEnumerable<T> values, Func<T, bool> predicate)
{
    return values.FirstOrDefault(predicate);
}

T? means different things depending on whether T is a reference type or value type.

Constraints can clarify intent:

Code
public T? FindReference<T>(IEnumerable<T> values, Func<T, bool> predicate)
    where T : class
{
    return values.FirstOrDefault(predicate);
}

For non-nullable generic type parameters:

Code
public void Save<T>(T entity)
    where T : notnull
{
    ArgumentNullException.ThrowIfNull(entity);
}

The notnull constraint communicates that T should not be nullable.

Common interview points:

  • T? is not always simple in unconstrained generics.
  • where T : class means a non-nullable reference type in a nullable-enabled context.
  • where T : class? allows nullable reference types.
  • where T : notnull allows non-nullable reference types and non-nullable value types.
  • Generic APIs often need nullable attributes to express precise contracts.

Nullable static analysis attributes

Nullable attributes help the compiler understand custom null-checking methods and advanced API contracts.

They are commonly found in System.Diagnostics.CodeAnalysis.

NotNullWhen

Use when a boolean return value tells the compiler something about a parameter.

Code
using System.Diagnostics.CodeAnalysis;

public static bool HasValue([NotNullWhen(true)] string? value)
{
    return !string.IsNullOrWhiteSpace(value);
}

Usage:

Code
string? input = GetInput();

if (HasValue(input))
{
    Console.WriteLine(input.Length); // Compiler knows input is not null
}

MaybeNull

Use when a method may return null even though the generic type does not show it clearly.

Code
using System.Diagnostics.CodeAnalysis;

public interface ICache
{
    [return: MaybeNull]
    T Get<T>(string key);
}

NotNullIfNotNull

Use when the return value is non-null if an input is non-null.

Code
using System.Diagnostics.CodeAnalysis;

[return: NotNullIfNotNull(nameof(value))]
public static string? Normalize(string? value)
{
    return value?.Trim();
}

MemberNotNull

Use when a method initializes members.

Code
using System.Diagnostics.CodeAnalysis;

public sealed class ReportBuilder
{
    private string? _title;

    [MemberNotNull(nameof(_title))]
    public void Initialize(string title)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(title);
        _title = title;
    }

    public string Build()
    {
        Initialize("Monthly Report");
        return _title.ToUpperInvariant();
    }
}

Common attributes:

AttributePurpose
AllowNullA non-nullable property can accept null as input, often converting it internally.
DisallowNullA nullable property should not accept null as input.
MaybeNullA return value, field, or property may be null.
NotNullA value is not null after a method returns.
MaybeNullWhenA parameter may be null when a method returns a specific boolean value.
NotNullWhenA parameter is not null when a method returns a specific boolean value.
NotNullIfNotNullReturn value is not null if a specified parameter is not null.
MemberNotNullMethod guarantees specified members are initialized after it returns.
MemberNotNullWhenMethod guarantees specified members are initialized when it returns a specific boolean value.

These are especially useful for libraries, helper methods, validation methods, and framework-like code.

Null-safety in ASP.NET Core APIs

Nullable reference types help express API request and response contracts.

Example request model:

Code
public sealed class CreateCustomerRequest
{
    public required string Name { get; init; }
    public string? PhoneNumber { get; init; }
}

Meaning:

  • Name is required.
  • PhoneNumber is optional.

In ASP.NET Core, non-nullable properties can influence validation behavior. However, do not rely only on nullable annotations for full business validation.

Use validation rules as well:

Code
public sealed class CreateCustomerRequestValidator
{
    public static void Validate(CreateCustomerRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);
        ArgumentException.ThrowIfNullOrWhiteSpace(request.Name);
    }
}

In real projects, teams commonly use FluentValidation, data annotations, or endpoint filters.

Controller example:

Code
[HttpPost]
public async Task<IActionResult> Create(CreateCustomerRequest request)
{
    if (string.IsNullOrWhiteSpace(request.Name))
    {
        return BadRequest("Name is required.");
    }

    var customer = await _service.CreateAsync(request.Name, request.PhoneNumber);

    return CreatedAtAction(nameof(GetById), new { id = customer.Id }, customer);
}

Good habits for API models:

  • Mark required values as non-nullable.
  • Mark optional values as nullable.
  • Validate at the boundary.
  • Avoid accepting nullable values deep inside business logic unless null is meaningful.
  • Avoid returning nullable collections in API responses.
  • Keep request DTOs separate from domain entities.

Null-safety in EF Core

EF Core uses nullable reference type annotations to help infer required and optional properties.

Example:

Code
public sealed class Customer
{
    public int Id { get; set; }

    public required string Name { get; set; }

    public string? PhoneNumber { get; set; }
}

Typical meaning:

  • Name is required.
  • PhoneNumber is optional.

Be careful when enabling nullable reference types in an existing EF Core project. A property that was previously treated as optional may become required if it is non-nullable. This can affect migrations and database schema.

For navigation properties:

Code
public sealed class Order
{
    public int Id { get; set; }

    public int CustomerId { get; set; }

    public Customer Customer { get; set; } = null!;

    public List<OrderItem> Items { get; } = new();
}

Here, Customer is required but initialized by EF when loaded. The null! is a framework-specific compromise. The collection is non-null and initialized to an empty list.

For optional navigation:

Code
public sealed class Order
{
    public int Id { get; set; }

    public int? DiscountCodeId { get; set; }

    public DiscountCode? DiscountCode { get; set; }
}

Good EF Core habits:

  • Align C# nullability with database nullability.
  • Use required or constructors for required scalar properties.
  • Initialize collection navigations.
  • Avoid nullable collection navigation properties.
  • Use null! only when EF or another framework truly initializes the member.
  • Review migrations after enabling nullable reference types.
  • Be careful with optional relationships in LINQ queries because the compiler may not understand provider-specific query translation.

Null-safety in Clean Architecture and domain models

In Clean Architecture, null-safety should be strongest in the domain and application layers.

Domain model example:

Code
public sealed class Product
{
    public Product(string name, decimal price)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);

        if (price < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(price), "Price cannot be negative.");
        }

        Name = name;
        Price = price;
    }

    public string Name { get; }
    public decimal Price { get; }
}

The domain model does not allow an invalid product to exist.

Application service example:

Code
public async Task<ProductDto?> FindProductAsync(Guid id)
{
    var product = await _repository.FindByIdAsync(id);

    return product is null
        ? null
        : new ProductDto(product.Id, product.Name, product.Price);
}

Here, ProductDto? communicates that the product may not be found.

Command handler example:

Code
public sealed record UpdateProductNameCommand(Guid ProductId, string Name);

public sealed class UpdateProductNameHandler
{
    private readonly IProductRepository _repository;

    public UpdateProductNameHandler(IProductRepository repository)
    {
        ArgumentNullException.ThrowIfNull(repository);
        _repository = repository;
    }

    public async Task Handle(UpdateProductNameCommand command)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);

        var product = await _repository.FindByIdAsync(command.ProductId);

        if (product is null)
        {
            throw new KeyNotFoundException("Product was not found.");
        }

        product.Rename(command.Name);

        await _repository.SaveChangesAsync();
    }
}

Good architecture habit:

  • Validate external input at the boundary.
  • Keep domain entities in a valid state.
  • Use nullable return types for optional lookups.
  • Convert nullable external data into safer internal models as early as possible.
  • Avoid spreading string? and object? everywhere in the application layer.

Null-safety with dependency injection

Services injected through constructors should usually be non-nullable.

Code
public sealed class InvoiceService
{
    private readonly IInvoiceRepository _repository;
    private readonly ILogger<InvoiceService> _logger;

    public InvoiceService(
        IInvoiceRepository repository,
        ILogger<InvoiceService> logger)
    {
        ArgumentNullException.ThrowIfNull(repository);
        ArgumentNullException.ThrowIfNull(logger);

        _repository = repository;
        _logger = logger;
    }
}

Some developers skip these checks because dependency injection normally provides non-null dependencies. However, guard clauses are still useful because:

  • They protect tests that instantiate classes manually.
  • They fail fast when the container is misconfigured.
  • They document the dependency contract.
  • They help static analysis.

Avoid this:

Code
_logger?.LogInformation("Invoice created");

If _logger is required, using ?. hides a broken invariant.

Null-safety with external data

External systems are a common source of unexpected nulls.

Examples:

  • JSON APIs
  • Message queues
  • CSV files
  • User input
  • Configuration
  • Databases
  • Legacy services

Even if your model says a value is non-nullable, external data may violate it.

Example:

Code
public sealed class ExternalCustomerDto
{
    public string? Name { get; init; }
    public string? Email { get; init; }
}

Convert to a safer internal model:

Code
public sealed class Customer
{
    public Customer(string name, string email)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        ArgumentException.ThrowIfNullOrWhiteSpace(email);

        Name = name;
        Email = email;
    }

    public string Name { get; }
    public string Email { get; }
}

public static Customer Map(ExternalCustomerDto dto)
{
    ArgumentNullException.ThrowIfNull(dto);

    if (string.IsNullOrWhiteSpace(dto.Name))
    {
        throw new InvalidOperationException("External customer name is missing.");
    }

    if (string.IsNullOrWhiteSpace(dto.Email))
    {
        throw new InvalidOperationException("External customer email is missing.");
    }

    return new Customer(dto.Name, dto.Email);
}

Good habit:

Treat external data as untrusted, validate it once, and convert it into a safer internal model.

Null-safety in configuration and options

Configuration values are often missing at runtime.

Example:

Code
public sealed class StorageOptions
{
    public required string ConnectionString { get; init; }
    public required string ContainerName { get; init; }
}

Validation:

Code
builder.Services
    .AddOptions<StorageOptions>()
    .Bind(builder.Configuration.GetSection("Storage"))
    .Validate(options => !string.IsNullOrWhiteSpace(options.ConnectionString), "Storage connection string is required.")
    .Validate(options => !string.IsNullOrWhiteSpace(options.ContainerName), "Storage container name is required.")
    .ValidateOnStart();

Good habit:

  • Make required options non-nullable.
  • Validate options at startup.
  • Avoid allowing the application to start with invalid configuration.
  • Do not hide missing configuration with fake defaults.

Null-safety and async code

Nullable annotations work with async methods too.

Code
public async Task<Customer?> FindCustomerAsync(Guid id)
{
    return await _repository.FindByIdAsync(id);
}

Caller:

Code
Customer? customer = await service.FindCustomerAsync(id);

if (customer is null)
{
    return Results.NotFound();
}

return Results.Ok(customer);

Avoid returning Task<Customer> when the result may be missing. That forces callers to discover nullability through documentation or runtime behavior.

Also avoid Task<Customer?>?. A task itself should almost never be null.

Bad:

Code
public Task<Customer?>? FindCustomerAsync(Guid id);

Better:

Code
public Task<Customer?> FindCustomerAsync(Guid id);

The task should always exist. The result inside the task may be null.

Null-safety with LINQ

LINQ methods can return null depending on the method and data.

Examples:

Code
Customer? customer = customers.FirstOrDefault(c => c.Email == email);

if (customer is null)
{
    return null;
}

return customer.Name;

For collections of nullable values:

Code
List<string?> names = ["Alice", null, "Bob"];

List<string> validNames = names
    .Where(name => name is not null)
    .Select(name => name!)
    .ToList();

In some cases, use pattern matching:

Code
List<string> validNames = names
    .OfType<string>()
    .ToList();

OfType<string>() filters out null values and values of different types.

Be careful with First() vs FirstOrDefault():

Code
Customer customer = customers.First(c => c.Id == id); // Throws if not found
Customer? maybeCustomer = customers.FirstOrDefault(c => c.Id == id); // Returns null if not found

Use the method that matches the business expectation.

Pattern matching and null-safety

Pattern matching can make null checks expressive.

Code
if (customer is null)
{
    return;
}

if (customer.Address is { City: "London" })
{
    // customer.Address is not null in this block
}

Property pattern example:

Code
if (request is { Name.Length: > 0 })
{
    Console.WriteLine(request.Name);
}

This checks that request is not null, Name is not null, and Name.Length is greater than zero.

However, readability matters. Do not write overly clever patterns when a simple guard clause is clearer.

Common null-safety habits

Strong C# developers usually follow these habits:

Enable nullable reference types for new code

Use nullable warnings as feedback. Avoid treating warnings as noise.

Code
<PropertyGroup>
  <Nullable>enable</Nullable>
</PropertyGroup>

For stricter teams, consider treating nullable warnings as build errors after migration.

Prefer non-nullable by default

Make a value nullable only when null has a clear meaning.

Code
public string Name { get; init; } = string.Empty;
public string? Description { get; init; }

Validate at boundaries

Validate API requests, messages, files, configuration, and external service responses.

Keep domain models valid

Do not allow invalid entities to exist.

Code
public void Rename(string name)
{
    ArgumentException.ThrowIfNullOrWhiteSpace(name);
    Name = name;
}

Use empty collections instead of null collections

Code
public IReadOnlyList<OrderItem> Items { get; init; } = [];

Avoid excessive !

The null-forgiving operator should be rare and explainable.

Avoid meaningless defaults

Do not use ?? "", ?? 0, or ?? new() unless the fallback is correct for the business case.

Distinguish not-found from invalid input

Code
if (id == Guid.Empty)
{
    return BadRequest();
}

var customer = await repository.FindByIdAsync(id);

if (customer is null)
{
    return NotFound();
}

Design clear contracts

A method signature should tell the caller what can happen.

Code
Task<Customer?> FindByIdAsync(Guid id);
Task<Customer> GetRequiredByIdAsync(Guid id);
Task<IReadOnlyList<Customer>> SearchAsync(string keyword);

Common mistakes

Adding ? everywhere

Bad:

Code
public sealed class CreateOrderRequest
{
    public string? CustomerId { get; init; }
    public List<OrderItemDto>? Items { get; init; }
}

This makes every caller handle null, even when the data is required.

Better:

Code
public sealed class CreateOrderRequest
{
    public required string CustomerId { get; init; }
    public List<OrderItemDto> Items { get; init; } = [];
}

Using null! as a default fix

Bad:

Code
public string Name { get; set; } = null!;

Better:

Code
public required string Name { get; init; }

or:

Code
public string Name { get; set; } = string.Empty;

depending on the business meaning.

Hiding bugs with ?.

Bad:

Code
_orderService?.Process(order);

If _orderService is required, this silently skips work.

Better:

Code
_orderService.Process(order);

and ensure _orderService is initialized correctly.

Returning null for collections

Bad:

Code
public IReadOnlyList<Customer>? Search(string keyword)
{
    return null;
}

Better:

Code
public IReadOnlyList<Customer> Search(string keyword)
{
    return [];
}

Ignoring nullable warnings

Warnings are often early indicators of runtime bugs.

Bad habit:

Code
#pragma warning disable CS8602

Use warning suppression only when you understand why the warning is incorrect and cannot express the contract better.

Confusing compiler safety with runtime validation

Nullable annotations are not a replacement for input validation.

Code
public void Register(string email)
{
    // email is non-nullable, but public callers, reflection, tests, or legacy code may still pass null.
    ArgumentException.ThrowIfNullOrWhiteSpace(email);
}

Best practices

Use nullable reference types as API documentation

Method signatures should be self-explanatory.

Code
public interface IUserRepository
{
    Task<User?> FindByEmailAsync(string email);
    Task<IReadOnlyList<User>> SearchAsync(string keyword);
}

This tells callers:

  • FindByEmailAsync may not find a user.
  • SearchAsync always returns a collection, possibly empty.

Make invalid states unrepresentable

Prefer constructors, required, and validation.

Code
public sealed class EmailAddress
{
    public EmailAddress(string value)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(value);

        if (!value.Contains('@'))
        {
            throw new ArgumentException("Invalid email address.", nameof(value));
        }

        Value = value;
    }

    public string Value { get; }
}

Use nullable annotations at boundaries

External DTOs can be more nullable because external data may be incomplete.

Internal domain models should be less nullable because they should represent valid business state.

Keep nullable warnings visible

A good production standard is:

  • New code should have no nullable warnings.
  • Existing warnings should be tracked and gradually reduced.
  • Suppressions should be rare and documented.
  • Nullable context should be enabled in new projects.

Prefer explicit handling over silent fallback

Bad:

Code
var email = request.Email ?? "";

Better:

Code
if (string.IsNullOrWhiteSpace(request.Email))
{
    return BadRequest("Email is required.");
}

Use nullable attributes for reusable helpers

Without attributes, the compiler may not understand custom null-check methods.

Code
public static bool IsPresent([NotNullWhen(true)] string? value)
{
    return !string.IsNullOrWhiteSpace(value);
}

Review database and API contracts during migration

When enabling nullable reference types in an existing project, review:

  • Public APIs
  • DTOs
  • EF Core entities
  • Database migrations
  • JSON serialization behavior
  • Tests
  • External integrations
  • Reflection-based frameworks

Nullable migration is partly a design exercise, not just a syntax update.

Practical comparison table

ScenarioRecommended approachReason
Required constructor dependencyNon-nullable parameter + guard clauseFail fast and document dependency contract
Optional user inputNullable property or parameterNull is part of valid input
Required API request fieldNon-nullable or required + validationClear contract and runtime protection
Lookup may not find dataReturn T? or result typeCaller must handle absence
Search returns no rowsReturn empty collectionEasier and safer for callers
EF Core required navigationNon-nullable with careful initialization or null!Framework initializes it
External API responseNullable DTO properties + mapping validationExternal data is untrusted
Required domain valueConstructor validationKeeps domain model valid
Lazy initialization??= or initialized propertyAvoid repeated null checks
Custom null-check helperNullable static analysis attributesHelps compiler understand contract

Example: before and after nullable improvements

Before:

Code
public class CustomerService
{
    private ICustomerRepository _repository;

    public CustomerService(ICustomerRepository repository)
    {
        _repository = repository;
    }

    public async Task<CustomerDto> GetByEmail(string email)
    {
        var customer = await _repository.FindByEmail(email);

        return new CustomerDto
        {
            Id = customer.Id,
            Name = customer.Name,
            PhoneNumber = customer.PhoneNumber
        };
    }
}

Problems:

  • _repository could be null if constructed incorrectly.
  • email might be null or whitespace.
  • FindByEmail might return null.
  • customer.Id may throw.
  • The method contract does not show what happens when the customer is not found.

After:

Code
public sealed class CustomerService
{
    private readonly ICustomerRepository _repository;

    public CustomerService(ICustomerRepository repository)
    {
        ArgumentNullException.ThrowIfNull(repository);
        _repository = repository;
    }

    public async Task<CustomerDto?> FindByEmailAsync(string email)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(email);

        Customer? customer = await _repository.FindByEmailAsync(email);

        if (customer is null)
        {
            return null;
        }

        return new CustomerDto
        {
            Id = customer.Id,
            Name = customer.Name,
            PhoneNumber = customer.PhoneNumber
        };
    }
}

Repository contract:

Code
public interface ICustomerRepository
{
    Task<Customer?> FindByEmailAsync(string email);
}

DTO:

Code
public sealed class CustomerDto
{
    public required Guid Id { get; init; }
    public required string Name { get; init; }
    public string? PhoneNumber { get; init; }
}

The improved version makes the nullable behavior explicit and forces callers to handle the not-found case.

Interview Practice

PreviousExceptionsNext UpObject-Oriented Programming