mcpField
D
dotnet-architecture-patterns

dotnet-architecture-patterns

github.com/wshaddix開発86112 回インストール35 週間前に更新
リポジトリ

dotnet-architecture-patterns について

.NET 開発者向けに、アプリケーション設計のパターンをまとめたスキルです。垂直スライスや Minimal API の構成、検証、キャッシュ、冪等性やトランザクショナルアウトボックスを扱います。

主な機能

  1. 01垂直スライスによる構成
  2. 02大規模な Minimal API の整理
  3. 03リクエストパイプラインとエンドポイントフィルター
  4. 04検証と Problem Details によるエラー処理
  5. 05出力・分散・ハイブリッドキャッシュ
  6. 06冪等性キーとトランザクショナルアウトボックス

活用シーン

  1. 01大規模な .NET アプリを機能単位で整理
  2. 02Minimal API のリクエスト処理を構成
  3. 03再試行される書き込みの重複処理を防止
  4. 04データベース更新後にドメインイベントを確実に発行

インストール

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

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-architecture-patterns

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

詳細

dotnet-architecture-patterns

.NET アプリケーション向けの最新アーキテクチャパターンです。大規模な Minimal API の実践的な構成方法、垂直スライスアーキテクチャ、リクエストパイプラインの構成、検証戦略、キャッシュ、エラー処理、冪等性/アウトボックスパターンを扱います。

対象外: DI コンテナーの仕組みと async/await パターンについては、[skill:dotnet-csharp-dependency-injection] と [skill:dotnet-csharp-async-patterns] を参照してください。プロジェクトのスキャフォールディングとファイル構成については、[skill:dotnet-scaffold-project] を参照してください。テスト戦略については、判断の指針は [skill:dotnet-testing-strategy] を、WebApplicationFactory パターンは [skill:dotnet-integration-testing] を参照してください。

相互参照:[skill:dotnet-csharp-dependency-injection] ではサービスの登録とライフタイム、[skill:dotnet-csharp-async-patterns] では非同期パイプラインパターン、[skill:dotnet-csharp-configuration] では構成における Options パターン、[skill:dotnet-solid-principles] ではクラスとインターフェースの設計を支える SOLID/DRY 設計原則を扱っています。


垂直スライスアーキテクチャ

技術レイヤー(コントローラー、サービス、リポジトリ)ではなく、機能(垂直スライス)ごとにコードを構成します。各スライスは、エンドポイント、ハンドラー、検証、データアクセスを所有します。

ディレクトリ構成
Features/
  Orders/
    CreateOrder/
      CreateOrderEndpoint.cs
      CreateOrderHandler.cs
      CreateOrderRequest.cs
      CreateOrderValidator.cs
    GetOrder/
      GetOrderEndpoint.cs
      GetOrderHandler.cs
    ListOrders/
      ListOrdersEndpoint.cs
      ListOrdersHandler.cs
  Products/
    GetProduct/
      ...
垂直スライスの利点
  • 疎結合:ある機能を変更しても、共有レイヤー全体に影響が波及しません
  • 見つけやすさ:機能に関するものがすべて一か所にまとまっています
  • 独立したテストのしやすさ:各スライスには明確な入力/出力の契約があります
  • チームの拡張性:マージコンフリクトを避けながら、複数の開発者が異なる機能に取り組めます
スライスの構成

各スライスには通常、次のものが含まれます。

  1. リクエスト/レスポンス DTO -- 契約
  2. バリデーター -- 入力検証ルール
  3. ハンドラー -- ビジネスロジック
  4. エンドポイント -- HTTP マッピング(ルート、メソッド、ステータスコード)
// Features/Orders/CreateOrder/CreateOrderRequest.cs
public sealed record CreateOrderRequest(
    string CustomerId,
    List<OrderLineRequest> Lines);

public sealed record OrderLineRequest(
    string ProductId,
    int Quantity);

// Features/Orders/CreateOrder/CreateOrderResponse.cs
public sealed record CreateOrderResponse(
    string OrderId,
    decimal Total,
    DateTimeOffset CreatedAt);

大規模な Minimal API の構成

