DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

5 Underused C# Features That Make Defensive Code Obsolete

5 Underused C# Features That Make Defensive Code Obsolete

5 Underused C# Features That Make Defensive Code Obsolete

Modern C# Is Better at Protecting Your Invariants Than You Are

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.

This article is not about syntactic sugar.

It’s about removing whole layers of defensive friction using five features that most teams still underuse.

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
};
Enter fullscreen mode Exit fullscreen mode

The compiler smiled.

Production did not.

Now:

public sealed class User
{
    public required string Name { get; init; }
    public required string Email { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

This now fails:

var user = new User
{
    Name = "Ali"
};
Enter fullscreen mode Exit fullscreen mode

Compile-time error.

Why this matters architecturally

Without required, you end up writing this everywhere:

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

    ...
}
Enter fullscreen mode Exit fullscreen mode

That code exists only because your type failed to encode reality.

With required, the invalid state is unrepresentable.

That is not convenience.

That is 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 is not.

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

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

This is time-travel waiting to happen.

Now consider:

public sealed class Order
{
    public required Guid Id { get; init; }
    public required DateTime CreatedAt { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

This works:

var order = new Order
{
    Id = Guid.NewGuid(),
    CreatedAt = DateTime.UtcNow
};
Enter fullscreen mode Exit fullscreen mode

This does not:

order.CreatedAt = DateTime.UtcNow.AddDays(-1); // compile-time error
Enter fullscreen mode Exit fullscreen mode

Why init is different

init is not about access modifiers.

It is 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 one switch:

await SomeWork().ConfigureAwait(false);
Enter fullscreen mode Exit fullscreen mode

Blunt. Context capture or not.

In .NET 8:

await SomeWork().ConfigureAwait(
    ConfigureAwaitOptions.SuppressThrowing);
Enter fullscreen mode Exit fullscreen mode

Now intent becomes explicit.

Why this matters

Infrastructure code often needs fine-grained control over continuation behavior.

Example in a pipeline:

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

This is no longer stylistic preference.

It’s semantic control over execution flow.

In high-throughput systems, async misconfiguration causes more subtle production failures than bad SQL.

This feature moves that risk into clarity.


4. CallerArgumentExpression — Self-Describing Guards

Traditional validation:

if (email == null)
    throw new ArgumentNullException(nameof(email));
Enter fullscreen mode Exit fullscreen mode

Readable, but limited.

Now:

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

Usage:

NotEmpty(user.Email);
Enter fullscreen mode Exit fullscreen mode

Error message:

Invalid value: user.Email
Enter fullscreen mode Exit fullscreen mode

Why this is transformative

This reduces boilerplate without reducing clarity.

In large validation libraries:

Ensure.NotNull(order.Items);
Ensure.NotEmpty(order.Customer.Email);
Ensure.Positive(order.TotalAmount);
Enter fullscreen mode Exit fullscreen mode

Each failure carries context automatically.

This is not syntactic cleverness.

It is eliminating repetitive defensive scaffolding.


5. Nullability Flow Attributes — Teaching the Compiler Your Intent

Nullable reference types are powerful — but conservative.

Without guidance:

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

Add the attribute:

public static bool TryGetUser(
    int id,
    [NotNullWhen(true)] out User? user)
{
    ...
}
Enter fullscreen mode Exit fullscreen mode

Now:

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

Why this matters

Without attributes, teams write defensive noise:

if (user == null)
    throw new InvalidOperationException();
Enter fullscreen mode Exit fullscreen mode

The attribute eliminates redundant checks and improves static guarantees.

In shared libraries, this reduces API misuse dramatically.


Defensive Code vs Declarative Intent

Look at the pattern across all five features:

Old Approach Modern Approach
Runtime validation Compile-time enforcement
Manual guard clauses Flow-aware attributes
Mutable objects Lifecycle-constrained objects
Async superstition Explicit configuration
Boilerplate error messaging Expression-captured context

Defensive code was never the goal.

Correct code was.

The language is finally helping.


Real-World Example: Composing All Five

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 <= 0 || string.IsNullOrWhiteSpace(currency))
        {
            payment = null;
            return false;
        }

        payment = new Payment
        {
            Id = Guid.NewGuid(),
            Amount = amount,
            Currency = currency
        };

        return true;
    }
}
Enter fullscreen mode Exit fullscreen mode

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.


The Architectural Impact

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.

It’s structural simplification.

In large systems, simplicity compounds.


Final Thought

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

Top comments (0)