mcpField
D
dotnet-result-pattern

dotnet-result-pattern

github.com/ronnythedev開発7763 回インストール15 週間前に更新
リポジトリ

dotnet-result-pattern について

.NET のドメイン層やアプリケーションコードで Result パターンを実装するスキルです。例外で業務エラーを表さず、成功・失敗の明示的な処理や操作の合成、検証を扱います。

主な機能

  1. 01値の有無に対応する Result 型
  2. 02コードと説明を持つ不変エラー
  3. 03Bind、Map、Match 操作
  4. 04複数エラーに対応する検証結果
  5. 05ドメイン層と API 境界の利用指針

活用シーン

  1. 01作成・取得操作の結果表現
  2. 02注文処理ステップの連結
  3. 03複数フィールドの検証と失敗返却
  4. 04コントローラーでのコマンド結果の変換

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/ronnythedev/dotnet-clean-architecture-skills --skill dotnet-result-pattern

インストール後、Claude Code で/dotnet-result-patternを入力して読み込まれたか確認します。

詳細

Result パターンの実装

概要

Result パターンは、例外を使わずに明示的なエラー処理を実現します。

  • ビジネスエラーには例外を使いません - 例外は本当に例外的なケースにのみ使用します
  • 成功/失敗を明示します - コンパイラーによって両方のケースを処理するよう強制されます
  • エラーを合成できます - 操作を連鎖させ、エラーが発生したら直ちに中断します
  • 自己文書化されています - メソッドシグネチャに、起こり得る結果が示されます

クイックリファレンス

型目的用途
Result戻り値のない操作Update、Delete 操作
Result<T>戻り値のある操作Create、Get 操作
Errorエラー情報コード + 説明

実装構造

/Domain/Abstractions/
├── Result.cs           # Result and Result<T>
├── Error.cs            # Error record
└── ValidationResult.cs # Multiple errors support

テンプレート:コア Result 型

// src/{name}.domain/Abstractions/Error.cs
namespace {name}.domain.abstractions;

/// <summary>
/// Represents an error with a code and description
/// </summary>
public record Error(string Code, string Description)
{
    /// <summary>
    /// Represents no error (success state)
    /// </summary>
    public static readonly Error None = new(string.Empty, string.Empty);

    /// <summary>
    /// Represents a null value error
    /// </summary>
    public static readonly Error NullValue = new(
        "Error.NullValue",
        "A null value was provided");

    /// <summary>
    /// Creates an error from an exception
    /// </summary>
    public static Error FromException(Exception exception) => new(
        "Error.Exception",
        exception.Message);

    /// <summary>
    /// Implicit conversion to string (returns Code)
    /// </summary>
    public static implicit operator string(Error error) => error.Code;

    public override string ToString() => Code;
}
// src/{name}.domain/Abstractions/Result.cs
namespace {name}.domain.abstractions;

/// <summary>
/// Represents the outcome of an operation that doesn't return a value
/// </summary>
public class Result
{
    protected Result(bool isSuccess, Error error)
    {
        if (isSuccess && error != Error.None)
        {
            throw new InvalidOperationException(
                "Cannot create successful result with an error");
        }

        if (!isSuccess && error == Error.None)
        {
            throw new InvalidOperationException(
                "Cannot create failed result without an error");
        }

        IsSuccess = isSuccess;
        Error = error;
    }

    public bool IsSuccess { get; }

    public bool IsFailure => !IsSuccess;

    public Error Error { get; }

    // ═══════════════════════════════════════════════════════════════
    // FACTORY METHODS
    // ═══════════════════════════════════════════════════════════════

    /// <summary>
    /// Creates a successful result
    /// </summary>
    public static Result Success() => new(true, Error.None);

    /// <summary>
    /// Creates a failed result with the specified error
    /// </summary>
    public static Result Failure(Error error) => new(false, error);

    /// <summary>
    /// Creates a successful result with a value
    /// </summary>
    public static Result<TValue> Success<TValue>(TValue value) =>
        new(value, true, Error.None);

