agentleFS
zdanovichnickzdanovichnick/dotnet-pilot/skills/error-handling/SKILL.md

error-handling

Skill5 starsChanged 6 days agoMITView on GitHub

The canonical Result<TValue, TError> type, typed error records, ProblemDetails (RFC 9457), and global exception boundaries for .NET APIs.

80 other projects use a version of this.

Adopted by

Most recently taken up by

Used with

Projects with it also use dependency-injection, type-safety and 3 more.

Other files people use alongside this one.

Files found in the same public projects as a version of this one, more often than they turn up anywhere else. Each project counts once, large collections count for less, and this file's own repository is left out.
  • HamzaSheikh768Skill

    dependency-injection

    Leverages FastAPI's powerful Depends system for reusable authentication, database sessions, rate limiting, and business logic injection.

    Also in 5 projects that use this

  • MANVENDRA-githubCursor rule

    type-safety

    Python static typing discipline — annotate every signature, run a strict type checker, treat escape hatches as code smells. Apply when working in any Python file. Skip for throwaway scripts and notebooks where the code will not outlive the session.

    Also in 3 projects that use this

  • junhjangSkill

    configuration

    Adding a new config file, adding a field, or wiring a config consumer. Enforces schema validation at startup (no permissive defaults), env-var indirection for secrets, layered config (defaults / per-env / operator-override), hot-reload explicit per field.

    Also in 4 projects that use this

  • dbtlrSkill

    dependencies

    Use when adding, evaluating, or updating Rust crate dependencies. Also use when considering whether to implement something yourself vs pulling in a library.

    Also in 4 projects that use this

  • OpenAISkill

    auth

    Authentication integration guidance — Clerk (native Vercel Marketplace), Descope, and Auth0 setup for Next.js applications. Covers middleware auth patterns, sign-in/sign-up flows, and Marketplace provisioning. Use when implementing user authentication.

    Also in 5 projects that use this

The file

We split this skill into the rules it gives an agent