ルートグループパターン

MapGroup を使って関連するエンドポイントをまとめ、共通のフィルターを適用します。

// Program.cs
var app = builder.Build();

app.MapGroup("/api/orders")
   .WithTags("Orders")
   .MapOrderEndpoints();

app.MapGroup("/api/products")
   .WithTags("Products")
   .MapProductEndpoints();

app.Run();
// Features/Orders/OrderEndpoints.cs
public static class OrderEndpoints
{
    public static RouteGroupBuilder MapOrderEndpoints(this RouteGroupBuilder group)
    {
        group.MapPost("/", CreateOrderEndpoint.Handle)
             .WithName("CreateOrder")
             .Produces<CreateOrderResponse>(StatusCodes.Status201Created)
             .ProducesValidationProblem();

        group.MapGet("/{id}", GetOrderEndpoint.Handle)
             .WithName("GetOrder")
             .Produces<OrderResponse>()
             .ProducesProblem(StatusCodes.Status404NotFound);

        group.MapGet("/", ListOrdersEndpoint.Handle)
             .WithName("ListOrders")
             .Produces<PagedResult<OrderSummary>>();

        return group;
    }
}
エンドポイントクラス

各エンドポイントを、単一の Handle メソッドを持つ独自の静的クラスにします。

public static class CreateOrderEndpoint
{
    public static async Task<IResult> Handle(
        CreateOrderRequest request,
        IValidator<CreateOrderRequest> validator,
        IOrderService orderService,
        CancellationToken ct)
    {
        var validation = await validator.ValidateAsync(request, ct);
        if (!validation.IsValid)
        {
            return Results.ValidationProblem(validation.ToDictionary());
        }

        var order = await orderService.CreateAsync(request, ct);

        return Results.Created($"/api/orders/{order.OrderId}", order);
    }
}

リクエストパイプラインの構成

エンドポイントフィルター(エンドポイント向けミドルウェア)

特定のルートに適用範囲を限定した横断的関心事には、エンドポイントフィルターを使用します。

// Validation filter applied to a route group
public sealed class ValidationFilter<TRequest> : IEndpointFilter
    where TRequest : class
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var request = context.Arguments.OfType<TRequest>().FirstOrDefault();
        if (request is null)
        {
            return Results.BadRequest();
        }

        var validator = context.HttpContext.RequestServices
            .GetService<IValidator<TRequest>>();

        if (validator is not null)
        {
            var result = await validator.ValidateAsync(request);
            if (!result.IsValid)
            {
                return Results.ValidationProblem(result.ToDictionary());
            }
        }

        return await next(context);
    }
}

// Usage
group.MapPost("/", CreateOrderEndpoint.Handle)
     .AddEndpointFilter<ValidationFilter<CreateOrderRequest>>();
パイプラインの順序

標準的なミドルウェアパイプラインでは、順序が重要です。

app.UseExceptionHandler();       // 1. Global error handling
app.UseStatusCodePages();        // 2. Status code formatting
app.UseRateLimiter();            // 3. Rate limiting
app.UseAuthentication();         // 4. Authentication
app.UseAuthorization();          // 5. Authorization
// Endpoint routing happens here

エラー処理

Problem Details(RFC 9457)

一貫性のあるエラーレスポンスには、組み込みの Problem Details サポートを使用します。

// Program.cs
builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = context =>
    {
        context.ProblemDetails.Extensions["traceId"] =
            context.HttpContext.TraceIdentifier;
    };
});

app.UseExceptionHandler();
app.UseStatusCodePages();
ビジネスロジック向け Result パターン

想定されるビジネス上の失敗については、例外をスローする代わりに、ハンドラーから結果型を返します。

public abstract record Result<T>
{
    public sealed record Success(T Value) : Result<T>;
    public sealed record NotFound(string Message) : Result<T>;
    public sealed record ValidationFailed(IDictionary<string, string[]> Errors) : Result<T>;
    public sealed record Conflict(string Message) : Result<T>;
}

