DEV Community

Cover image for Clean Architecture in .NET 10: The Application Layer — CQRS Without the Ceremony
Brian Spann
Brian Spann

Posted on

Clean Architecture in .NET 10: The Application Layer — CQRS Without the Ceremony

The Application layer is where your use cases live. It orchestrates domain entities and infrastructure services to accomplish what users actually want to do: create a prompt, fetch a collection, search by tag.

In this part, we'll implement CQRS with MediatR, but we'll also be honest about when it's overkill—and what to do instead.


What The Application Layer Does

The Application layer:

  • Defines what the system can do (use cases)
  • Orchestrates how it happens (coordination)
  • Defines interfaces for external dependencies (repositories, services)

It does NOT:

  • Know about HTTP, controllers, or JSON
  • Implement database access (that's Infrastructure)
  • Contain business rules (that's Domain)

CQRS: The Pattern Everyone Explains Wrong

CQRS = Command Query Responsibility Segregation. It means:

  • Commands change state (create, update, delete)
  • Queries read state (get, list, search)

That's it. It doesn't require:

  • Event sourcing
  • Separate read/write databases
  • Complex infrastructure

For most apps, CQRS just means: "have different classes for operations that read vs. operations that write."


MediatR: Help or Just Indirection?

MediatR is the go-to library for implementing CQRS in .NET. It gives you:

  • Request/Handler pattern
  • Pipeline behaviors (validation, logging)
  • Clean separation between "what to do" and "how to do it"

The criticism: "It's just indirection. Instead of calling a method on a service, you call mediator.Send() which finds a handler which... calls methods on services."

The defense: MediatR gives you a consistent pattern for:

  • Adding cross-cutting concerns (validation, caching, logging) without touching handlers
  • Making handlers testable in isolation
  • Keeping controllers thin

🤔 My take: MediatR is worth it for applications with 10+ distinct operations. For a 3-endpoint API, just use a service class.

For PromptVault, we have enough operations that MediatR helps organize them.


Setting Up MediatR

cd src/PromptVault.Application
dotnet add package MediatR
dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions
Enter fullscreen mode Exit fullscreen mode

Defining Repository Interfaces

Before we write commands and queries, we need repository interfaces. These live in Application (not Infrastructure) because:

  • Application layer knows what it needs
  • Infrastructure layer provides implementations
  • This keeps dependencies flowing inward

src/PromptVault.Application/Interfaces/IPromptRepository.cs

using PromptVault.Domain.Entities;

namespace PromptVault.Application.Interfaces;

public interface IPromptRepository
{
    Task<Prompt?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task<Prompt?> GetByIdWithVersionsAsync(Guid id, CancellationToken ct = default);
    Task<List<Prompt>> SearchAsync(string query, CancellationToken ct = default);
    Task AddAsync(Prompt prompt, CancellationToken ct = default);
    Task UpdateAsync(Prompt prompt, CancellationToken ct = default);
    Task DeleteAsync(Guid id, CancellationToken ct = default);
    Task<bool> TitleExistsAsync(string title, Guid? excludeId = null, CancellationToken ct = default);
}
Enter fullscreen mode Exit fullscreen mode

Real-World Callout: The Repository Explosion

Every entity gets a repository. Every repository has 8-10 methods. For 20 entities, that's 20 interfaces and 20 implementations.

Alternatives:

  1. Generic repository: One IRepository<T> with basic CRUD.
  2. No repository at all: Just inject DbContext into handlers.

We'll use specific repositories because they express intent clearly.


Project Structure

src/PromptVault.Application/
├── Commands/
│   ├── CreatePrompt/
│   │   ├── CreatePromptCommand.cs
│   │   ├── CreatePromptCommandHandler.cs
│   │   └── CreatePromptCommandValidator.cs
│   └── UpdatePrompt/
│       └── ...
├── Queries/
│   ├── GetPromptById/
│   │   ├── GetPromptByIdQuery.cs
│   │   └── GetPromptByIdQueryHandler.cs
│   └── SearchPrompts/
│       └── ...
├── Interfaces/
│   └── IPromptRepository.cs
├── DTOs/
│   └── PromptDto.cs
└── Common/
    ├── Result.cs
    └── Behaviors/
        └── ValidationBehavior.cs
Enter fullscreen mode Exit fullscreen mode

Folder-per-feature: Each command/query gets its own folder. This scales better than one big Commands/ folder with 50 files.


The Result Pattern

Before we write handlers, let's address error handling. Instead of throwing exceptions for expected failures, we use a Result type:

src/PromptVault.Application/Common/Result.cs

namespace PromptVault.Application;

public enum ErrorType
{
    None,
    Validation,
    NotFound,
    Conflict,
    Failure
}

public class Result<T>
{
    public bool IsSuccess { get; }
    public T? Value { get; }
    public string? Error { get; }
    public ErrorType ErrorType { get; }

    private Result(bool isSuccess, T? value, string? error, ErrorType errorType)
    {
        IsSuccess = isSuccess;
        Value = value;
        Error = error;
        ErrorType = errorType;
    }

    public static Result<T> Success(T value) => 
        new(true, value, null, ErrorType.None);

    public static Result<T> Failure(string error, ErrorType type = ErrorType.Failure) => 
        new(false, default, error, type);

    public static Result<T> NotFound(string error = "Resource not found") => 
        new(false, default, error, ErrorType.NotFound);

    public static Result<T> Conflict(string error) => 
        new(false, default, error, ErrorType.Conflict);
}
Enter fullscreen mode Exit fullscreen mode

Why Result instead of exceptions?

Exceptions should be exceptional. "Prompt not found" isn't exceptional—it's a normal flow. Using Result:

  • Makes failure cases explicit in the type signature
  • Avoids try/catch everywhere
  • Makes testing easier

Commands

CreatePromptCommand

src/PromptVault.Application/Commands/CreatePrompt/CreatePromptCommand.cs

using MediatR;

namespace PromptVault.Application.Commands.CreatePrompt;

public record CreatePromptCommand(
    string Title,
    string Content,
    string ModelType,
    List<string>? Tags = null
) : IRequest<Result<Guid>>;
Enter fullscreen mode Exit fullscreen mode

CreatePromptCommandHandler

using MediatR;
using PromptVault.Application.Interfaces;
using PromptVault.Domain.Entities;
using PromptVault.Domain.ValueObjects;

namespace PromptVault.Application.Commands.CreatePrompt;

public class CreatePromptCommandHandler : IRequestHandler<CreatePromptCommand, Result<Guid>>
{
    private readonly IPromptRepository _repository;

    public CreatePromptCommandHandler(IPromptRepository repository)
    {
        _repository = repository;
    }

    public async Task<Result<Guid>> Handle(CreatePromptCommand request, CancellationToken ct)
    {
        // Business rule: titles must be unique
        if (await _repository.TitleExistsAsync(request.Title, null, ct))
        {
            return Result<Guid>.Conflict("A prompt with this title already exists");
        }

        // Create domain entity
        var modelType = new ModelType(request.ModelType);
        var prompt = new Prompt(request.Title, request.Content, modelType);

        // Add tags
        if (request.Tags != null)
        {
            foreach (var tag in request.Tags)
            {
                prompt.AddTag(tag);
            }
        }

        await _repository.AddAsync(prompt, ct);

        return Result<Guid>.Success(prompt.Id);
    }
}
Enter fullscreen mode Exit fullscreen mode

CreatePromptCommandValidator

using FluentValidation;

namespace PromptVault.Application.Commands.CreatePrompt;

public class CreatePromptCommandValidator : AbstractValidator<CreatePromptCommand>
{
    public CreatePromptCommandValidator()
    {
        RuleFor(x => x.Title)
            .NotEmpty().WithMessage("Title is required")
            .MaximumLength(200).WithMessage("Title cannot exceed 200 characters");

        RuleFor(x => x.Content)
            .NotEmpty().WithMessage("Content is required")
            .MaximumLength(50000).WithMessage("Content cannot exceed 50,000 characters");

        RuleFor(x => x.ModelType)
            .NotEmpty().WithMessage("Model type is required");

        RuleFor(x => x.Tags)
            .Must(tags => tags == null || tags.Count <= 20)
            .WithMessage("Cannot have more than 20 tags");
    }
}
Enter fullscreen mode Exit fullscreen mode

Queries

GetPromptByIdQuery

using MediatR;
using PromptVault.Application.DTOs;

namespace PromptVault.Application.Queries.GetPromptById;

public record GetPromptByIdQuery(Guid Id, bool IncludeVersions = false) 
    : IRequest<Result<PromptDto>>;
Enter fullscreen mode Exit fullscreen mode

GetPromptByIdQueryHandler

using MediatR;
using PromptVault.Application.DTOs;
using PromptVault.Application.Interfaces;

namespace PromptVault.Application.Queries.GetPromptById;

public class GetPromptByIdQueryHandler : IRequestHandler<GetPromptByIdQuery, Result<PromptDto>>
{
    private readonly IPromptRepository _repository;

    public GetPromptByIdQueryHandler(IPromptRepository repository)
    {
        _repository = repository;
    }

    public async Task<Result<PromptDto>> Handle(GetPromptByIdQuery request, CancellationToken ct)
    {
        var prompt = request.IncludeVersions
            ? await _repository.GetByIdWithVersionsAsync(request.Id, ct)
            : await _repository.GetByIdAsync(request.Id, ct);

        if (prompt == null)
        {
            return Result<PromptDto>.NotFound($"Prompt {request.Id} not found");
        }

        return Result<PromptDto>.Success(PromptDto.FromEntity(prompt, request.IncludeVersions));
    }
}
Enter fullscreen mode Exit fullscreen mode

DTOs

Queries return DTOs, not entities. This:

  • Prevents accidentally exposing internal state
  • Allows shaping data for the API's needs
  • Decouples API contracts from domain structure

src/PromptVault.Application/DTOs/PromptDto.cs

using PromptVault.Domain.Entities;

namespace PromptVault.Application.DTOs;

public record PromptDto(
    Guid Id,
    string Title,
    string Content,
    string ModelType,
    List<string> Tags,
    int VersionCount,
    DateTime CreatedAt,
    DateTime? UpdatedAt,
    List<PromptVersionDto>? Versions = null
)
{
    public static PromptDto FromEntity(Prompt prompt, bool includeVersions = false)
    {
        return new PromptDto(
            prompt.Id,
            prompt.Title,
            prompt.Content,
            prompt.ModelType.Value,
            prompt.Tags.Select(t => t.Value).ToList(),
            prompt.Versions.Count,
            prompt.CreatedAt,
            prompt.UpdatedAt,
            includeVersions 
                ? prompt.Versions.Select(PromptVersionDto.FromEntity).ToList()
                : null
        );
    }
}

public record PromptVersionDto(
    Guid Id,
    int VersionNumber,
    string Content,
    DateTime CreatedAt,
    string? CreatedBy
)
{
    public static PromptVersionDto FromEntity(PromptVersion v) => new(
        v.Id, v.VersionNumber, v.Content, v.CreatedAt, v.CreatedBy
    );
}
Enter fullscreen mode Exit fullscreen mode

Validation Pipeline Behavior

This is where MediatR shines. We add one behavior that validates ALL commands automatically:

src/PromptVault.Application/Common/Behaviors/ValidationBehavior.cs

using FluentValidation;
using MediatR;

namespace PromptVault.Application.Common.Behaviors;

public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    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 failures = _validators
            .Select(v => v.Validate(context))
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Count != 0)
            throw new ValidationException(failures);

        return await next();
    }
}
Enter fullscreen mode Exit fullscreen mode

