Result Pattern — Returning Errors Without Exceptions
The Result pattern in Clean Architecture: why exceptions are wrong for business rule failures, how to implement Result and Result<T>, how to use Match for mapping, and the production bugs this pattern prevents.
The Problem With Exceptions for Business Logic
Exceptions are for unexpected failures — database timeouts, null reference bugs, network drops. Using them for expected business rule failures is expensive, misleading, and breaks control flow.
Production issue I've seen: An application was throwing
DuplicatePatientExceptionin a handler every time a form was double-submitted. The exception propagated to global middleware, got logged as a 500 error, triggered error monitoring alerts, and was treated by the on-call team as a production incident. It was a normal user action — not an incident. The Result pattern would have kept it silent and deliberate.
Exceptions: unexpected failures (DB down, NullReference, socket timeout)
Result pattern: expected outcomes (validation failed, not found, already exists)The Result Type
// Application/Common/Result.cs
namespace SystemForge.Application.Common;
public class Result
{
protected Result(bool isSuccess, Error error)
{
if (isSuccess && error != Error.None)
throw new InvalidOperationException("Success result cannot have an error.");
if (!isSuccess && error == Error.None)
throw new InvalidOperationException("Failure result must have an error.");
IsSuccess = isSuccess;
Error = error;
}
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public Error Error { get; }
public static Result Success() => new(true, Error.None);
public static Result Failure(Error error) => new(false, error);
public static Result<TValue> Success<TValue>(TValue value) => new(value, true, Error.None);
public static Result<TValue> Failure<TValue>(Error error) => new(default!, false, error);
}
public sealed class Result<TValue> : Result
{
private readonly TValue _value;
internal Result(TValue value, bool isSuccess, Error error)
: base(isSuccess, error)
{
_value = value;
}
public TValue Value =>
IsSuccess
? _value
: throw new InvalidOperationException("Cannot access value of a failed result.");
// Implicit conversion from value — convenience for happy path
public static implicit operator Result<TValue>(TValue value) =>
Result.Success(value);
}The Error Type
// Application/Common/Error.cs
public sealed record Error(string Code, string Description)
{
public static readonly Error None = new(string.Empty, string.Empty);
public static Error NotFound(string entity, string id) =>
new($"{entity}.NotFound", $"{entity} with ID '{id}' was not found.");
public static Error Conflict(string code, string description) =>
new(code, description);
public static Error Validation(string field, string message) =>
new($"Validation.{field}", message);
}
// Domain/Errors/PatientErrors.cs
public static class PatientErrors
{
public static readonly Error NotFound =
new("Patient.NotFound", "Patient was not found.");
public static readonly Error MRNAlreadyExists =
new("Patient.MRNAlreadyExists", "A patient with this MRN already exists.");
public static readonly Error InactivePatient =
new("Patient.Inactive", "Cannot add a prescription to an inactive patient.");
public static readonly Error FutureDateOfBirth =
new("Patient.FutureDateOfBirth", "Date of birth cannot be in the future.");
public static readonly Error NameRequired =
new("Patient.NameRequired", "Patient name is required.");
}Using Result in Handlers
// Application/Patients/Commands/CreatePatient/CreatePatientCommandHandler.cs
public async Task<Result<PatientId>> Handle(CreatePatientCommand command, CancellationToken ct)
{
// Check → return failure without throwing
if (await _patients.ExistsByMRNAsync(command.MRN, ct))
return Result.Failure<PatientId>(PatientErrors.MRNAlreadyExists);
// Domain factory → may also fail
var patientResult = Patient.Create(command.Name, command.DateOfBirth, command.MRN);
if (patientResult.IsFailure)
return Result.Failure<PatientId>(patientResult.Error);
await _patients.AddAsync(patientResult.Value, ct);
await _unitOfWork.SaveChangesAsync(ct);
return Result.Success(patientResult.Value.Id);
}The Match Method — Mapping Result to HTTP
// Application/Common/ResultExtensions.cs
public static class ResultExtensions
{
public static TOut Match<TValue, TOut>(
this Result<TValue> result,
Func<TValue, TOut> onSuccess,
Func<Error, TOut> onFailure) =>
result.IsSuccess ? onSuccess(result.Value) : onFailure(result.Error);
public static TOut Match<TOut>(
this Result result,
Func<TOut> onSuccess,
Func<Error, TOut> onFailure) =>
result.IsSuccess ? onSuccess() : onFailure(result.Error);
}
// Api/Controllers/PatientsController.cs
[HttpPost]
public async Task<IActionResult> Create(CreatePatientRequest request, CancellationToken ct)
{
var command = new CreatePatientCommand(request.Name, request.DateOfBirth, request.MRN);
var result = await _create.Handle(command, ct);
return result.Match<IActionResult>(
id => CreatedAtAction(nameof(GetById), new { id = id.Value }, new { id = id.Value }),
err => Problem(detail: err.Description, statusCode: StatusCodes.Status409Conflict));
}Mapping Errors to HTTP Status Codes
// Api/Extensions/ResultMappingExtensions.cs
public static class ResultMappingExtensions
{
public static IActionResult ToActionResult(this Result result) =>
result.IsSuccess ? new NoContentResult() : result.Error.ToProblem();
public static IActionResult ToActionResult<TValue>(this Result<TValue> result) =>
result.IsSuccess ? new OkObjectResult(result.Value) : result.Error.ToProblem();
}
// Api/Extensions/ErrorExtensions.cs
public static class ErrorExtensions
{
public static ObjectResult ToProblem(this Error error)
{
var statusCode = error.Code switch
{
var c when c.EndsWith(".NotFound") => StatusCodes.Status404NotFound,
var c when c.EndsWith(".Conflict") => StatusCodes.Status409Conflict,
var c when c.StartsWith("Validation") => StatusCodes.Status400BadRequest,
_ => StatusCodes.Status500InternalServerError,
};
var problem = new ProblemDetails
{
Title = error.Code,
Detail = error.Description,
Status = statusCode,
};
return new ObjectResult(problem) { StatusCode = statusCode };
}
}Chaining Results
// When one step depends on the previous step succeeding
public async Task<Result<PrescriptionId>> Handle(
AddPrescriptionCommand command, CancellationToken ct)
{
// Step 1: load patient
var patient = await _patients.GetByIdAsync(command.PatientId, ct);
if (patient is null)
return Result.Failure<PrescriptionId>(PatientErrors.NotFound);
// Step 2: validate the dosage value object
var dosageResult = Dosage.Create(command.DosageAmount, command.DosageUnit);
if (dosageResult.IsFailure)
return Result.Failure<PrescriptionId>(dosageResult.Error);
// Step 3: validate medication code
var codeResult = MedicationCode.Create(command.MedicationCode);
if (codeResult.IsFailure)
return Result.Failure<PrescriptionId>(codeResult.Error);
// Step 4: apply domain rule (AddPrescription checks for duplicates, inactivity)
var prescription = Prescription.Create(codeResult.Value, dosageResult.Value, command.Frequency);
var addResult = patient.AddPrescription(prescription.Value);
if (addResult.IsFailure)
return Result.Failure<PrescriptionId>(addResult.Error);
await _unitOfWork.SaveChangesAsync(ct);
return Result.Success(prescription.Value.Id);
}Red Flag Answers in Interviews
Red flag: "I throw a custom exception from the handler and catch it in global middleware."
That means every business rule failure — MRN already exists, inactive patient, duplicate prescription — travels through the exception machinery, gets stack-traced, and lands in your error logs. In a busy clinical system, that is thousands of false incidents per day.
Green answer: "Business rule failures return a
Result<T>. Only genuinely unexpected failures — DB connection errors, null reference bugs — become exceptions. The controller usesMatchto map the result to the right HTTP status code."
PRO TIP
If you find yourself writing
try/catchinside a command handler to catch aDomainException, convert those domain exceptions to Result errors instead. Exceptions crossing layer boundaries are a code smell in Clean Architecture — they force callers to know about the exception types defined in the Domain layer, creating hidden coupling.
Key Takeaway
The Result pattern makes the failure surface of every method visible in its return type. A method returning
Result<PatientId>tells the caller: "this can fail, and here is the error when it does." A method returningPatientIdor throwingDuplicateMRNExceptionhides that from the caller. Explicit failure paths are easier to test, easier to map to HTTP responses, and impossible to accidentally swallow.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.