// In the handler
public async Task<Result<Order>> CreateAsync(
    CreateOrderRequest request,
    CancellationToken ct)
{
    var customer = await _db.Customers.FindAsync([request.CustomerId], ct);
    if (customer is null)
    {
        return new Result<Order>.NotFound($"Customer {request.CustomerId} not found");
    }

    // ... create order
    return new Result<Order>.Success(order);
}

// In the endpoint -- map result to HTTP response
return result switch
{
    Result<Order>.Success s => Results.Created($"/api/orders/{s.Value.Id}", s.Value),
    Result<Order>.NotFound n => Results.Problem(n.Message, statusCode: 404),
    Result<Order>.ValidationFailed v => Results.ValidationProblem(v.Errors),
    Result<Order>.Conflict c => Results.Problem(c.Message, statusCode: 409),
    _ => Results.Problem("Unexpected error", statusCode: 500)
};

検証戦略

複雑さに応じて検証方法を選択します。既定では組み込みの仕組みを優先し、宣言型属性では対応しきれない複雑なビジネスルールには FluentValidation を使用します。フレームワークの詳しいガイダンスについては、[skill:dotnet-input-validation] を参照してください。アーキテクチャにおける検証の責務を定める SOLID 原則については、[skill:dotnet-solid-principles] を参照してください。

組み込み:Data Annotations + MiniValidation(既定)

単純なプロパティレベルの制約には、まず Data Annotations を使用します。MVC モデルバインディングのオーバーヘッドなしで Minimal API の軽量な検証を行うには、MiniValidation を使用します。.NET 10 以降のプロジェクトでは、組み込みの AddValidation() ソースジェネレーターパイプラインを優先してください([skill:dotnet-input-validation] を参照)。

public sealed record CreateProductRequest(
    [Required, MaxLength(200)] string Name,
    [Range(0.01, double.MaxValue)] decimal Price);

// In endpoint
if (!MiniValidator.TryValidate(request, out var errors))
{
    return Results.ValidationProblem(errors);
}
FluentValidation(複雑なルール向けのオプション)

検証ルールがアノテーションでは対応しきれない場合(プロパティ間のルール、条件付きロジック、データベースに依存するチェックなど)は、FluentValidation を使用します。アセンブリスキャンで登録し、エンドポイントフィルターまたは手動検証を通じて適用します。

// Register validators by assembly scanning
builder.Services.AddValidatorsFromAssemblyContaining<Program>(ServiceLifetime.Scoped);

// Validator implementation
public sealed class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderValidator()
    {
        RuleFor(x => x.CustomerId)
            .NotEmpty()
            .MaximumLength(50);

        RuleFor(x => x.Lines)
            .NotEmpty()
            .WithMessage("Order must have at least one line item");

        RuleForEach(x => x.Lines)
            .ChildRules(line =>
            {
                line.RuleFor(l => l.ProductId).NotEmpty();
                line.RuleFor(l => l.Quantity).GreaterThan(0);
            });
    }
}

キャッシュ戦略

出力キャッシュ(HTTP レスポンスキャッシュ)
builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(p => p.NoCache());

    options.AddPolicy("ProductList", p =>
        p.Expire(TimeSpan.FromMinutes(5))
         .Tag("products"));

    options.AddPolicy("ProductDetail", p =>
        p.Expire(TimeSpan.FromMinutes(10))
         .SetVaryByRouteValue("id")
         .Tag("products"));
});

app.UseOutputCache();

// Apply to endpoints
group.MapGet("/", ListProductsEndpoint.Handle)
     .CacheOutput("ProductList");

group.MapGet("/{id}", GetProductEndpoint.Handle)
     .CacheOutput("ProductDetail");

// Invalidate by tag
app.MapPost("/api/products", async (
    IOutputCacheStore cache,
    /* ... */) =>
{
    // ... create product
    await cache.EvictByTagAsync("products", ct);
    return Results.Created(/* ... */);
});
分散キャッシュ(アプリケーションレベル)
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration
        .GetConnectionString("Redis");
});

