Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20266 min read
Clean Architecture.NETResult PatternError HandlingExceptions
Share:𝕏

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 DuplicatePatientException in 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

C#
// 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

C#
// 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

C#
// 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

C#
// 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

C#
// 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

C#
// 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 uses Match to map the result to the right HTTP status code."


PRO TIP

If you find yourself writing try/catch inside a command handler to catch a DomainException, 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 returning PatientId or throwing DuplicateMRNException hides that from the caller. Explicit failure paths are easier to test, easier to map to HTTP responses, and impossible to accidentally swallow.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.