    /// <summary>
    /// Creates a failed result with the specified error
    /// </summary>
    public static Result<TValue> Failure<TValue>(Error error) =>
        new(default, false, error);

    /// <summary>
    /// Creates a result based on a condition
    /// </summary>
    public static Result Create(bool condition, Error error) =>
        condition ? Success() : Failure(error);

    /// <summary>
    /// Creates a result based on a condition with a value
    /// </summary>
    public static Result<TValue> Create<TValue>(TValue? value, Error error) =>
        value is not null ? Success(value) : Failure<TValue>(error);
}

/// <summary>
/// Represents the outcome of an operation that returns a value
/// </summary>
public class Result<TValue> : Result
{
    private readonly TValue? _value;

    protected internal Result(TValue? value, bool isSuccess, Error error)
        : base(isSuccess, error)
    {
        _value = value;
    }

    /// <summary>
    /// Gets the value if successful, throws if failed
    /// </summary>
    public TValue Value => IsSuccess
        ? _value!
        : throw new InvalidOperationException(
            $"Cannot access value of a failed result. Error: {Error.Code}");

    /// <summary>
    /// Implicit conversion from value to successful Result
    /// </summary>
    public static implicit operator Result<TValue>(TValue? value) =>
        value is not null ? Success(value) : Failure<TValue>(Error.NullValue);

    /// <summary>
    /// Implicit conversion from Error to failed Result
    /// </summary>
    public static implicit operator Result<TValue>(Error error) =>
        Failure<TValue>(error);
}

テンプレート:Result 拡張機能(関数型操作)

// src/{name}.domain/Abstractions/ResultExtensions.cs
namespace {name}.domain.abstractions;

public static class ResultExtensions
{
    // ═══════════════════════════════════════════════════════════════
    // MAP: Transform success value
    // ═══════════════════════════════════════════════════════════════

    /// <summary>
    /// Transforms the value if successful, preserves error if failed
    /// </summary>
    public static Result<TOut> Map<TIn, TOut>(
        this Result<TIn> result,
        Func<TIn, TOut> mapper)
    {
        return result.IsSuccess
            ? Result.Success(mapper(result.Value))
            : Result.Failure<TOut>(result.Error);
    }

    /// <summary>
    /// Async version of Map
    /// </summary>
    public static async Task<Result<TOut>> Map<TIn, TOut>(
        this Task<Result<TIn>> resultTask,
        Func<TIn, TOut> mapper)
    {
        var result = await resultTask;
        return result.Map(mapper);
    }

    // ═══════════════════════════════════════════════════════════════
    // BIND: Chain operations that return Result
    // ═══════════════════════════════════════════════════════════════

    /// <summary>
    /// Chains another Result-returning operation if successful
    /// </summary>
    public static Result<TOut> Bind<TIn, TOut>(
        this Result<TIn> result,
        Func<TIn, Result<TOut>> binder)
    {
        return result.IsSuccess
            ? binder(result.Value)
            : Result.Failure<TOut>(result.Error);
    }

    /// <summary>
    /// Async version of Bind
    /// </summary>
    public static async Task<Result<TOut>> Bind<TIn, TOut>(
        this Result<TIn> result,
        Func<TIn, Task<Result<TOut>>> binder)
    {
        return result.IsSuccess
            ? await binder(result.Value)
            : Result.Failure<TOut>(result.Error);
    }

    /// <summary>
    /// Async version of Bind for Task results
    /// </summary>
    public static async Task<Result<TOut>> Bind<TIn, TOut>(
        this Task<Result<TIn>> resultTask,
        Func<TIn, Result<TOut>> binder)
    {
        var result = await resultTask;
        return result.Bind(binder);
    }

    /// <summary>
    /// Fully async Bind
    /// </summary>
    public static async Task<Result<TOut>> Bind<TIn, TOut>(
        this Task<Result<TIn>> resultTask,
        Func<TIn, Task<Result<TOut>>> binder)
    {
        var result = await resultTask;
        return await result.Bind(binder);
    }

    // ═══════════════════════════════════════════════════════════════
    // TAP: Execute side effect without changing result
    // ═══════════════════════════════════════════════════════════════