// Usage with IDistributedCache
public sealed class ProductService(
    IDistributedCache cache,
    AppDbContext db)
{
    public async Task<Product?> GetByIdAsync(
        string id, CancellationToken ct = default)
    {
        var cacheKey = $"product:{id}";
        var cached = await cache.GetStringAsync(cacheKey, ct);

        if (cached is not null)
        {
            return JsonSerializer.Deserialize<Product>(cached);
        }

        var product = await db.Products.FindAsync([id], ct);
        if (product is not null)
        {
            await cache.SetStringAsync(
                cacheKey,
                JsonSerializer.Serialize(product),
                new DistributedCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
                },
                ct);
        }

        return product;
    }
}
HybridCache(.NET 9 以降)

HybridCache は、スタンピード保護機能を備えた L1(メモリ内)キャッシュと L2(分散)キャッシュを組み合わせます。

builder.Services.AddHybridCache(options =>
{
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(10),
        LocalCacheExpiration = TimeSpan.FromMinutes(2)
    };
});

// Usage -- stampede-safe, two-tier
public sealed class ProductService(HybridCache cache, AppDbContext db)
{
    public async Task<Product?> GetByIdAsync(
        string id, CancellationToken ct = default)
    {
        return await cache.GetOrCreateAsync(
            $"product:{id}",
            async cancel => await db.Products.FindAsync([id], cancel),
            cancellationToken: ct);
    }
}

冪等性とアウトボックスパターン

冪等性キー

再試行されたリクエストの重複処理を防ぎます。堅牢な冪等性の実装には、次の要件があります。

  1. エンドポイント間の衝突を防ぐため、ルートとユーザー/テナントごとにキーをスコープ設定する
  2. 実行前にキーをアトミックに確保し、同時に発生した重複リクエストを拒否する
  3. 安全な再送のため、(IResult の参照ではなく)具体的なレスポンスエンベロープを保存する
データベースを使用した冪等性(推奨)

一意制約のあるデータベース行を使用して、アトミックな確保後の実行を実現します。

// Idempotency record stored alongside domain data
public sealed class IdempotencyRecord
{
    public required string Key { get; init; }         // Scoped key
    public required string RequestRoute { get; init; }
    public required string? UserId { get; init; }
    public int StatusCode { get; set; }
    public string? ResponseBody { get; set; }         // Serialized JSON
    public string? ContentType { get; set; }
    public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
    public bool IsCompleted { get; set; }
}

public sealed class IdempotencyFilter(AppDbContext db) : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var httpContext = context.HttpContext;
        if (!httpContext.Request.Headers.TryGetValue(
            "Idempotency-Key", out var keyValues))
        {
            return await next(context);
        }

        var clientKey = keyValues.ToString();
        if (string.IsNullOrWhiteSpace(clientKey) || clientKey.Length > 256)
        {
            return Results.Problem("Invalid Idempotency-Key", statusCode: 400);
        }

        // Scope key by route + user to prevent cross-endpoint/cross-tenant collisions
        var route = $"{httpContext.Request.Method}:{httpContext.Request.Path}";
        var userId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous";
        var scopedKey = $"{route}:{userId}:{clientKey}";

        // Check for existing record (completed = replay, in-progress = reject)
        var existing = await db.IdempotencyRecords
            .FirstOrDefaultAsync(r => r.Key == scopedKey);

        if (existing is { IsCompleted: true })
        {
            // Replay: value responses get body, non-value responses get status only
            if (existing.ResponseBody is not null)
            {
                return Results.Text(
                    existing.ResponseBody,
                    existing.ContentType ?? "application/json",
                    statusCode: existing.StatusCode);
            }

            return Results.StatusCode(existing.StatusCode);
        }

        if (existing is { IsCompleted: false })
        {
            // Another request claimed this key but hasn't completed yet.
            // Reject to prevent duplicate execution.
            return Results.Problem(
                "Duplicate request in progress", statusCode: 409);
        }

        // Atomic claim: insert with unique constraint -- concurrent duplicate
        // requests will throw DbUpdateException and get a 409 Conflict
        {
            var record = new IdempotencyRecord
            {
                Key = scopedKey,
                RequestRoute = route,
                UserId = userId,
                IsCompleted = false
            };
            db.IdempotencyRecords.Add(record);

            try
            {
                await db.SaveChangesAsync();
            }
            catch (DbUpdateException)
            {
                return Results.Problem(
                    "Duplicate request in progress", statusCode: 409);
            }

            existing = record;
        }

        // Execute the actual handler
        var result = await next(context);

        // Always finalize the record -- handles both value and non-value results
        // (Results.Ok(obj), Results.NoContent(), Results.Accepted(), etc.)
        existing.StatusCode = result is IStatusCodeHttpResult statusResult
            ? statusResult.StatusCode ?? 200
            : 200;
        existing.ResponseBody = result is IValueHttpResult valueResult
            ? JsonSerializer.Serialize(valueResult.Value)
            : null;  // No body for non-value results (204, 202, etc.)
        existing.ContentType = existing.ResponseBody is not null
            ? "application/json"
            : null;
        existing.IsCompleted = true;
        await db.SaveChangesAsync();

        return result;
    }
}

