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
};
The compiler smiled.
Production did not.
Now:
public sealed class User
{
public required string Name { get; init; }
public required string Email { get; init; }
}
This now fails:
var user = new User
{
Name = "Ali"
};
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.");
...
}
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
}
}
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; }
}
This works:
var order = new Order
{
Id = Guid.NewGuid(),
CreatedAt = DateTime.UtcNow
};
This does not:
order.CreatedAt = DateTime.UtcNow.AddDays(-1); // compile-time error
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);
Blunt. Context capture or not.
In .NET 8:
await SomeWork().ConfigureAwait(
ConfigureAwaitOptions.SuppressThrowing);
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.");
}
}
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));
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}");
}
Usage:
NotEmpty(user.Email);
Error message:
Invalid value: user.Email
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);
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
}
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
}
Why this matters
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.
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;
}
}
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)