.NET 2026年のClean Architecture: CQRS、MediatR、面接で問われる設計パターン

.NETでのClean Architecture実装を徹底解説。CQRS、MediatR 14、パイプラインビヘイビアの実践的なコード例と、シニア開発者面接で頻出の設計質問への回答方法を紹介します。

.NET 2026年のClean Architecture: CQRS、MediatR、面接で問われる設計パターン

.NETにおけるClean Architectureは、ビジネスロジックをインフラストラクチャの関心事から分離し、テスト容易性、保守性、スケーラビリティを向上させる設計手法です。CQRS(Command Query Responsibility Segregation)とMediatRを組み合わせることで、エンタープライズアプリケーション構築における構造化されたアプローチを実現できます。この設計パターンは、シニア.NET開発者の面接において頻繁に評価される重要なスキルです。

面接での重要ポイント

Clean Architectureに関する質問は、シニア.NET面接の70%以上で出題されます。面接官は依存関係の規則について説明できることを期待しています:依存関係は内側に向かい、ドメイン層は外部フレームワークへの依存を持ちません。

Clean Architectureのレイヤー構成と依存関係の規則

ASP.NET Core 10向けのArdalis Clean Architectureテンプレートは、コードを4つの同心円状のレイヤーに整理します。各レイヤーには特定の責務と厳格な依存関係の規則があります。

csharp
// Core/Domain Layer - No external dependencies
// src/Core/Domain/Entities/Order.cs
namespace Core.Domain.Entities;

public class Order
{
    public Guid Id { get; private set; }
    public string CustomerEmail { get; private set; }
    public List<OrderLine> Lines { get; private set; } = new();
    public OrderStatus Status { get; private set; }
    public DateTime CreatedAt { get; private set; }

    // Domain logic encapsulated in the entity
    public void AddLine(Product product, int quantity)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Cannot modify a submitted order");

        var existingLine = Lines.FirstOrDefault(l => l.ProductId == product.Id);
        if (existingLine is not null)
        {
            existingLine.IncreaseQuantity(quantity);
            return;
        }

        Lines.Add(new OrderLine(product.Id, product.Price, quantity));
    }

    public void Submit()
    {
        if (!Lines.Any())
            throw new InvalidOperationException("Cannot submit an empty order");

        Status = OrderStatus.Submitted;
    }
}

ドメイン層には、エンティティ、値オブジェクト、ドメインサービスが含まれます。データベース、HTTP、外部フレームワークについては一切知識を持ちません。この設計により、ビジネスルールの純粋性が保たれ、単体テストが容易になります。

CQRSパターン:読み取りと書き込みの分離

CQRSは、操作をCommand(状態を変更する書き込み操作)とQuery(データを返す読み取り操作)に分離します。この分離により、各パスの独立したスケーリングと最適化が可能になります。

csharp
// UseCases Layer - Commands and Queries
// src/UseCases/Orders/Commands/CreateOrder/CreateOrderCommand.cs
namespace UseCases.Orders.Commands.CreateOrder;

public record CreateOrderCommand(
    string CustomerEmail,
    List<OrderLineDto> Lines
) : IRequest<Result<Guid>>;

public record OrderLineDto(Guid ProductId, int Quantity);

Commandは最小限のデータを返します。通常は成功インジケーターまたは新規作成されたIDのみです。Queryはコンシューマー向けに最適化されたDTOを返します。

src/UseCases/Orders/Queries/GetOrderById/GetOrderByIdQuery.cscsharp
namespace UseCases.Orders.Queries.GetOrderById;

public record GetOrderByIdQuery(Guid OrderId) : IRequest<Result<OrderDetailsDto>>;

public record OrderDetailsDto(
    Guid Id,
    string CustomerEmail,
    string Status,
    decimal TotalAmount,
    List<OrderLineDetailsDto> Lines
);

読み取りモデルと書き込みモデルを分離することで、それぞれの要件に最適化された設計が可能です。例えば、読み取り側では非正規化されたビューを使用し、書き込み側では正規化されたドメインモデルを維持できます。

MediatR 14:パイプラインビヘイビアとハンドラー実装

MediatR 14.2は、CQRSのためのメッセージングインフラストラクチャを提供します。各CommandまたはQueryには正確に1つのハンドラーがあり、単一責任の原則を強制します。

src/UseCases/Orders/Commands/CreateOrder/CreateOrderHandler.cscsharp
namespace UseCases.Orders.Commands.CreateOrder;