    /// <summary>
    /// Executes an action if successful, returns original result
    /// </summary>
    public static Result<T> Tap<T>(
        this Result<T> result,
        Action<T> action)
    {
        if (result.IsSuccess)
        {
            action(result.Value);
        }

        return result;
    }

    /// <summary>
    /// Async version of Tap
    /// </summary>
    public static async Task<Result<T>> Tap<T>(
        this Result<T> result,
        Func<T, Task> action)
    {
        if (result.IsSuccess)
        {
            await action(result.Value);
        }

        return result;
    }

    // ═══════════════════════════════════════════════════════════════
    // MATCH: Pattern match on result
    // ═══════════════════════════════════════════════════════════════

    /// <summary>
    /// Executes success or failure function based on result state
    /// </summary>
    public static TOut Match<TIn, TOut>(
        this Result<TIn> result,
        Func<TIn, TOut> onSuccess,
        Func<Error, TOut> onFailure)
    {
        return result.IsSuccess
            ? onSuccess(result.Value)
            : onFailure(result.Error);
    }

    /// <summary>
    /// Async version of Match
    /// </summary>
    public static async Task<TOut> Match<TIn, TOut>(
        this Task<Result<TIn>> resultTask,
        Func<TIn, TOut> onSuccess,
        Func<Error, TOut> onFailure)
    {
        var result = await resultTask;
        return result.Match(onSuccess, onFailure);
    }

    // ═══════════════════════════════════════════════════════════════
    // ENSURE: Add validation to existing result
    // ═══════════════════════════════════════════════════════════════

    /// <summary>
    /// Validates the value and fails if predicate returns false
    /// </summary>
    public static Result<T> Ensure<T>(
        this Result<T> result,
        Func<T, bool> predicate,
        Error error)
    {
        if (result.IsFailure)
        {
            return result;
        }

        return predicate(result.Value)
            ? result
            : Result.Failure<T>(error);
    }

    /// <summary>
    /// Async version of Ensure
    /// </summary>
    public static async Task<Result<T>> Ensure<T>(
        this Result<T> result,
        Func<T, Task<bool>> predicate,
        Error error)
    {
        if (result.IsFailure)
        {
            return result;
        }

        return await predicate(result.Value)
            ? result
            : Result.Failure<T>(error);
    }

    // ═══════════════════════════════════════════════════════════════
    // COMBINE: Combine multiple results
    // ═══════════════════════════════════════════════════════════════

    /// <summary>
    /// Combines multiple results, returning first failure or success
    /// </summary>
    public static Result Combine(params Result[] results)
    {
        foreach (var result in results)
        {
            if (result.IsFailure)
            {
                return result;
            }
        }

        return Result.Success();
    }

    /// <summary>
    /// Combines multiple results with values
    /// </summary>
    public static Result<(T1, T2)> Combine<T1, T2>(
        Result<T1> result1,
        Result<T2> result2)
    {
        if (result1.IsFailure) return Result.Failure<(T1, T2)>(result1.Error);
        if (result2.IsFailure) return Result.Failure<(T1, T2)>(result2.Error);

        return Result.Success((result1.Value, result2.Value));
    }

    /// <summary>
    /// Combines three results with values
    /// </summary>
    public static Result<(T1, T2, T3)> Combine<T1, T2, T3>(
        Result<T1> result1,
        Result<T2> result2,
        Result<T3> result3)
    {
        if (result1.IsFailure) return Result.Failure<(T1, T2, T3)>(result1.Error);
        if (result2.IsFailure) return Result.Failure<(T1, T2, T3)>(result2.Error);
        if (result3.IsFailure) return Result.Failure<(T1, T2, T3)>(result3.Error);

        return Result.Success((result1.Value, result2.Value, result3.Value));
    }

    // ═══════════════════════════════════════════════════════════════
    // GET VALUE OR DEFAULT
    // ═══════════════════════════════════════════════════════════════

    /// <summary>
    /// Returns the value if successful, or default value if failed
    /// </summary>
    public static T GetValueOrDefault<T>(
        this Result<T> result,
        T defaultValue = default!)
    {
        return result.IsSuccess ? result.Value : defaultValue;
    }

