5 Underused C# Features That Make Defensive Code Obsolete
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
requiredyou 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 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.
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.
It’s 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