Now every command with a validator is automatically validated before the handler runs. No manual if (!IsValid) checks.


When Handlers Are Overkill

Here's the uncomfortable truth: some handlers are trivial.

// This handler just calls one repository method
public class DeletePromptCommandHandler : IRequestHandler<DeletePromptCommand, Result>
{
    private readonly IPromptRepository _repository;

    public async Task<Result> Handle(DeletePromptCommand request, CancellationToken ct)
    {
        await _repository.DeleteAsync(request.Id, ct);
        return Result.Success();
    }
}
Enter fullscreen mode Exit fullscreen mode

Is this indirection worth it?

  • Yes: Consistency. Every operation goes through MediatR. Cross-cutting concerns apply uniformly.
  • No: One-liner handlers are noise.

💡 My take: Start with MediatR for consistency. If most handlers are trivial pass-throughs, consider using service classes for simple CRUD.


Alternative: Service Classes

If MediatR feels heavy:

public interface IPromptService
{
    Task<Result<Guid>> CreateAsync(CreatePromptRequest request);
    Task<Result<PromptDto>> GetByIdAsync(Guid id);
    Task<Result> DeleteAsync(Guid id);
}
Enter fullscreen mode Exit fullscreen mode

Tradeoffs:

  • ✅ Simpler, fewer files
  • ❌ No pipeline behaviors
  • ❌ Services grow large over time