    /// <summary>
    /// Returns the value if successful, or result of factory if failed
    /// </summary>
    public static T GetValueOrDefault<T>(
        this Result<T> result,
        Func<T> defaultFactory)
    {
        return result.IsSuccess ? result.Value : defaultFactory();
    }
}

テンプレート:検証結果(複数のエラー)

// src/{name}.domain/Abstractions/ValidationResult.cs
namespace {name}.domain.abstractions;

/// <summary>
/// Result that can contain multiple validation errors
/// </summary>
public sealed class ValidationResult : Result, IValidationResult
{
    private ValidationResult(Error[] errors)
        : base(false, IValidationResult.ValidationError)
    {
        Errors = errors;
    }

    public Error[] Errors { get; }

    public static ValidationResult WithErrors(Error[] errors) => new(errors);
}

/// <summary>
/// Validation result with a value
/// </summary>
public sealed class ValidationResult<TValue> : Result<TValue>, IValidationResult
{
    private ValidationResult(Error[] errors)
        : base(default, false, IValidationResult.ValidationError)
    {
        Errors = errors;
    }

    public Error[] Errors { get; }

    public static ValidationResult<TValue> WithErrors(Error[] errors) => new(errors);
}

/// <summary>
/// Marker interface for validation results
/// </summary>
public interface IValidationResult
{
    public static readonly Error ValidationError = new(
        "Validation.Error",
        "One or more validation errors occurred");

    Error[] Errors { get; }
}

使用例

ドメインエンティティでの基本的な使い方
public sealed class User : Entity
{
    public static Result<User> Create(string email, string name)
    {
        // Validate email
        var emailResult = Email.Create(email);
        if (emailResult.IsFailure)
        {
            return Result.Failure<User>(emailResult.Error);
        }

        // Validate name
        if (string.IsNullOrWhiteSpace(name))
        {
            return Result.Failure<User>(UserErrors.NameRequired);
        }

        if (name.Length > 100)
        {
            return Result.Failure<User>(UserErrors.NameTooLong);
        }

        var user = new User(Guid.NewGuid(), emailResult.Value, name);

        return Result.Success(user);
    }

    public Result UpdateName(string name)
    {
        if (string.IsNullOrWhiteSpace(name))
        {
            return Result.Failure(UserErrors.NameRequired);
        }

        Name = name;
        return Result.Success();
    }
}
コマンドハンドラーでの使い方
internal sealed class CreateUserCommandHandler
    : ICommandHandler<CreateUserCommand, Guid>
{
    private readonly IUserRepository _userRepository;
    private readonly IUnitOfWork _unitOfWork;

    public async Task<Result<Guid>> Handle(
        CreateUserCommand request,
        CancellationToken cancellationToken)
    {
        // Check if user exists
        var existingUser = await _userRepository
            .GetByEmailAsync(request.Email, cancellationToken);

        if (existingUser is not null)
        {
            return Result.Failure<Guid>(UserErrors.EmailAlreadyExists);
        }

        // Create user using factory method
        var userResult = User.Create(request.Email, request.Name);

        if (userResult.IsFailure)
        {
            return Result.Failure<Guid>(userResult.Error);
        }

        _userRepository.Add(userResult.Value);

        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return userResult.Value.Id;
    }
}
Bind による連鎖
public async Task<Result<OrderConfirmation>> PlaceOrder(
    Guid userId,
    CreateOrderRequest request,
    CancellationToken ct)
{
    return await GetUser(userId, ct)
        .Bind(user => ValidateUserCanOrder(user))
        .Bind(user => CreateOrder(user, request))
        .Bind(order => ProcessPayment(order, ct))
        .Bind(order => SendConfirmation(order, ct));
}
コントローラーでの Match の使用
[HttpPost]
public async Task<IActionResult> Create(
    [FromBody] CreateUserRequest request,
    CancellationToken ct)
{
    var command = new CreateUserCommand(request.Email, request.Name);

    var result = await _sender.Send(command, ct);

    return result.Match(
        onSuccess: id => CreatedAtAction(nameof(GetById), new { id }, id),
        onFailure: error => error.Code switch
        {
            "User.EmailExists" => Conflict(error),
            "User.NotFound" => NotFound(error),
            _ => BadRequest(error)
        });
}
複数の結果の結合
public Result<Order> CreateOrder(
    CreateOrderRequest request)
{
    // Validate all fields
    var customerResult = CustomerId.Create(request.CustomerId);
    var addressResult = Address.Create(request.Street, request.City);
    var amountResult = Money.Create(request.Amount);

    // Combine - returns first failure
    var combinedResult = ResultExtensions.Combine(
        customerResult,
        addressResult,
        amountResult);

    if (combinedResult.IsFailure)
    {
        return Result.Failure<Order>(combinedResult.Error);
    }

    var (customerId, address, amount) = combinedResult.Value;

    return Order.Create(customerId, address, amount);
}