3 rules. 0 are common, 1 is distinctive, and 2 are very specific to this project.

  • 0

    Common

    Most of the 64 edited versions we read keep these.

  • 1

    Distinctive

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Never throw exceptions for expected domain outcomes.

    line 21
  2. Very specific:

    Leave type unset. AddProblemDetails() fills type and title with the RFC 9110 section URI for the status code (e.g.

    line 139
  3. Very specific:

    Never put exception.Message or the stack trace in detail, because it reaches the client.

    line 201

“Common” is measured: kept by at least 6 in 10 of the 64 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Error Handling Patterns

This skill owns the Result<TValue, TError> definition. ddd, clean-architecture and vertical-slice use it and do not redefine it.

Philosophy: When to Use Results vs Exceptions

ScenarioApproach
Domain rule violation (not found, invalid state, business constraint)Result<TValue, TError> — expected failure path
Infrastructure failure (DB timeout, network error, config missing)Exception — unexpected, unrecoverable at call site
Validation failure (bad input from HTTP layer)ValidationProblem via model binding / FluentValidation
Unhandled exception escaping to HTTPGlobalExceptionHandler → 500 ProblemDetails

Never throw exceptions for expected domain outcomes. Never swallow exceptions at call sites.

Result Type

Define once in the Domain project (e.g. Domain/Common/Result.cs). It has no dependencies, so every layer can reference it:

namespace MyApp.Domain.Common;

public readonly record struct Result<TValue, TError>
{
    private readonly TValue? _value;
    private readonly TError? _error;

    public bool IsSuccess { get; }
    public bool IsFailure => !IsSuccess;

    public TValue Value => IsSuccess ? _value! : throw new InvalidOperationException("Result is a failure; read Error.");
    public TError Error => IsFailure ? _error! : throw new InvalidOperationException("Result is a success; read Value.");

    private Result(TValue value) { _value = value; IsSuccess = true; }
    private Result(TError error) { _error = error; IsSuccess = false; }

    public static Result<TValue, TError> Success(TValue value) => new(value);
    public static Result<TValue, TError> Failure(TError error) => new(error);

    public static implicit operator Result<TValue, TError>(TValue value) => Success(value);
    public static implicit operator Result<TValue, TError>(TError error) => Failure(error);

    public TResult Match<TResult>(Func<TValue, TResult> onSuccess, Func<TError, TResult> onFailure)
        => IsSuccess ? onSuccess(_value!) : onFailure(_error!);
}

// Success marker for operations that return no value: Result<Done, TError>.
public readonly record struct Done
{
    public static readonly Done Value = default;
}

Gotchas:

  • default(Result<,>), including an uninitialised field of this type, is a failure with a null Error. Always construct one through Success/Failure or the implicit conversions.
  • The implicit conversions let a method return order; or return new NotFoundError(...). They become ambiguous when TValue and TError are the same type or related by inheritance, and C# applies no user-defined conversion from an interface-typed value. Call Success/Failure explicitly in those cases.
  • The no-value form is named Done, not Unit (clashes with MediatR.Unit) or Ok (clashes with Microsoft.AspNetCore.Http.HttpResults.Ok).
  • A discarded Result compiles silently. Treat one as a bug in review.

Typed Error Records

namespace MyApp.Domain.Errors;

public abstract record DomainError(string Message);

public sealed record NotFoundError(string Resource, object Id)
    : DomainError($"{Resource} with id '{Id}' was not found.");

public sealed record ConflictError(string Resource, string Reason)
    : DomainError($"{Resource} conflict: {Reason}");

public sealed record ValidationError(string Field, string Reason)
    : DomainError($"Validation failed for '{Field}': {Reason}");

public sealed record ForbiddenError(string Action, string Resource)
    : DomainError($"Not permitted to {Action} {Resource}.");

Returning Results

The aggregate owns the business rule and returns a result. The service propagates it rather than re-checking state:

public class OrderService(IOrderRepository repo, TimeProvider clock)
{
    public async Task<Result<Order, DomainError>> CancelAsync(Guid id, CancellationToken ct)
    {
        var order = await repo.GetByIdAsync(id, ct);
        if (order is null)
            return new NotFoundError("Order", id);

        var cancelled = order.Cancel(clock);     // Result<Done, DomainError>
        if (cancelled.IsFailure)
            return cancelled.Error;

        await repo.SaveChangesAsync(ct);
        return order;
    }
}

Mapping Results to HTTP (Minimal API)

app.MapPost("/orders/{id:guid}/cancel", async (Guid id, OrderService svc, CancellationToken ct) =>
{
    var result = await svc.CancelAsync(id, ct);
    return result.Match<IResult>(order => TypedResults.Ok(order), MapError);
});

static IResult MapError(DomainError error) => error switch
{
    NotFoundError e   => TypedResults.Problem(e.Message, statusCode: StatusCodes.Status404NotFound),
    ConflictError e   => TypedResults.Problem(e.Message, statusCode: StatusCodes.Status409Conflict),
    ForbiddenError e  => TypedResults.Problem(e.Message, statusCode: StatusCodes.Status403Forbidden),
    ValidationError e => TypedResults.ValidationProblem(
        new Dictionary<string, string[]> { [e.Field] = [e.Reason] }),
    _                 => TypedResults.Problem(error.Message, statusCode: StatusCodes.Status500InternalServerError)
};
  • A domain ForbiddenError maps to a 403 problem, not TypedResults.Forbid(). Forbid() runs the authentication handler's forbid flow, which for cookie auth redirects to an access-denied page.
  • Leave type unset. AddProblemDetails() fills type and title with the RFC 9110 section URI for the status code (e.g. 404 → https://tools.ietf.org/html/rfc9110#section-15.5.5).
  • Returning IResult loses OpenAPI response metadata. Declare .ProducesProblem(404) and so on for the endpoint, or return a Results<Ok<T>, ProblemHttpResult, ...> union.

Controller mapping: references/controllers-and-jobs.md.

ProblemDetails (RFC 9457)

RFC 9457 obsoletes RFC 7807. The JSON shape is unchanged.

builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Instance =
            $"{ctx.HttpContext.Request.Method} {ctx.HttpContext.Request.Path}";
        ctx.ProblemDetails.Extensions["traceId"] =
            Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
    };
});
FieldDescriptionExample
statusHTTP status code404
titleShort human-readable summary"Not Found"
typeURI identifying the problem type"https://tools.ietf.org/html/rfc9110#section-15.5.5"
detailSpecific explanation for this occurrence"Order 42 was not found."
instanceReference to this occurrence"GET /orders/42"

Global Exception Handler

Catches unhandled exceptions at the HTTP boundary. Writing through IProblemDetailsService keeps the CustomizeProblemDetails callback (traceId, instance) in the response:

public sealed class GlobalExceptionHandler(
    IProblemDetailsService problemDetails,
    ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception exception, CancellationToken ct)
    {
        logger.LogError(exception, "Unhandled exception on {Method} {Path}", ctx.Request.Method, ctx.Request.Path);

        ctx.Response.StatusCode = StatusCodes.Status500InternalServerError;
        return await problemDetails.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = ctx,
            Exception = exception,
            ProblemDetails = { Title = "An unexpected error occurred." }
        });
    }
}

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

app.UseExceptionHandler();   // first in the pipeline, before routing

Never put exception.Message or the stack trace in detail, because it reaches the client.

Background-job exception boundaries: references/controllers-and-jobs.md.

Do / Don't

DoDon't
Return Result<TValue, TError> for domain failuresThrow DomainException for expected failure paths
Define typed error records per domain conceptUse stringly-typed error messages
Catch exceptions only at HTTP/job boundariesCatch Exception at the service or repository layer
Log at the boundary with full exceptionLog the same exception multiple times as it propagates
Use TypedResults (compile-time checked) over ResultsMix IActionResult and IResult in the same endpoint
Include traceId in ProblemDetails extensionsExpose stack traces or internal exception messages to clients

See Also

  • skills/modern-csharp/SKILL.md — record and pattern-matching gotchas used in error types
  • skills/aspnet-api-patterns/SKILL.md — endpoint conventions that consume these patterns

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.