Defensive programming is writing code that assumes inputs, clocks, disks, and other teams will lie. It is not “null-check everything” and not “never throw.” It is making implicit contracts explicit and failing in a way the caller can handle.
Trust boundaries
Draw the line: HTTP body, queue payload, file, environment, clock, “the other microservice.” Inside the line, prefer types that cannot represent illegal states. At the line, parse — do not cast.
// untrusted
var raw = request.Query["page"];
if (!int.TryParse(raw, out var page) || page < 1 || page > 1000)
return Results.BadRequest();
Do not sprinkle int.Parse on query strings and hope.
Make illegal states unrepresentable
Replace boolean flags that contradict each other with a discriminated union or a small enum. An Order that is both Cancelled and Shipped is a bug factory. In C#, records + required members + private constructors beat public setters on every field.
sealed class FlaggedOrder
{
public bool IsShipped { get; set; }
public bool IsCancelled { get; set; } // both can be true
}
enum OrderStatus { Placed, Shipped, Cancelled }
sealed record StatusOrder(OrderStatus Status);
FlaggedOrder can be shipped and cancelled at once. StatusOrder cannot.
Fail closed vs fail open
Authorization, payments, and privacy: fail closed (deny if unsure). Observability and optional recommendations: fail open with a metric. Defaulting “allow” on a missing role claim is an incident.
Assertions, not business control flow
Use assertions / Debug.Assert / “this is a programmer error” exceptions for broken invariants (null repository in a composition root). Use domain results or HTTP 4xx for user-fixable mistakes. Catching Exception around a whole request and returning 200 hides outages.
Timeouts, cancellation, idempotency
Pass CancellationToken. Honor it in I/O. Treat client disconnect as cancel, not as “keep writing the report.” For handlers that may run twice (at-least-once queues, user double-submit), store an idempotency key. See reliability patterns.
What breaks it
The catch turns a downed gateway into HTTP 200. Delete it: the fault stays a 500. Return a 4xx only for input the caller can fix.
static IResult HideTheFault()
{
try
{
throw new InvalidOperationException("gateway down");
}
catch (Exception)
{
return Results.Ok();
}
}
Pitfalls
- Swallowing exceptions to “keep the app up.”
- Logging tokens, PAN, or session cookies “for debug.”
- Defensive copies nowhere, then mutating a cached DTO.
- Validating only on the client.
Related: OWASP Top Ten, SQL for Business Logic.