ドメインエラーパターン

// src/{name}.domain/Users/UserErrors.cs
namespace {name}.domain.users;

public static class UserErrors
{
    public static readonly Error NotFound = new(
        "User.NotFound",
        "The user with the specified ID was not found");

    public static readonly Error EmailAlreadyExists = new(
        "User.EmailExists",
        "A user with this email already exists");

    public static readonly Error NameRequired = new(
        "User.NameRequired",
        "User name is required");

    public static readonly Error NameTooLong = new(
        "User.NameTooLong",
        "User name cannot exceed 100 characters");

    public static readonly Error InvalidCredentials = new(
        "User.InvalidCredentials",
        "The provided credentials are invalid");

    public static readonly Error AccountLocked = new(
        "User.AccountLocked",
        "The user account is locked");

    // Parameterized errors
    public static Error NotFoundById(Guid id) => new(
        "User.NotFound",
        $"The user with ID '{id}' was not found");

    public static Error Unauthorized(string resource) => new(
        "User.Unauthorized",
        $"User is not authorized to access '{resource}'");
}

重要なルール

  1. ビジネスエラーに対して例外をスローしない - Result.Failure を返します
  2. IsSuccess/IsFailure を必ず確認する - Value にアクセスする前に確認します
  3. ファクトリーメソッドを使用する - Result.Success()、Result.Failure()
  4. エラーは不変です - record Error(...)
  5. エラーコードは一意です - {Entity}.{ErrorType} のパターンに従います
  6. Bind で連鎖させる - 順次実行する操作に使用します
  7. コントローラーでは Match を使用する - レスポンスのマッピングを簡潔にします
  8. 値オブジェクトは Create で検証する - ファクトリーから Result を返します
  9. 複数の検証を結合する - 最初の失敗を返します
  10. Result はドメイン内にとどめる - API レイヤーに直接漏らしません

避けるべきアンチパターン

// ❌ WRONG: Throwing for business errors
if (user is null)
    throw new NotFoundException("User not found");

// ✅ CORRECT: Return Result
if (user is null)
    return Result.Failure<User>(UserErrors.NotFound);

// ❌ WRONG: Accessing Value without checking
var user = result.Value;  // Throws if failed!

// ✅ CORRECT: Check first
if (result.IsFailure)
    return Result.Failure(result.Error);
var user = result.Value;

// ❌ WRONG: Ignoring Result
await CreateUserAsync(request);  // Ignores possible failure

// ✅ CORRECT: Handle the result
var result = await CreateUserAsync(request);
if (result.IsFailure)
    // Handle error

// ❌ WRONG: Using exceptions as control flow
try { return Success(Process()); }
catch (ValidationException ex) { return Failure(ex.Error); }

// ✅ CORRECT: Design to return Result
var validationResult = Validate(input);
if (validationResult.IsFailure)
    return validationResult;
return Success(Process(input));

関連スキル

  • dotnet-domain-entity-generator - ファクトリーメソッドで Result を使用します
  • dotnet-cqrs-command-generator - コマンドは Result を返します
  • dotnet-cqrs-query-generator - クエリは Result を返します
  • dotnet-pipeline-behaviors - 検証ビヘイビアーは Result を使用します

コメント