主な設計上の選択肢:

  • 3つの状態:レコードなし(確保する)、処理中(409を返す)、完了(キャッシュ済みレスポンスを再送する)
  • Key 列の一意制約により、分散ロックなしでアトミックに確保できます
  • スコープ付きキー(route:userId:clientKey)により、エンドポイント間およびテナント間の衝突を防ぎます
  • レスポンスエンベロープには、シリアライズされた本文、ステータスコード、コンテンツタイプを保存します(IResult の参照ではありません)
  • 処理中のレコード(確保済みだが未完了)は、同時に発生した重複リクエストに対して409を返します
  • 放置された処理中のレコード(例:実行途中でプロセスがクラッシュした場合)に対応するため、古いレコードをクリーンアップするジョブの追加を検討してください
トランザクショナルアウトボックスパターン

データベースへの書き込みとともに、ドメインイベントを少なくとも1回配信することを保証します。

// 1. Store outbox messages in the same transaction as the domain write
public sealed class OutboxMessage
{
    public Guid Id { get; init; } = Guid.NewGuid();
    public required string EventType { get; init; }
    public required string Payload { get; init; }
    public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
    public DateTimeOffset? ProcessedAt { get; set; }
}

// 2. In the handler -- same DbContext transaction
public async Task<Order> CreateOrderAsync(
    CreateOrderRequest request,
    CancellationToken ct)
{
    await using var transaction = await _db.Database
        .BeginTransactionAsync(ct);

    var order = new Order { /* ... */ };
    _db.Orders.Add(order);

    _db.OutboxMessages.Add(new OutboxMessage
    {
        EventType = "OrderCreated",
        Payload = JsonSerializer.Serialize(
            new OrderCreatedEvent(order.Id, order.Total))
    });

    await _db.SaveChangesAsync(ct);
    await transaction.CommitAsync(ct);

    return order;
}

// 3. Background processor publishes outbox messages
// See [skill:dotnet-background-services] for the Channels-based
// processor that polls and publishes these messages.

アウトボックスパターンにより、メッセージブローカーが一時的に利用できない場合でも、データベースへの書き込みが成功すれば、イベントが(最終的に)必ず発行されます。


主な原則

  • SOLID原則を適用する -- 単一責任(クラスを変更する理由は1つ)、オープン/クローズド(既存コードの変更ではなく、新しい型によって拡張する)、依存関係逆転(モジュール境界では抽象に依存する)。アンチパターン、修正方法、準拠テストについては、[skill:dotnet-solid-principles] を参照してください。
  • 継承よりコンポジションを優先する -- 基底クラスではなく、エンドポイントフィルター、ミドルウェア、パイプラインのコンポジションを使用する
  • スライスを独立させる -- 機能間を結び付ける共有抽象化を避ける。DRYが適用されるのは知識の重複であり、境界づけられたコンテキスト間のコードの類似性ではありません
  • 早期に検証し、速やかに失敗させる -- ビジネスロジックに入る前に、境界(エンドポイントフィルター)で検証する
  • あらゆる箇所でProblem Detailsを使用する -- すべてのエンドポイントで一貫したエラー形式を使用する
  • 適切なレベルでキャッシュする -- HTTPレスポンスには出力キャッシュ、共有状態には分散キャッシュ、両方にはHybridCacheを使用する
  • 書き込みを冪等にする -- クライアントが再試行する可能性のある、冪等でない操作には冪等性キーを使用する