Testing Handlers

Handlers are easy to test because dependencies are injected:

public class CreatePromptCommandHandlerTests
{
    [Fact]
    public async Task Handle_WithValidData_ReturnsSuccessWithId()
    {
        // Arrange
        var mockRepo = new Mock<IPromptRepository>();
        mockRepo.Setup(r => r.TitleExistsAsync(It.IsAny<string>(), null, default))
            .ReturnsAsync(false);

        var handler = new CreatePromptCommandHandler(mockRepo.Object);
        var command = new CreatePromptCommand("Test", "Content", "gpt-4");

        // Act
        var result = await handler.Handle(command, CancellationToken.None);

        // Assert
        Assert.True(result.IsSuccess);
        Assert.NotEqual(Guid.Empty, result.Value);
    }

    [Fact]
    public async Task Handle_WithDuplicateTitle_ReturnsConflict()
    {
        var mockRepo = new Mock<IPromptRepository>();
        mockRepo.Setup(r => r.TitleExistsAsync("Existing", null, default))
            .ReturnsAsync(true);

        var handler = new CreatePromptCommandHandler(mockRepo.Object);
        var command = new CreatePromptCommand("Existing", "Content", "gpt-4");

        var result = await handler.Handle(command, CancellationToken.None);

        Assert.False(result.IsSuccess);
        Assert.Equal(ErrorType.Conflict, result.ErrorType);
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. CQRS is simple — Commands write, Queries read. That's it.
  2. MediatR adds structure — Worth it for larger apps, optional for small ones
  3. Define interfaces in Application — Implementation goes in Infrastructure
  4. Use Result over exceptions — For expected failure cases
  5. Folder-per-feature scales better — Than one big Commands folder
  6. Pipeline behaviors are powerful — Add validation/logging once, applies everywhere

Coming Up

In Part 4, we'll implement the Infrastructure layer:

  • EF Core DbContext and entity configuration
  • Repository implementations
  • The "repository vs. DbContext directly" debate

👉 Part 4: The Infrastructure Layer — EF Core Without the Leakage


Full source: github.com/bspann/promptvault

Top comments (0)