public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Result<Guid>>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IProductRepository _productRepository;
    private readonly IUnitOfWork _unitOfWork;

    public CreateOrderHandler(
        IOrderRepository orderRepository,
        IProductRepository productRepository,
        IUnitOfWork unitOfWork)
    {
        _orderRepository = orderRepository;
        _productRepository = productRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result<Guid>> Handle(
        CreateOrderCommand request,
        CancellationToken cancellationToken)
    {
        // Load products to validate and get prices
        var productIds = request.Lines.Select(l => l.ProductId).ToList();
        var products = await _productRepository
            .GetByIdsAsync(productIds, cancellationToken);

        if (products.Count != productIds.Count)
            return Result.NotFound("One or more products not found");

        // Create order using domain logic
        var order = new Order(request.CustomerEmail);

        foreach (var line in request.Lines)
        {
            var product = products.First(p => p.Id == line.ProductId);
            order.AddLine(product, line.Quantity);
        }

        await _orderRepository.AddAsync(order, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return Result.Success(order.Id);
    }
}

ハンドラーは、リポジトリインターフェースを通じてデータにアクセスし、ドメインロジックを呼び出して操作を実行します。Unit of Workパターンにより、複数のリポジトリ操作を1つのトランザクションとして管理できます。

.NETの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

パイプラインビヘイビアによる横断的関心事の処理

MediatRのパイプラインビヘイビアは、ロギング、バリデーション、キャッシング、トランザクション管理をハンドラーを汚染することなく処理します。

src/UseCases/Common/Behaviors/ValidationBehavior.cscsharp
namespace UseCases.Common.Behaviors;

public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext<TRequest>(request);

        var validationResults = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, cancellationToken)));

        var failures = validationResults
            .SelectMany(r => r.Errors)
            .Where(f => f is not null)
            .ToList();

        if (failures.Any())
            throw new ValidationException(failures);

        return await next();
    }
}

ビヘイビアは実行順序で登録します。通常、バリデーションが最初に実行され、次にロギング、そしてトランザクション処理の順序となります。

src/Web/Program.cscsharp
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(CreateOrderCommand).Assembly);
    cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
    cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
    cfg.AddOpenBehavior(typeof(TransactionBehavior<,>));
});

このパイプラインアーキテクチャにより、横断的関心事をモジュール化し、必要に応じて追加・削除できます。面接では、この設計がAOPの代替としてどのように機能するかを説明できることが重要です。

インフラストラクチャ層:リポジトリの実装

インフラストラクチャ層は、コア層で定義されたインターフェースを実装します。Entity Framework Coreリポジトリは、ドメイン操作をデータベース呼び出しに変換します。

src/Infrastructure/Data/Repositories/OrderRepository.cscsharp
namespace Infrastructure.Data.Repositories;

public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Order?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        return await _context.Orders
            .Include(o => o.Lines)
            .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
    }

    public async Task AddAsync(
        Order order,
        CancellationToken cancellationToken = default)
    {
        await _context.Orders.AddAsync(order, cancellationToken);
    }

    public async Task<IReadOnlyList<Order>> GetByCustomerEmailAsync(
        string email,
        CancellationToken cancellationToken = default)
    {
        return await _context.Orders
            .Where(o => o.CustomerEmail == email)
            .OrderByDescending(o => o.CreatedAt)
            .ToListAsync(cancellationToken);
    }
}

リポジトリパターンにより、データアクセスロジックがカプセル化され、ドメイン層はデータの永続化方法を意識する必要がありません。テスト時にはモックリポジトリを注入することで、データベースなしでビジネスロジックをテストできます。

面接での質問:シニア開発者が知るべきこと

シニア.NETポジションの技術面接では、Clean Architectureに関する質問が頻繁に含まれます。以下は、面接官が評価するパターンです。

Q: なぜドメイン層は外部フレームワークに依存してはいけないのですか?

ドメイン層には、インフラストラクチャの変更に関係なく安定すべきビジネスルールが含まれます。ドメインがEntity Frameworkに依存している場合、Dapperや別のデータベースに切り替えるにはビジネスロジックの変更が必要になります。依存関係の規則により、インフラストラクチャはコアの動作に影響を与えずに交換できる詳細となります。

Q: CQRSが過剰な場合はどのような時ですか?

CQRSは、シンプルなCRUDアプリケーションには不要な複雑さを追加します。読み取りモデルと書き込みモデルがほぼ同一で、別々のスケーリングの必要がなく、チームが小規模な場合は、よりシンプルなアーキテクチャが適切です。CQRSは、読み取りモデルが書き込みモデルと大きく異なる場合、イベントソーシングが必要な場合、または読み取りと書き込みの負荷を独立してスケーリングする必要がある場合に効果を発揮します。