エージェントに関する注意点

  1. 冪等性では3つの状態を扱う必要があります -- 冪等性の実装では、レコードなし(処理を取得する)、処理中(重複を拒否する)、完了(キャッシュされた応答を再生する)を区別する必要があります。処理中の状態を保護せずに確認してから実行すると、同時に重複実行される可能性があります。
  2. 冪等性レコードは必ず無条件に完了させてください -- 特定の IResult サブタイプ(例:IValueHttpResult)を条件に完了させてはいけません。Results.NoContent() や Results.Accepted() などの値を返さない結果は、処理中の状態のまま永久に残ります。
  3. キャッシュの無効化は明示的に行う必要があります -- 出力キャッシュや分散キャッシュを使用する場合は、書き込み操作の後に必ず無効化(タグまたはキーによる削除)してください。無効化を忘れると、デバッグが難しい古いデータが読み取られます。
  4. HybridCache のスタンピード保護が機能するのは GetOrCreateAsync の場合のみです -- HybridCache で個別の取得後に設定するパターンを使用してはいけません。同じキーへの並行リクエストをライブラリが直列化できるよう、ファクトリーのオーバーロードを使用してください。
  5. Outbox メッセージはドメインデータと同じトランザクション内で書き込む必要があります -- Outbox メッセージをドメイントランザクションの外で書き込むと、2つの書き込みの間でクラッシュした際にイベントが失われます。両方の書き込みをアトミックに行うため、必ず BeginTransactionAsync を使用してください。
  6. エンドポイントフィルターの順序は重要です -- 先に追加したフィルターが最も外側で実行されます。検証フィルターは冪等性フィルターより先に実行する必要があります。そうしないと、無効なリクエストが冪等性応答としてキャッシュされます。
  7. 並行リクエスト間で DbContext を共有してはいけません -- DbContext はスレッドセーフではありません。各リクエストで、DI から独自のスコープ付きインスタンスを解決する必要があります。シングルトンまたは静的な DbContext を使用すると、並行実行時にデータが破損します。

ナレッジソース

このスキルのアーキテクチャパターンは、以下の公開コンテンツに基づいています。

  • Jimmy Bogard の Vertical Slice Architecture -- 技術レイヤーではなく機能ごとにコードを整理する方法です。Bogard は、各垂直スライスが独自のリクエスト、ハンドラー、検証、データアクセスを持つことで、機能間の結合を減らすことを提唱しています。彼は .NET におけるリクエスト/ハンドラーのディスパッチ用として広く使われている MediatR ライブラリを開発しましたが、MediatR は現在、商用利用向けの商用製品です。垂直スライスのガイダンスを適用する際は、単純なシナリオでサードパーティのメディエーター依存関係を導入するのではなく、上記の組み込みの IEndpointFilter とハンドラーパターンを優先してください。出典:https://www.jimmybogard.com/vertical-slice-architecture/
  • Jimmy Bogard の Domain-Driven Design Patterns -- エンティティ設計、値オブジェクト、ドメインイベント、集約境界など、リッチなドメインモデルに関するガイダンスです。重要な知見:永続化に失敗した場合の不整合を避けるため、ドメインイベントは集約の状態変更が永続化された後に(前ではなく)ディスパッチする必要があります。出典:https://www.jimmybogard.com/
  • Nick Chapsas の Modern .NET Patterns -- エラー処理のための結果型、構造化された検証パイプライン、本番コードベースにおける最新の C# 機能の導入など、モダンな .NET の実践的なパターンです。出典:https://www.youtube.com/@nickchapsas

注: このスキルは、公開されているガイダンスを適用したものです。記載されている情報源を代表したり、その見解を代弁したりするものではありません。MediatR は商用利用向けの商用製品です。ここでは、組み込みの .NET 機能を使ってパターンを紹介しています。

参考資料

コメント