What Defensive Programming Means in Modern .NET
Defensive programming is not the practice of surrounding every line with guards and try/catch. It is the deliberate design of software that remains predictable when inputs are invalid, dependencies fail, requests are canceled, operations overlap, configuration is wrong, or assumptions stop being true.
The strongest defensive code often contains fewer runtime checks because the type system, API contract, database, or application boundary already guarantees the invariant.
Core principle: make invalid states difficult or impossible to represent, validate where uncertainty enters the system, and handle realistic failures explicitly without hiding bugs.
During a code review or technical audit, I am less interested in counting null checks than in asking: what can realistically go wrong here, and does the design make the outcome explicit?
1. Defend Trust Boundaries, Not Every Line of Code
Validation is most valuable where data crosses a trust boundary. HTTP requests are obvious examples, but messages, files, webhooks, external APIs, configuration, and legacy database records can all contain values your application did not create or cannot fully trust.
A good boundary translates uncertain external data into a validated internal representation. Deeper code can then rely on established invariants instead of repeating the same validation at every layer.
Problem
public async Task HandleAsync(CreateOrderRequest request, CancellationToken cancellationToken)
{
if (request is null)
throw new ArgumentNullException(nameof(request));
if (request.CustomerId <= 0)
throw new ArgumentException("CustomerId is required.");
if (request.Items is null || request.Items.Count == 0)
throw new ArgumentException("At least one item is required.");
// The same checks continue in deeper layers...
}
Better
public async Task<IResult> CreateOrder(
CreateOrderRequest request,
IOrderService orderService,
CancellationToken cancellationToken)
{
var command = CreateOrderCommand.Create(request.CustomerId, request.Items);
await orderService.CreateAsync(command, cancellationToken);
return Results.Accepted();
}
The exact validation mechanism can be manual, framework-based, or library-based. The important decision is where the untrusted representation becomes a trusted internal contract.
Audit signal: the same null, range, or format checks appear in several layers, or external DTOs travel deep into business logic without a clear validation and translation boundary.
Validate and translate uncertain data at the trust boundary, then let internal code rely on the established contract.
2. Make Invalid States Hard to Represent
Guard clauses are useful, but a better API can eliminate entire categories of guards. Modern C# gives us nullable reference types, records, required members, init-only state, pattern matching, and expressive types that move useful guarantees closer to compile time.
Problem
public Task SendInvoiceAsync(
string customerId,
string email,
decimal amount,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(customerId))
throw new ArgumentException("Customer ID is required.");
if (string.IsNullOrWhiteSpace(email))
throw new ArgumentException("Email is required.");
if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(amount));
// ...
}
Better
public readonly record struct CustomerId
{
public Guid Value { get; }
private CustomerId(Guid value) => Value = value;
public static CustomerId Create(Guid value)
{
if (value == Guid.Empty)
throw new ArgumentException("Customer ID cannot be empty.", nameof(value));
return new CustomerId(value);
}
}
public sealed record EmailAddress
{
public string Value { get; }
private EmailAddress(string value) => Value = value;
public static EmailAddress Create(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
if (!System.Net.Mail.MailAddress.TryCreate(value, out var address))
throw new ArgumentException("Invalid email address.", nameof(value));
return new EmailAddress(address.Address);
}
}
This is not an argument for turning every primitive into a value object. A new type earns its place when it carries a meaningful invariant, prevents accidental mixing, or makes an important concept explicit. The email example uses the real MailAddress.TryCreate API for parsing; whether that syntax is sufficient for your product is still a domain decision, because deliverability and business acceptance rules are separate concerns.
Audit signal: APIs accept combinations of primitive values that are meaningless or invalid, while every caller is expected to remember undocumented rules.
3. Let Nullability Express the Contract
null should have a meaning. With nullable reference types enabled, Customer and Customer? communicate different contracts. Treat that distinction as design information rather than compiler noise.
public Customer? FindCustomer(CustomerId id)
{
// Absence is a normal outcome.
return customers.SingleOrDefault(x => x.Id == id);
}
public void Process(Order order)
{
ArgumentNullException.ThrowIfNull(order);
// A null Order is a contract violation, not a reason to silently return.
}
Avoid using ! merely to silence warnings. Empty collections are also usually preferable to nullable collections when “no items” is a normal state.
Audit signal: frequent null-forgiving operators, silent returns on unexpected nulls, or nullable values whose absence has no defined meaning.
4. Separate Validation, Preconditions, Business Rules, and Invariants
Not every check that rejects a value represents the same concern. Keeping the categories separate makes failure behavior clearer.
Concern | Example | Typical location |
|---|---|---|
Input validation | Malformed date or missing field | Trust boundary |
Precondition | Required argument is null | Public API / method contract |
Business rule | Order cannot be canceled after shipment | Domain/application logic |
Invariant | Email must be unique | Model and/or database guarantee |
Repeating input validation in every internal method usually adds noise. Conversely, relying only on controller validation for a business invariant is too weak when another entry point can invoke the operation.
Audit signal: validation is duplicated across layers, or it is unclear whether a rejected value represents bad input, a business rejection, or a programmer error.
5. Fail Fast on Invalid Configuration
If the application cannot operate correctly without a configuration value, discover that during startup rather than on the first production request that reaches the affected path.
public sealed class PaymentOptions
{
public const string SectionName = "Payments";
public Uri? BaseAddress { get; init; }
public TimeSpan Timeout { get; init; }
}
builder.Services
.AddOptions<PaymentOptions>()
.Bind(builder.Configuration.GetSection(PaymentOptions.SectionName))
.Validate(options => options.BaseAddress is { IsAbsoluteUri: true }, "BaseAddress must be absolute.")
.Validate(options => options.Timeout > TimeSpan.Zero, "Timeout must be greater than zero.")
.ValidateOnStart();
ValidateOnStart() moves options validation to startup. This is different from requiring every dependency to be reachable at startup. A valid endpoint can still be temporarily unavailable, which is a resilience problem rather than an invalid configuration.
Audit signal: required configuration is silently defaulted, parsed repeatedly, or first validated deep inside a request path.
6. Handle Exceptions Where You Can Add Meaning
Defensive programming does not mean catching every exception. Catch when you can recover, translate the failure at a meaningful boundary, add context owned by that layer, or perform required cleanup.
In the next example, DatabaseException and OrderStorageException are intentionally application/provider-specific exception types. The important part is catching a failure you understand at this boundary and preserving the original exception as the inner exception.
Problem
try
{
return await repository.GetOrderAsync(id, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Could not load order.");
return null;
}
Why it matters: the caller can no longer distinguish “order does not exist” from “database failed”, so a real outage starts looking like normal absence.
Better
try
{
return await repository.GetOrderAsync(id, cancellationToken);
}
catch (DatabaseException ex)
{
throw new OrderStorageException($"Could not load order {id}.", ex);
}
Do not log and rethrow the same exception at every layer. Preserve the original exception when wrapping, and use throw; rather than throw ex; when rethrowing the current exception.
Audit signal: broad catches return null, false, empty collections or defaults, or one exception is logged repeatedly.
7. Choose a Failure Mode That Matches the Problem
Situation | Possible representation | Reason |
|---|---|---|
Broken invariant / programmer error | Exception | Continuing may hide a defect |
Expected validation rejection | Validation result | Caller can correct input |
Normal absence | Nullable/option-style result | Absence is not exceptional |
Transient dependency failure | Explicit failure, bounded retry, or degradation | Recovery may be possible |
Irreversible partial state | Failure plus compensation/reconciliation | Simple rollback may no longer exist |
Audit signal: unrelated failure categories collapse into the same bool, null, or generic exception.
8. Use Fallbacks Only When They Are Semantically Valid
A fallback is a product or domain decision, not an automatic error-handling technique. Returning cached data can be legitimate if stale data is explicitly acceptable. Returning zero because an exception occurred usually is not.
Problem
try
{
return await pricingClient.GetPriceAsync(productId, cancellationToken);
}
catch
{
return 0m;
}
Better
try
{
return await pricingClient.GetPriceAsync(productId, cancellationToken);
}
catch (HttpRequestException ex) when (IsTransientFailure(ex))
{
var cachedPrice = await cache.GetPriceAsync(productId, cancellationToken);
return cachedPrice
?? throw new PricingUnavailableException(productId, ex);
}
static bool IsTransientFailure(HttpRequestException exception)
{
if (exception.StatusCode is null)
return true; // Transport-level failure. Refine for your dependency.
var statusCode = (int)exception.StatusCode.Value;
return statusCode == StatusCodes.Status408RequestTimeout
|| statusCode == StatusCodes.Status429TooManyRequests
|| statusCode >= StatusCodes.Status500InternalServerError;
}
The IsTransientFailure predicate is deliberately application-specific. The sample treats transport failures, HTTP 408, HTTP 429, and 5xx responses as candidates for a fallback, but your dependency contract may require a narrower policy. Do not treat every exception as transient by default.
Audit signal: ?? default, empty objects, zero values, or cached data appear after failures without a reason why the fallback remains correct enough.
9. Bound Calls to External Dependencies
An external call that can wait indefinitely can consume request capacity and amplify a dependency incident. External operations should have a bounded execution time appropriate to the workload. The example below uses the current Microsoft.Extensions.Http.Resilience integration for HttpClient.
builder.Services
.AddHttpClient<CatalogClient>(client =>
{
client.BaseAddress = new Uri("https://catalog.example.com/");
})
.AddStandardResilienceHandler(options =>
{
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(10);
options.Retry.DisableForUnsafeHttpMethods();
});
Timeouts are not universal magic numbers. Choose them from the operation's latency budget. Also remember that a timeout does not prove the remote side did nothing: the operation may have succeeded and only the response was lost.
Audit signal: outbound calls have no explicit execution budget, or nested retry layers can exceed the caller's deadline.
10. Retry Only When the Operation Is Safe to Retry
Retries can recover from transient failures, but repeating a side effect can be harmful. Current .NET HTTP resilience guidance explicitly provides mechanisms to disable retries for unsafe HTTP methods.
Consider a payment that is committed by the provider but whose response is lost. The client sees a timeout. Retrying without idempotency can create a second charge.
public Task<PaymentResult> ChargeAsync(
OrderId orderId,
Money amount,
CancellationToken cancellationToken)
{
var request = new HttpRequestMessage(HttpMethod.Post, "payments");
request.Headers.Add("Idempotency-Key", orderId.Value.ToString());
return SendPaymentAsync(request, amount, cancellationToken);
}
A timeout describes what the caller observed, not necessarily what the remote system executed. Provider-supported idempotency can make a retry refer to the same logical operation.
Retries should be bounded, target failures that are actually transient, use appropriate backoff, and respect server guidance such as Retry-After. The Idempotency-Key example assumes the remote API explicitly supports and enforces that contract. Sending a header by itself does not make an operation idempotent. Idempotency is a property of the operation and participating systems, not something a retry library can invent.
Audit signal: generic retry policies wrap writes, payments, emails, or other side effects without considering duplicate execution.
11. Treat Cancellation as Part of the Operation Contract
A CancellationToken is not decoration. Propagate it when cancellation is meaningful, but understand what cancellation means after side effects occur.
public async Task PlaceOrderAsync(
Order order,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
await using var transaction =
await database.BeginTransactionAsync(cancellationToken);
await orderRepository.SaveAsync(order, cancellationToken);
await outbox.EnqueueAsync(
new OrderCreated(order.Id),
cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
The example uses a transactional outbox shape so the order and the intent to publish its event are committed together. After a successful commit, canceling the original HTTP request does not undo that durable state; a separate dispatcher can publish the outbox message. The broader principle is the point of no cancellation: once an irreversible boundary has been crossed, cancellation semantics must preserve consistency instead of pretending the earlier side effect can be rolled back.
Audit signal: tokens are accepted but not propagated, cancellation becomes a generic error, or a token is mechanically passed after irreversible side effects.
12. Assume Concurrent Execution Unless You Can Prove Otherwise
Code that works in a single-request test can fail when two requests or workers execute simultaneously.
Problem: check, then act
if (!await repository.EmailExistsAsync(email, cancellationToken))
{
await repository.CreateUserAsync(user, cancellationToken);
}
Two requests can both observe that the email does not exist, and both attempt the insert.
Why it matters: a pre-check can improve feedback, but it cannot guarantee uniqueness under concurrency.
Protect the invariant at the source
CREATE UNIQUE INDEX IX_Users_Email
ON dbo.Users(Email);
try
{
await repository.CreateUserAsync(user, cancellationToken);
}
catch (DuplicateUserException)
{
return CreateUserResult.EmailAlreadyExists;
}
An application pre-check can improve feedback, but the database constraint is what guarantees uniqueness when requests race.
DuplicateUserException in the example represents an application-level exception produced after translating the database provider's unique-key violation. The right mechanism might instead be a transaction, optimistic concurrency, atomic operation, idempotency or synchronization. Do not default to lock, especially in distributed systems.
Audit signal: correctness depends on a read followed by a write while assuming nothing can change between them.
13. Protect Invariants Where They Can Actually Be Guaranteed
Application validation improves errors and UX, but it may not be the final enforcement mechanism. If a rule must hold under concurrency, restarts, and multiple application instances, protect it at a layer capable of guaranteeing it.
Database uniqueness, foreign keys, check constraints, and transactions are defensive tools too. They complement application logic.
Audit question: if this invariant must always be true, what actually makes it true?
14. Turn Important Assumptions Into Enforceable Contracts
Comments such as “this should never happen” are useful only if something makes the statement true.
Weak
var customer = await repository.GetAsync(customerId);
// Customer should always exist here.
return customer!.Email;
Stronger
var customer = await repository.GetAsync(customerId)
?? throw new InvalidOperationException(
$"Customer {customerId} must exist before this operation.");
return customer.Email;
An even better design may establish the invariant earlier through a factory, required relationship or database constraint. The runtime check is not the goal. The explicit guarantee is.
Audit signal: correctness depends on comments, !, undocumented call order, or assumptions no layer enforces.
15. Make Resource Ownership and Lifetime Explicit
Streams, transactions, and asynchronous disposable resources should have clear ownership. DI lifetimes should also match the lifetime assumptions of the service.
await using var stream = await storage.OpenWriteAsync(
path,
cancellationToken);
await content.CopyToAsync(stream, cancellationToken);
Avoid manually creating and disposing a fresh HttpClient for every operation. Factory-created clients are intentionally short-lived while their underlying handlers are pooled; an appropriately configured long-lived client using SocketsHttpHandler is another supported model. Do not infer lifetime semantics from IDisposable alone.
Audit signal: it is unclear who disposes a resource, a long-lived service captures a shorter-lived dependency, or expensive clients are repeatedly constructed.
16. Leave Enough Diagnostic Signal to Understand Failure
A system that handles a failure but leaves no trace of what happened can still be difficult to operate. Use structured logging and preserve useful identifiers and dependency context without logging every implementation detail.
logger.LogWarning(
"Catalog request failed for ProductId {ProductId} with StatusCode {StatusCode}",
productId,
(int)response.StatusCode);
Correlation and trace context are usually more useful than dumping entire request objects. Log at the level that owns the operational meaning of the event.
Audit signal: failures disappear into false/null, logs say only “Something went wrong”, or one exception creates duplicate error events.
17. Fail With Context Without Leaking Sensitive Data
Diagnostic code is itself a data boundary. Requests can contain credentials, tokens, personal data, and payment metadata that should not be copied into logs or public responses.
Risky
catch (Exception ex)
{
logger.LogError(ex, "Payment failed for {@Request}", request);
return Results.Problem(ex.Message);
}
Safer
catch (PaymentException ex)
{
logger.LogError(
ex,
"Payment failed for OrderId {OrderId}",
request.OrderId);
return Results.Problem(
title: "Payment failed",
statusCode: StatusCodes.Status502BadGateway);
}
Modern .NET also provides data classification and redaction libraries for scenarios that require systematic protection of sensitive telemetry.
Audit signal: whole DTOs are destructured into logs, secrets can appear in exception messages, or internal exception details are returned directly to clients.
18. Validate Security Boundaries Too
Input validation is not authorization. A syntactically valid identifier does not mean the caller is allowed to access the resource it identifies. Defensive programming at security boundaries includes authorization, parameterized database access, safe path handling, controlled deserialization, and protection of secrets.
var order = await repository.GetAsync(orderId, cancellationToken);
if (order is null)
return Results.NotFound();
var authorizationResult = await authorizationService.AuthorizeAsync(
currentUser,
order,
"CanViewOrder");
if (!authorizationResult.Succeeded)
return Results.Forbid();
The CanViewOrder policy is application-specific, while IAuthorizationService.AuthorizeAsync is the ASP.NET Core resource-authorization API. The important point is that authorization is evaluated against the actual loaded resource rather than inferred from a route value or UI restriction.
Audit signal: code verifies that an identifier exists but never establishes that the current principal may act on the loaded resource.
19. Test Failure Paths, Not Only Happy Paths
Guard clauses, retries, and fallbacks are only claims until their behavior is exercised. Prioritize realistic failure modes:
invalid and boundary input
missing optional and required data
dependency timeouts and non-success responses
request cancellation
duplicate submissions and idempotent retries
concurrent writes where invariants matter
malformed external payloads
fallback behavior
logs and responses that must not expose sensitive data
Audit signal: the resilience code is complex, but tests only exercise successful dependency responses.
20. Know When Defensive Programming Goes Too Far
More defensive code is not automatically safer code. Every check, retry, fallback, copy, synchronization primitive and log statement adds complexity and can add runtime cost.
revalidating established invariants in every private method
wrapping every method in
try/catchretrying every exception or HTTP method
using fallbacks that hide broken configuration or bugs
creating value objects that add no useful guarantee
locking without a demonstrated concurrency problem
logging the same failure at multiple layers
performing an extra database read “for safety” when a constraint is the actual guarantee
Guiding principle: defend uncertainty. Trust contracts that have already been established and can actually be relied upon.
Defensive Programming Audit Checklist
This condensed checklist is designed for a production .NET code review or technical audit.
Where can untrusted or malformed data enter the system?
Is external data validated and translated before it reaches the core logic?
Can important invalid states be represented by the API or model?
Does nullability match the real contract?
Are business rules distinguished from input validation and preconditions?
Is required configuration validated during startup?
Are exceptions caught only where the code can recover, translate, clean up, or add meaningful context?
Are failures converted into ambiguous
null,falseor defaults?Are fallbacks deliberate and semantically valid?
Do external calls have a bounded execution time?
Are retries limited to appropriate transient failures?
Can retried operations duplicate side effects?
Is idempotency used where duplicate execution would be harmful?
Is cancellation propagated where meaningful?
Is there a point after which cancellation could leave the state inconsistent?
Can concurrent execution break check-then-act assumptions?
Are critical invariants enforced at a layer that can guarantee them?
What makes each “this can never happen” assumption true?
Is resource ownership and lifetime clear?
Do failures leave enough structured diagnostic context?
Can secrets, PII, or sensitive request data reach logs or responses?
Are authorization decisions enforced server-side at the operation boundary?
Are realistic failure paths tested?
Are defensive mechanisms adding complexity without protecting a real failure mode?
Frequently Asked Questions
Is defensive programming just input validation?
No. It also covers contracts, failure semantics, dependencies, cancellation, concurrency, resource ownership, security boundaries, diagnostics, and recovery.
Should every public method validate every argument?
Public or externally reachable APIs should enforce meaningful preconditions, but repeating the same checks throughout trusted internal code can obscure the real invariants.
Should I catch every exception?
No. Catch exceptions where you can recover, translate them meaningfully, add context, or perform necessary cleanup.
Are retries always a good resilience strategy?
No. Retry transient failures only when another attempt is useful and the operation is safe to repeat. Side effects require particular care and may need idempotency.
Does CancellationToken belong in every async method?
No mechanical rule works for every operation. Propagate cancellation where callers may cancel work, but understand what cancellation means after irreversible side effects.
Is a database constraint defensive programming?
Yes. If the database is the layer capable of guaranteeing an invariant such as uniqueness, a constraint can be stronger than an application check that can race.
How does this help during a code audit?
It provides a systematic way to inspect assumptions and failure behavior: trust boundaries, invalid states, external calls, concurrency, exception handling, logging, and recovery.
References
Conclusion
Defensive programming is most effective when it makes a system easier to reason about. The goal is not to assume every method will fail or every internal value is malicious. Identify uncertainty, establish explicit contracts, protect real invariants, and define what happens when realistic failures occur.
Modern C# and .NET give us better tools than a wall of null checks: nullable reference types, expressive APIs, startup validation, structured logging, cancellation, resilience mechanisms, and infrastructure guarantees.
When reviewing production code, one question cuts through a surprising amount of complexity:
What makes this assumption true?
If the answer is a type, validated boundary, database constraint, explicit failure contract, or tested recovery path, the design is probably on solid ground. If the answer is “it should never happen”, that is where the audit should continue.