Detect invalid state at the earliest point: guard clauses, ArgumentNullException.ThrowIfNull, options validation on start, ValidateOnBuild; fail fast versus resilience at boundaries.
Fail fast means detecting a problem at the earliest point and stopping clearly, instead of carrying bad state forward until it causes a confusing failure later. In .NET that means guard clauses (ArgumentNullException.ThrowIfNull, ArgumentOutOfRangeException.ThrowIfNegativeOrZero), constructors that refuse invalid objects, nullable reference types, options validation with ValidateOnStart(), and DI ValidateOnBuild. It does not mean crashing on every transient error: inside a component I fail fast on programming and configuration errors, and at boundaries (network, other services) I use retries, timeouts and graceful degradation.
"Fail fast means detecting a problem at the earliest possible point and stopping loudly and clearly, instead of carrying bad state forward until it causes a confusing failure somewhere else, often hours later and far from the cause. In .NET I push failures as early as they can go. At compile time: nullable reference types,
requiredmembers, strong types and value objects. At startup: options validated withValidateDataAnnotations().ValidateOnStart(), and DI checked withValidateOnBuild/ValidateScopes, so a broken deployment fails its readiness probe instead of taking traffic. At object creation: constructors and factories that refuse invalid objects. At method entry: guard clauses likeArgumentNullException.ThrowIfNullandArgumentOutOfRangeException.ThrowIfNegativeOrZero. The part that keeps it from making systems fragile is classifying failures. Programming errors and misconfiguration should fail fast. Transient faults at boundaries (a slow partner API, a dropped connection) should be handled with timeouts, retries, circuit breakers and graceful degradation. AndEnvironment.FailFastis reserved for state that's unsafe to continue with."
| Stage | .NET mechanism | Catches |
|---|---|---|
| Compile time | Nullable reference types, required members, init, strong types |
Nulls, missing initialisation, primitive mix-ups |
| Startup | ValidateOnStart() for options; ValidateOnBuild / ValidateScopes for DI |
Missing/invalid configuration, missing registrations, captive dependencies |
| Object creation | Constructor/factory guards, value objects (Abn, Money, Email) |
Invalid objects existing at all |
| Method entry | ThrowIfNull, ThrowIfNullOrWhiteSpace, ThrowIfNegativeOrZero, ThrowIfGreaterThan |
Invalid arguments |
| API boundary | Model validation → 400 ProblemDetails | Bad client input |
| Impossible state | throw new UnreachableException() (.NET 7+), Debug.Assert |
Logic bugs |
| Catastrophic corruption | Environment.FailFast(message) |
State unsafe to continue with |
| Failure | Category | Response |
|---|---|---|
| Missing connection string / invalid URL setting | Configuration | Fail at startup, block the rollout |
| Missing DI registration | Programming | ValidateOnBuild fails Build() |
| Null/invalid argument from a caller | Programming | Guard clause → ArgumentException |
| Invalid client input | Expected | 400 with details (fail fast for that request, not the process) |
| Partner API timeout | Transient | Retry with backoff + jitter, timeout |
| Dependency down for minutes | Environmental | Circuit breaker, fallback, degrade |
| Corrupted critical in-memory state | Catastrophic | Environment.FailFast (skips finally, writes crash dump) |
The rule of thumb: fail fast inside a component for things that indicate a bug or misconfiguration; be resilient at boundaries for things the environment does.
| Without | With |
|---|---|
| App starts, health checks green, first real request fails | App refuses to start with a clear OptionsValidationException |
| Bad config discovered by a customer | Readiness probe fails; Kubernetes/slots stop the rollout |
| Rollback after customer impact | Rollout never completes; no impact |
Our [checkout service] was deployed with a mistyped payment configuration key. The app started, health checks were green, and nothing happened until the first customer tried to pay [40 minutes] later and got an error from a null gateway URI. The options were bound but never validated, so the failure surfaced only when the gateway client was first created. I added data annotations ([Required, Url], [Range]) to the options class and registered it with ValidateDataAnnotations().ValidateOnStart(), turned on ValidateOnBuild and ValidateScopes, and made the readiness probe depend on successful startup. We also validated configuration files against a schema in CI. The next misconfiguration failed the rollout within seconds and never reached customers.
Checks defensive programming and operational maturity.
Green flags
ValidateOnStart.Red flags
Jim Shore's Fail Fast (IEEE Software, 2004) argued that code which fails immediately and visibly is more robust, not less, because defects are found and fixed instead of hiding. The same idea appears in Erlang's "let it crash" philosophy, paired with supervision: fail fast locally, recover at a higher level.
Controller (null customerId accepted)
→ Service (passes it along)
→ Repository (builds a query)
→ Mapper → NullReferenceException in an unrelated place, minutes or hours later
The stack trace points at the symptom, not the cause; logs lack the original context. A guard at the controller or service boundary would have produced ArgumentNullException: customerId immediately.
| Helper | Since |
|---|---|
ArgumentNullException.ThrowIfNull |
.NET 6 |
ArgumentException.ThrowIfNullOrEmpty |
.NET 7 |
ObjectDisposedException.ThrowIf |
.NET 7 |
ArgumentException.ThrowIfNullOrWhiteSpace |
.NET 8 |
ArgumentOutOfRangeException.ThrowIfNegative, ThrowIfNegativeOrZero, ThrowIfZero, ThrowIfGreaterThan, ThrowIfLessThan, ThrowIfEqual... |
.NET 8 |
They use [CallerArgumentExpression] to fill in the parameter name automatically and keep the throwing code out of the hot path (better inlining).
builder.Services.AddOptions<PaymentOptions>()
.BindConfiguration("Payments")
.ValidateDataAnnotations()
.Validate(o => o.TimeoutSeconds < o.TotalBudgetSeconds, "Timeout must be below the total budget")
.ValidateOnStart();
ValidateOnStart runs validators during host startup (before requests).IValidateOptions<T> handles cross-field rules.[OptionsValidator]) that's trimming/AOT-friendly.public readonly record struct Email
{
public string Value { get; }
public Email(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
if (!value.Contains('@')) throw new ArgumentException("Invalid email", nameof(value));
Value = value.Trim();
}
}
Once an Email exists it's valid everywhere, so downstream code needs no re-checks.
try { ProcessPayment(); }
catch (Exception) { } // the opposite of fail fast: bad state continues silently
Catch only exceptions you can handle meaningfully, add context, and let the rest reach the global handler (IExceptionHandler, BackgroundService exception behaviour).
Environment.FailFastfinally blocks, finalisers and graceful shutdown.Payments:BaseUrl and observe the startup failure.ValidateOnBuild on, and see Build() fail.if (x == null) throw ... checks with ThrowIfNull and compare messages.catch blocks and decide what each should really do.using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<PaymentOptions>()
.BindConfiguration("Payments")
.ValidateDataAnnotations()
.ValidateOnStart(); // misconfiguration stops startup, not the first payment
builder.Host.UseDefaultServiceProvider(o =>
{
o.ValidateScopes = true;
o.ValidateOnBuild = true; // missing registrations fail at Build()
});
builder.Services.AddSingleton<RefundCalculator>();
var app = builder.Build();
app.MapGet("/refund/{amount:decimal}", (decimal amount, RefundCalculator calc, IOptions<PaymentOptions> opt) =>
Results.Ok(new { refund = calc.Refund(amount, 0.1m), gateway = opt.Value.BaseUrl }));
app.Run();
public sealed class PaymentOptions
{
[Required, Url] public string BaseUrl { get; set; } = "";
[Range(1, 60)] public int TimeoutSeconds { get; set; } = 10;
}
public sealed class RefundCalculator
{
public decimal Refund(decimal amount, decimal feeRate)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
ArgumentOutOfRangeException.ThrowIfNegative(feeRate);
ArgumentOutOfRangeException.ThrowIfGreaterThan(feeRate, 1m);
return Math.Round(amount * (1 - feeRate), 2);
}
}
Run without a Payments:BaseUrl setting and the host throws OptionsValidationException at startup.
What happens: catch (Exception) { } or log-and-continue everywhere.
Why it is wrong: bad state propagates; the real failure appears later, far from its cause.
Do instead: catch only what you can handle; let the rest reach a global handler.
What happens: options read and checked on first use.
Why it is wrong: the app looks healthy until a user hits the path.
Do instead: ValidateOnStart() so deployments fail before taking traffic.
What happens: a timeout to a partner API crashes the request pipeline or process. Why it is wrong: the system becomes fragile under normal partial failures. Do instead: retries, timeouts, circuit breakers and degradation at boundaries.
Environment.FailFast for ordinary errorsWhat happens: used for validation or I/O failures.
Why it is wrong: skips finally and graceful shutdown; turns small errors into outages.
Do instead: reserve it for state that's unsafe to continue with.
What happens: public entry points accept anything; checks happen layers later. Why it is wrong: errors surface far from the caller's mistake. Do instead: guard at public boundaries and constructors.
What happens: #nullable disable or ! sprinkled to silence warnings.
Why it is wrong: gives up compile-time fail fast.
Do instead: fix the nullability; treat warnings as errors in new code.
What happens: ValidateOnBuild only in Development.
Why it is wrong: missing registrations surface at runtime on specific paths.
Do instead: enable it in all environments (startup cost is small) or test the container in CI.
null for invalid inputWhat happens: CreateCustomer("") returns null.
Why it is wrong: the problem moves to whoever dereferences it later.
Do instead: throw, or return an explicit result type.
What happens: /health returns 200 even when critical config is broken.
Why it is wrong: orchestrators route traffic to broken instances.
Do instead: readiness checks that depend on successful startup validation and critical dependencies.
What happens: a nightly import stops at the first invalid row. Why it is wrong: fail fast applied at the wrong granularity. Do instead: fail fast per item (reject/dead-letter it), keep processing, and alert.
I distinguish failures by cause: programming and configuration errors should stop as early as possible; environmental errors at boundaries should be handled with resilience. Getting this split right is what makes systems both safe and available.
| Failure | Response |
|---|---|
| Missing or invalid configuration | Fail at startup, block deployment |
| Missing DI registration | ValidateOnBuild |
| Invalid argument from a caller | Guard clause, ArgumentException |
| Transient HTTP error | Retry with backoff (Microsoft.Extensions.Http.Resilience) |
| Dependency down for long | Circuit breaker, degrade |
| Corrupted critical state | Environment.FailFast |
The principle of least astonishment says code should behave the way a reasonable user expects from its name, signature and context. In C# that means following .NET conventions: a TryParse method returns bool and never throws for bad input, an ...Async method returns a task and accepts a CancellationToken as its last parameter, properties are cheap and have no side effects, and Equals, GetHashCode and == agree. Surprises such as a GetUser that creates a user, or a property that makes a network call, cause bugs because callers rely on the convention rather than reading the implementation.
Convention over configuration means a framework assumes sensible defaults, so you only write configuration where your case differs. EF Core is the clearest .NET example: a property called Id or OrderId becomes the primary key, navigation properties create relationships and foreign keys, and table names come from DbSet names. ASP.NET Core loads appsettings.{Environment}.json automatically, maps __ in environment variables to : in configuration keys, and finds controllers by naming. It removes boilerplate and makes projects consistent, but conventions are invisible, so I document team conventions, use ConfigureConventions for project-wide rules, and override explicitly where the default is wrong.
DIP says high-level policy should not depend on low-level details; both should depend on abstractions, and the abstraction belongs to the high-level side. For example, the domain or application layer defines IPaymentGateway, and the infrastructure project implements it with Stripe; the arrow of source dependency points inwards. Dependency injection is a technique for supplying those implementations (constructor injection), and the .NET DI container is a tool that automates it. You can use a container without inverting anything (if interfaces live next to their implementations in infrastructure), and you can invert dependencies without a container.
How to tell it (STAR)
We deployed with a typo in a payment setting and nothing noticed until a customer tried to pay [40] minutes later. I added options validation that runs at startup, so a bad configuration now stops the app before it takes traffic. The next mistake failed the rollout in seconds instead of reaching customers.
Last updated 31 Oct 2026.