Q: 複数の集約にまたがるトランザクションをどのように処理しますか?

csharp
// Transaction behavior wraps the entire handler execution
public class TransactionBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IUnitOfWork _unitOfWork;

    public TransactionBehavior(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        // Commands that modify multiple aggregates use explicit transactions
        if (request is not ICommand)
            return await next();

        await using var transaction = await _unitOfWork
            .BeginTransactionAsync(cancellationToken);

        try
        {
            var response = await next();
            await transaction.CommitAsync(cancellationToken);
            return response;
        }
        catch
        {
            await transaction.RollbackAsync(cancellationToken);
            throw;
        }
    }
}

複数の集約にまたがる操作では、可能な限りドメインイベントを使用した結果整合性を採用します。集約間の同期トランザクションは、設計上の問題を示唆している可能性があります:それらの集約は一つに統合すべきかもしれません。

読み取り側の最適化:分離されたクエリモデル

パフォーマンスが重要な場合、Queryはドメイン層を完全にバイパスします。Dapperや生のSQLを使用した直接データベースアクセスにより、完全なエンティティグラフの実体化によるオーバーヘッドを回避できます。

src/Infrastructure/Queries/GetOrderByIdQueryHandler.cscsharp
namespace Infrastructure.Queries;

public class GetOrderByIdQueryHandler
    : IRequestHandler<GetOrderByIdQuery, Result<OrderDetailsDto>>
{
    private readonly IDbConnection _connection;

    public GetOrderByIdQueryHandler(IDbConnection connection)
    {
        _connection = connection;
    }

    public async Task<Result<OrderDetailsDto>> Handle(
        GetOrderByIdQuery request,
        CancellationToken cancellationToken)
    {
        const string sql = """
            SELECT o.Id, o.CustomerEmail, o.Status, o.CreatedAt,
                   SUM(ol.UnitPrice * ol.Quantity) AS TotalAmount
            FROM Orders o
            LEFT JOIN OrderLines ol ON ol.OrderId = o.Id
            WHERE o.Id = @OrderId
            GROUP BY o.Id, o.CustomerEmail, o.Status, o.CreatedAt
            """;

        var order = await _connection.QueryFirstOrDefaultAsync<OrderDetailsDto>(
            new CommandDefinition(sql, new { request.OrderId }, cancellationToken: cancellationToken));

        return order is null
            ? Result.NotFound($"Order {request.OrderId} not found")
            : Result.Success(order);
    }
}

このアプローチにより、読み取り操作は最大のパフォーマンスを達成し、書き込み操作はドメインの整合性を維持できます。面接では、この設計の理由とトレードオフを説明できることが求められます。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

Clean Architecture面接対策:重要なポイント

  • 依存関係の規則は、ソースコードの依存関係が内側にのみ向かうことを規定しています。ドメイン層は、データベース、フレームワーク、配信メカニズムについての知識を持ちません。
  • CQRSは、Command(状態変更)とQuery(データ取得)を分離します。Commandはドメインモデルとバリデーションを経由します。Queryはパフォーマンスのためにドメインエンティティをバイパスできます。
  • MediatR 14はリクエストをハンドラーにルーティングします。パイプラインビヘイビアは、バリデーション、ロギング、トランザクションなどの横断的関心事をコンポーザブルな方法で処理します。
  • リポジトリインターフェースはコア層に配置されます。実装はインフラストラクチャ層に配置されます。この反転により、ビジネスロジックに触れることなくEF CoreをDapperに交換できます。
  • 面接での回答にはトレードオフを含めるべきです。Clean Architectureは初期の複雑さを追加しますが、複数の開発者がいる大規模で長期間運用されるアプリケーションではその価値を発揮します。
  • SharpSkillのASP.NET Core Clean Architectureモジュールでは、Specification、Resultオブジェクト、ドメインイベントなどの追加パターンをカバーしています。
今日のチャレンジ

.NET のバグを見つけられますか

実際のコード、隠れたバグ、1日1回。アカウントなしで試せます。

Anthony Fillion-Maillet

執筆

Anthony Fillion-Maillet

SharpSkill 創業者

10 年以上フルスタック開発に携わっています。SharpSkill を運営し、ここで公開される内容に責任を負っています。

2026年9月15日 更新

共有

関連記事