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:
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:
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:
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:
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:
Enabling nullable reference types
Nullable reference types are only useful when the nullable context is enabled.
In a project file:
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
You can also enable or disable it in a specific file or region:
#nullable enable
public class Customer
{
public string Name { get; set; } = string.Empty;
}
#nullable disable
Common nullable settings:
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:
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:
public sealed class UserProfile
{
public required string UserName { get; init; }
public string? Bio { get; init; }
}
This communicates:
UserNameis required and should not be null.Biois optional and may be null.
The compiler helps enforce this intent:
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:
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:
Example:
static int GetLength(string? value)
{
return value.Length; // Warning: value may be null
}
After a null check, the compiler updates the null-state:
static int GetLength(string? value)
{
if (value is null)
{
return 0;
}
return value.Length; // Safe
}
The compiler understands common null checks:
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:
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.
Example:
public sealed class Customer
{
public string Name { get; set; } // CS8618 if not initialized
}
Fix options:
public sealed class Customer
{
public required string Name { get; init; }
}
Or:
public sealed class Customer
{
public Customer(string name)
{
Name = name;
}
public string Name { get; }
}
Or, when a property is truly optional:
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:
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:
public sealed class Customer
{
public required string Name { get; init; }
public string? PhoneNumber { get; init; }
}
Usage:
var customer = new Customer
{
Name = "Alice"
};
The compiler requires callers to initialize Name.
Constructor initialization is also strong:
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:
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:
public sealed class Product
{
public Product(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
Name = name;
}
public string Name { get; }
}
Guard clause habit:
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.
int? length = customer?.Name?.Length;
If customer is null, the expression returns null instead of throwing.
For collections or arrays:
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:
_logger?.LogInformation("Order created");
If _logger should always be injected, this hides a dependency injection bug.
Better:
ArgumentNullException.ThrowIfNull(logger);
_logger = logger;
Null-coalescing operator: ??
The null-coalescing operator provides a fallback value.
string displayName = user.DisplayName ?? user.UserName;
This is useful when the fallback is a valid business default.
Example:
public string GetGreeting(string? name)
{
return $"Hello, {name ?? "guest"}";
}
It can also be used with throw expressions:
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:
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.
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:
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.
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
[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.
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.
public Customer Customer { get; set; } = null!;
This can be acceptable, but should be intentional.
Bad use:
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:
public sealed class Employee
{
public required string FullName { get; init; }
public string? MiddleName { get; init; }
public DateTime? TerminatedAt { get; init; }
}
Here:
FullNameis required.MiddleNameis optional.TerminatedAtis null when the employee is active or termination date is not applicable.
When null has multiple possible meanings, consider a clearer model.
Instead of:
public DateTime? ProcessedAt { get; init; }
Use:
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:
public List<OrderItem> Items { get; } = new();
Instead of:
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:
foreach (var item in order.Items)
{
total += item.Price;
}
If Items is nullable, every caller must decide what null means.
For API responses, prefer:
{
"items": []
}
Instead of:
{
"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:
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:
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:
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:
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:
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
public Task<Customer?> FindByEmailAsync(string email);
This communicates that the customer may not exist.
Call site:
var customer = await repository.FindByEmailAsync(email);
if (customer is null)
{
return NotFound();
}
Throw when absence is exceptional
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
public Task<IReadOnlyList<Customer>> SearchAsync(string keyword);
Returning an empty list is usually better than returning null.
Use a result type for richer outcomes
public sealed record Result<T>(
bool IsSuccess,
T? Value,
string? Error);
Or a more precise design:
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:
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:
public T? FindReference<T>(IEnumerable<T> values, Func<T, bool> predicate)
where T : class
{
return values.FirstOrDefault(predicate);
}
For non-nullable generic type parameters:
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 : classmeans a non-nullable reference type in a nullable-enabled context.where T : class?allows nullable reference types.where T : notnullallows 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.
using System.Diagnostics.CodeAnalysis;
public static bool HasValue([NotNullWhen(true)] string? value)
{
return !string.IsNullOrWhiteSpace(value);
}
Usage:
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.
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.
using System.Diagnostics.CodeAnalysis;
[return: NotNullIfNotNull(nameof(value))]
public static string? Normalize(string? value)
{
return value?.Trim();
}
MemberNotNull
Use when a method initializes members.
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:
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:
public sealed class CreateCustomerRequest
{
public required string Name { get; init; }
public string? PhoneNumber { get; init; }
}
Meaning:
Nameis required.PhoneNumberis 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:
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:
[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:
public sealed class Customer
{
public int Id { get; set; }
public required string Name { get; set; }
public string? PhoneNumber { get; set; }
}
Typical meaning:
Nameis required.PhoneNumberis 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:
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:
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
requiredor 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:
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:
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:
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?andobject?everywhere in the application layer.
Null-safety with dependency injection
Services injected through constructors should usually be non-nullable.
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:
_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:
public sealed class ExternalCustomerDto
{
public string? Name { get; init; }
public string? Email { get; init; }
}
Convert to a safer internal model:
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:
public sealed class StorageOptions
{
public required string ConnectionString { get; init; }
public required string ContainerName { get; init; }
}
Validation:
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.
public async Task<Customer?> FindCustomerAsync(Guid id)
{
return await _repository.FindByIdAsync(id);
}
Caller:
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:
public Task<Customer?>? FindCustomerAsync(Guid id);
Better:
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:
Customer? customer = customers.FirstOrDefault(c => c.Email == email);
if (customer is null)
{
return null;
}
return customer.Name;
For collections of nullable values:
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:
List<string> validNames = names
.OfType<string>()
.ToList();
OfType<string>() filters out null values and values of different types.
Be careful with First() vs FirstOrDefault():
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.
if (customer is null)
{
return;
}
if (customer.Address is { City: "London" })
{
// customer.Address is not null in this block
}
Property pattern example:
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.
<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.
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.
public void Rename(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
Name = name;
}
Use empty collections instead of null collections
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
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.
Task<Customer?> FindByIdAsync(Guid id);
Task<Customer> GetRequiredByIdAsync(Guid id);
Task<IReadOnlyList<Customer>> SearchAsync(string keyword);
Common mistakes
Adding ? everywhere
Bad:
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:
public sealed class CreateOrderRequest
{
public required string CustomerId { get; init; }
public List<OrderItemDto> Items { get; init; } = [];
}
Using null! as a default fix
Bad:
public string Name { get; set; } = null!;
Better:
public required string Name { get; init; }
or:
public string Name { get; set; } = string.Empty;
depending on the business meaning.
Hiding bugs with ?.
Bad:
_orderService?.Process(order);
If _orderService is required, this silently skips work.
Better:
_orderService.Process(order);
and ensure _orderService is initialized correctly.
Returning null for collections
Bad:
public IReadOnlyList<Customer>? Search(string keyword)
{
return null;
}
Better:
public IReadOnlyList<Customer> Search(string keyword)
{
return [];
}
Ignoring nullable warnings
Warnings are often early indicators of runtime bugs.
Bad habit:
#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.
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.
public interface IUserRepository
{
Task<User?> FindByEmailAsync(string email);
Task<IReadOnlyList<User>> SearchAsync(string keyword);
}
This tells callers:
FindByEmailAsyncmay not find a user.SearchAsyncalways returns a collection, possibly empty.
Make invalid states unrepresentable
Prefer constructors, required, and validation.
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:
var email = request.Email ?? "";
Better:
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.
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
Example: before and after nullable improvements
Before:
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:
_repositorycould be null if constructed incorrectly.emailmight be null or whitespace.FindByEmailmight return null.customer.Idmay throw.- The method contract does not show what happens when the customer is not found.
After:
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:
public interface ICustomerRepository
{
Task<Customer?> FindByEmailAsync(string email);
}
DTO:
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.