5 Underused C# Features That Make Defensive Code Obsolete

Published: (March 2, 2026 at 02:10 PM EST)
5 min read
Source: Dev.to

Source: Dev.to

Cristian Sifuentes · 2026 · #dotnet #csharp #architecture #performance

Defensive programming used to be a badge of seniority.

  • Null checks everywhere.
  • Guard clauses in every method.
  • Private setters “just in case.”
  • Constructors bloated with validation logic.
  • Unit tests verifying invariants that the type system should have enforced in the first place.

But modern C# (10 – 13) quietly changed the deal. The language and runtime now offer mechanisms that encode intent directly into the type system and flow analysis, reducing entire categories of runtime defensive code to compile‑time guarantees.

Note – This article is not about syntactic sugar.
It’s about removing whole layers of defensive friction using five features that most teams still under‑use.
Every example below is production‑oriented. Every insight is rooted in the code.


1. required Members — Compile‑Time Invariants

For years, object initializers undermined constructor guarantees.

var user = new User
{
    Name = "Ali"
    // Email forgotten
};

The compiler smiled. Production did not.

Modern approach

public sealed class User
{
    public required string Name  { get; init; }
    public required string Email { get; init; }
}

Now this fails at compile time:

var user = new User
{
    Name = "Ali"
};

Without required you end up writing this everywhere:

public void SendEmail(User user)
{
    if (string.IsNullOrWhiteSpace(user.Email))
        throw new InvalidOperationException("Email missing.");

    // …
}

That code exists only because the type failed to encode reality.
With required, the invalid state is unrepresentable – a structural improvement in domain modeling. In large distributed systems, eliminating even 2‑3 invariant checks per request path compounds into meaningful cognitive clarity.


2. init‑Only Setters — Freezing State After Construction

The problem with private set is that it looks immutable but isn’t.

public class Order
{
    public DateTime CreatedAt { get; private set; }

    public void Recalculate()
    {
        CreatedAt = DateTime.UtcNow; // still mutable
    }
}

This is time‑travel waiting to happen.

Modern approach

public sealed class Order
{
    public required Guid      Id        { get; init; }
    public required DateTime  CreatedAt { get; init; }
}
var order = new Order
{
    Id        = Guid.NewGuid(),
    CreatedAt = DateTime.UtcNow
};

// order.CreatedAt = DateTime.UtcNow.AddDays(-1); // compile‑time error

init is not about access modifiers; it’s about lifecycle guarantees. After construction the object graph stabilizes – no accidental mutation, no hidden temporal coupling. In high‑scale systems, immutability is not elegance – it’s performance predictability.


3. ConfigureAwaitOptions (.NET 8+) — Intentional Async Semantics

For years, async configuration meant a single switch:

await SomeWork().ConfigureAwait(false);

Blunt. “Capture context or not.”

Modern approach (.NET 8)

await SomeWork().ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);

Now intent becomes explicit. Infrastructure code often needs fine‑grained control over continuation behavior.

public async Task ExecuteAsync(Func handler)
{
    try
    {
        await handler()
            .ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Pipeline failure.");
    }
}

This is no longer a stylistic preference – it’s semantic control over execution flow. In high‑throughput systems, async misconfiguration causes more subtle production failures than a bad SQL query. This feature moves that risk into clarity.


4. CallerArgumentExpression — Self‑Describing Guards

Traditional validation:

if (email == null)
    throw new ArgumentNullException(nameof(email));

Readable, but limited.

Modern approach

public static void NotEmpty(
    string value,
    [CallerArgumentExpression(nameof(value))] string? expression = null)
{
    if (string.IsNullOrWhiteSpace(value))
        throw new ArgumentException($"Invalid value: {expression}");
}

Usage

NotEmpty(user.Email);

Error message

Invalid value: user.Email

This reduces boilerplate without reducing clarity.

In larger validation libraries:

Ensure.NotNull(order.Items);
Ensure.NotEmpty(order.Customer.Email);
Ensure.Positive(order.TotalAmount);

Each failure carries context automatically.


5. Nullable Reference Types + Flow‑Aware Attributes

Nullable reference types are powerful — but conservative. Without guidance:

if (TryGetUser(id, out var user))
{
    user.DoSomething(); // warning
}

Add the attribute:

public static bool TryGetUser(
    int id,
    [NotNullWhen(true)] out User? user)
{
    // …
}

Now:

if (TryGetUser(id, out var user))
{
    user.DoSomething(); // no warning
}

Without attributes, teams write defensive noise:

if (user == null)
    throw new InvalidOperationException();

The attribute eliminates redundant checks and improves static guarantees. In shared libraries, this reduces API misuse dramatically.


Pattern Across All Five Features

Old ApproachModern Approach
Runtime validationCompile‑time enforcement
Manual guard clausesFlow‑aware attributes
Mutable objectsLifecycle‑constrained objects
Async “superstition”Explicit configuration
Boilerplate error messagingExpression‑captured context

Defensive code was never the goal. Correct code was. The language is finally helping.

public sealed class Payment
{
    public required Guid   Id       { get; init; }
    public required decimal Amount  { get; init; }
    public required string  Currency { get; init; }

    public static bool TryCreate(
        decimal amount,
        string currency,
        [NotNullWhen(true)] out Payment? payment)
    {
        if (amount  **Notice what’s missing:**  
> - No redundant null checks later  
> - No defensive mutation guards  
> - No post‑construction validation  
> - No runtime invariant drift  

The type encodes correctness.  
When these features are used consistently:

- DTO misuse drops  
- Domain invariants stabilize  
- Null‑reference exceptions plummet  
- Guard libraries shrink  
- Unit tests become behavior‑focused instead of defensive  

This is not stylistic modernization.  
Its structural simplification.  

In large systems, simplicity compounds.  
The most powerful shift in modern C# is philosophical.  
The language no longer assumes you will remember your invariants.  
It helps you encode them.  

The more your types express reality, the less your runtime needs to defend against it.  
And in 2026, that difference separates code that merely compiles **from** systems that survive scale.

*Cristian Sifuentes*  
Full‑stack engineer · Production‑scale .NET systems thinker
0 views
Back to Blog

Related posts

Read more »

GitHub Code Quality enterprise policy

You can now manage GitHub Code Quality availability separately from Code Security in GitHub Advanced Security policies. This gives you more flexibility to make...