Use a Result<T, TError> Type for Expected Failures Instead of Nulls, Out Params, and Try/Catch
Use a Result<T, TError> Type for Expected Failures Instead of Nulls, Out Params, and Try/Catch
The Standard
For operations whose failure is an expected, recoverable outcome (parsing, lookups, cross-field validation), return Result<T, TError> — a small success/failure wrapper exposing IsSuccess, Value, and Error — instead of throwing/catching exceptions for control flow, returning null and hoping callers check it, or scattering ad-hoc try/catch blocks around each fallible call. Compose multiple such operations by collecting each Result, checking IsSuccess, and only proceeding to use .Value once all validations have passed.
Why
The "before" BooksHandlers.PostBook wraps culture parsing and BookTitle construction each in their own try { ... } catch { ... } block, inlines the publisher/author lookup loops directly in the handler, and falls back to ?? throw new InvalidOperationException() at the point of use to placate the nullable-reference-type checker — even though the preceding validation already proved those values weren't null. The "after" version factors each fallible step into a method or extension returning Result<T, string> (LoadPublisher, LoadAuthors, "...".TryParseCultureName(), BookTitle.TryCreate(...)), checks !result.IsSuccess uniformly to accumulate validation errors, and only reads .Value after a single combined if (validationErrors.ContainsErrors()) return Results.BadRequest(...) gate — eliminating every try/catch and every ?? throw new InvalidOperationException() "this can't actually happen" escape hatch. The exception-based and Result-based versions produce identical HTTP responses; the difference is that failure is now a value the compiler forces you to look at (Result.Value throws InvalidOperationException if read without checking IsSuccess first) rather than a side channel (an exception or a silently-ignorable null) that's easy to forget to handle.
Before (Anti-pattern)
CultureInfo? titleCulture = null;
try { titleCulture = CultureInfo.GetCultureInfo(book.TitleCulture, true); }
catch { validationErrors.AddFieldValidationError(nameof(book.TitleCulture), "Invalid title culture name"); }
BookTitle? title = null;
if (titleCulture != null)
{
try { title = new(book.Title, titleCulture); }
catch { validationErrors.AddFieldValidationError(nameof(book.Title), "Invalid title"); }
}
// ...
Book newBook = Book.CreateNew(title ?? throw new InvalidOperationException(), /* ... */);After (Standard)
public static class CultureInfoParsing
{
public static Result<CultureInfo, string> TryParseCultureName(this string cultureName)
{
try { return Result<CultureInfo, string>.Success(CultureInfo.GetCultureInfo(cultureName, true)); }
catch (CultureNotFoundException) { return Result<CultureInfo, string>.Failure("Invalid culture name"); }
}
}
Result<CultureInfo, string> titleCulture = book.TitleCulture.TryParseCultureName();
if (!titleCulture.IsSuccess) validationErrors.AddFieldValidationError(nameof(book.TitleCulture), titleCulture.Error);
if (validationErrors.ContainsErrors()) return Results.BadRequest(validationErrors);
Result<BookTitle, string> title = BookTitle.TryCreate(book.Title, titleCulture.Value);
// title.Value is now guaranteed valid — no `?? throw` neededRules for LLMs / Agents
- For any operation whose failure is expected and recoverable (parsing, "not found" lookups, business-rule validation), return
Result<T, TError>rather than throwing, returningnull, or using anout bool TryXpattern. - Wrap unavoidable exception-throwing APIs (e.g.,
CultureInfo.GetCultureInfo) at the boundary in a singletry/catchthat converts the exception intoResult.Failure(...); do not let that exception propagate into calling code as control flow. - Collect every
Resultfor a multi-field validation, check all of them, and only branch out (return Results.BadRequest(...)) once after accumulating all errors — do notreturnon the first failure if the goal is to report every invalid field at once. - Never read
.Valuebefore confirming.IsSuccess— treat aResultas failed-until-proven-successful; do not use?? throw new InvalidOperationException()as a substitute for checkingIsSuccess. - Keep
Result<T, TError>.Value/.Erroraccess guarded (throwingInvalidOperationExceptionwhen read in the wrong state) so misuse fails loudly during development instead of silently returningdefault.
When NOT to apply
Do not use Result<T, TError> for truly exceptional, non-recoverable conditions (programming errors, invariant violations, infrastructure failures like a lost DB connection) — those should still throw. Do not introduce Result for a single fallible call with no downstream composition; a simple nullable return or TryParse-style method is sufficient when there's nothing to accumulate or chain.