In Part 1 we talked about why Clean Architecture still matters in 2026 and introduced Kids Learn — our AI-powered adaptive learning SaaS. In Part 2 we built the Domain layer: value objects, entities, aggregates, domain events, all the building blocks that enforce business rules at the lowest level.

The Domain layer has our business rules. But who orchestrates them?

When a child completes a lesson, something needs to: update the mastery level, check for knowledge gaps, trigger the adaptive engine, and maybe notify the parent. That’s not business logic — the domain doesn’t know about AI engines or notification systems. And it’s not infrastructure — we’re not talking about database queries or HTTP calls. It’s coordination. Use case orchestration.

That’s the Application layer’s job. And in 2026, we have a fascinating choice to make about how we structure it.

What the Application Layer Actually Does

Think of the Application layer as the director of a play. The Domain layer is the script — the rules, the characters, the constraints. The Infrastructure layer is the stage, the lighting, the sound system. The Application layer is the director saying “okay, in this scene, the adaptive engine calculates difficulty, then the lesson generator creates content, then we save to the database, and finally we publish an event.”

Concretely, the Application layer has three responsibilities:

1. Orchestrating use cases. A use case is a single thing a user can do in the system. “Generate a lesson for my child.” “Complete a lesson and update progress.” “Get recommended topics.” Each use case coordinates multiple domain operations and infrastructure calls.

2. Defining ports (interfaces). The Application layer defines the contracts that the Infrastructure layer must fulfill. ILessonGenerator, ICurriculumRetriever, IAdaptiveEngine — these interfaces live in the Application layer. The actual implementations (calling OpenAI, querying a curriculum database, running an ML model) live in Infrastructure. This is the Dependency Inversion Principle in action.

3. Handling cross-cutting concerns. Validation, authorization, logging, caching — these are pipeline behaviors that wrap around every use case. Rather than duplicating this logic in every handler, we define it once and apply it universally.

What the Application layer does NOT do:

  • It doesn’t know about EF Core, HTTP, or any specific framework (with the exception of your mediator library)
  • It doesn’t implement business rules — that’s the Domain layer
  • It doesn’t talk to databases or APIs directly — it uses interfaces that Infrastructure implements
  • It doesn’t know about ASP.NET, controllers, or Minimal APIs — that’s the Presentation layer

Here’s the project structure for our Application layer:

KidsLearn.Application/
├── Common/
│   ├── Interfaces/
│   │   ├── IApplicationDbContext.cs
│   │   ├── ILessonGenerator.cs
│   │   ├── ICurriculumRetriever.cs
│   │   ├── IAdaptiveEngine.cs
│   │   └── ICurrentUserService.cs
│   ├── Behaviors/
│   │   ├── ValidationBehavior.cs
│   │   ├── AuthorizationBehavior.cs
│   │   └── LoggingBehavior.cs
│   ├── Exceptions/
│   │   ├── ValidationException.cs
│   │   └── ForbiddenAccessException.cs
│   └── Models/
│       └── Result.cs
├── Lessons/
│   ├── Commands/
│   │   ├── GenerateLesson/
│   │   │   ├── GenerateLessonCommand.cs
│   │   │   ├── GenerateLessonCommandHandler.cs
│   │   │   └── GenerateLessonCommandValidator.cs
│   │   └── CompleteLesson/
│   │       ├── CompleteLessonCommand.cs
│   │       ├── CompleteLessonCommandHandler.cs
│   │       └── CompleteLessonCommandValidator.cs
│   └── Queries/
│       ├── GetLessonById/
│       │   ├── GetLessonByIdQuery.cs
│       │   └── GetLessonByIdQueryHandler.cs
│       └── GetRecommendedTopics/
│           ├── GetRecommendedTopicQuery.cs
│           └── GetRecommendedTopicQueryHandler.cs
├── Children/
│   ├── Commands/
│   │   └── UpdateChildProfile/
│   │       └── ...
│   └── Queries/
│       └── GetChildProgress/
│           └── ...
└── DependencyInjection.cs

Every use case gets its own folder. The command, handler, and validator live together. You open the folder and you see everything related to that feature. Six months from now when someone needs to modify lesson generation, they know exactly where to look.

CQRS Explained Simply

CQRS stands for Command Query Responsibility Segregation. Despite the intimidating name, the concept is straightforward: commands change state, queries read state, and they should be handled separately.

That’s it. That’s the core idea.

For Kids Learn, our commands and queries look like this:

Commands (write side — they change something):

  • GenerateLessonCommand — creates a new lesson with AI-generated content
  • CompleteLessonCommand — marks a lesson done and updates progress
  • UpdateChildProfileCommand — modifies a child’s learning preferences

Queries (read side — they just retrieve data):

  • GetChildProgressQuery — returns a child’s learning progress
  • GetRecommendedTopicQuery — fetches the next best topic to study
  • GetLessonByIdQuery — retrieves a specific lesson

Why separate them? Because the read and write paths have fundamentally different requirements:

Commands need validation, authorization, transaction management, domain event publishing, and audit logging. When a child completes a lesson, we need to validate the input, check that the parent’s account has access, update multiple aggregates in a transaction, publish a LessonCompletedEvent, and log who did what.

Queries need speed. They don’t modify anything. They often need a different data shape than what the domain model provides. For a dashboard showing “your child’s progress this week,” I don’t want to load full aggregate roots with all their invariant-checking logic — I want a flat DTO assembled from a fast SQL query.

Let’s define the base abstractions:

// Commands change state and return a result
public interface ICommand<TResponse> : IRequest<TResponse>
{
}

// Queries read state and return data — they never change anything
public interface IQuery<TResponse> : IRequest<TResponse>
{
}

// Command handler
public interface ICommandHandler<TCommand, TResponse>
    : IRequestHandler<TCommand, TResponse>
    where TCommand : ICommand<TResponse>
{
}

// Query handler
public interface IQueryHandler<TQuery, TResponse>
    : IRequestHandler<TQuery, TResponse>
    where TQuery : IQuery<TResponse>
{
}

These are thin wrappers over MediatR’s IRequest/IRequestHandler. They don’t add behavior — they add semantics. When I see ICommand<Result> in a code review, I immediately know this operation changes state. When I see IQuery<LessonDto>, I know it’s read-only. That matters when you’re reviewing a pull request at 11 PM.

One critical distinction I want to hammer home: CQRS and MediatR are not the same thing. CQRS is an architectural pattern. MediatR is a library that can implement the mediator pattern, which happens to work well with CQRS. You could implement CQRS with direct method calls, with a simple interface-based approach, with Wolverine, or with any other dispatching mechanism. MediatR is popular for CQRS, but they’re separate concepts.

I’ve seen developers say “we’re doing CQRS” when they mean “we installed MediatR.” That’s like saying “we’re doing dependency injection” when you mean “we installed Autofac.” The tool is not the pattern.

CQRS flow — command path goes through validation, authorization, handler, then persistence. Query path goes through handler directly to read model.

MediatR Implementation

Let’s implement the GenerateLessonCommand use case end to end with MediatR. This is the flow: a parent requests a new lesson for their child on a specific topic, the adaptive engine calculates the right difficulty level, the AI lesson generator creates the content, and we persist it.

The Command

public sealed record GenerateLessonCommand(
    Guid ChildId,
    Guid TopicId,
    Guid RequestedByParentId
) : ICommand<Result<LessonDto>>;

I use records for commands and queries. They’re immutable, they get value equality for free, and the positional syntax is clean. The command carries the minimum data needed to execute the use case — IDs, not full objects.

The return type is Result<LessonDto>, not just LessonDto. I use a Result type to avoid throwing exceptions for expected business failures like “this child has no remaining lessons on their plan.” Exceptions should be for exceptional things.

The Validator

Before the handler ever runs, FluentValidation checks the input:

public sealed class GenerateLessonCommandValidator
    : AbstractValidator<GenerateLessonCommand>
{
    private readonly IApplicationDbContext _db;

    public GenerateLessonCommandValidator(IApplicationDbContext db)
    {
        _db = db;

        RuleFor(x => x.ChildId)
            .NotEmpty()
            .WithMessage("Child ID is required.");

        RuleFor(x => x.TopicId)
            .NotEmpty()
            .WithMessage("Topic ID is required.");

        RuleFor(x => x.RequestedByParentId)
            .NotEmpty()
            .WithMessage("Parent ID is required.");

        RuleFor(x => x)
            .MustAsync(ParentOwnsChild)
            .WithMessage("You don't have access to this child's account.");

        RuleFor(x => x)
            .MustAsync(TopicExists)
            .WithMessage("The requested topic was not found.");
    }

    private async Task<bool> ParentOwnsChild(
        GenerateLessonCommand command,
        CancellationToken ct)
    {
        return await _db.Children
            .AnyAsync(c => c.Id == command.ChildId
                        && c.ParentId == command.RequestedByParentId, ct);
    }

    private async Task<bool> TopicExists(
        GenerateLessonCommand command,
        CancellationToken ct)
    {
        return await _db.Topics
            .AnyAsync(t => t.Id == command.TopicId && t.IsActive, ct);
    }
}

This validator does two things: checks for obvious invalid input (empty GUIDs) and verifies business preconditions (the parent actually owns this child’s account, the topic exists). Some people argue that the second type of check belongs in the handler. I disagree — if a request can never succeed because of a precondition, fail it fast. Don’t load up the adaptive engine and AI generator only to discover the topic doesn’t exist.

The Handler

Now the actual use case logic:

public sealed class GenerateLessonCommandHandler
    : ICommandHandler<GenerateLessonCommand, Result<LessonDto>>
{
    private readonly IApplicationDbContext _db;
    private readonly IAdaptiveEngine _adaptiveEngine;
    private readonly ILessonGenerator _lessonGenerator;
    private readonly ICurriculumRetriever _curriculumRetriever;
    private readonly ILogger<GenerateLessonCommandHandler> _logger;

    public GenerateLessonCommandHandler(
        IApplicationDbContext db,
        IAdaptiveEngine adaptiveEngine,
        ILessonGenerator lessonGenerator,
        ICurriculumRetriever curriculumRetriever,
        ILogger<GenerateLessonCommandHandler> logger)
    {
        _db = db;
        _adaptiveEngine = adaptiveEngine;
        _lessonGenerator = lessonGenerator;
        _curriculumRetriever = curriculumRetriever;
        _logger = logger;
    }

    public async Task<Result<LessonDto>> Handle(
        GenerateLessonCommand request,
        CancellationToken ct)
    {
        // 1. Load the child's learning progress
        var child = await _db.Children
            .Include(c => c.LearningProgress)
            .FirstOrDefaultAsync(c => c.Id == request.ChildId, ct);

        if (child is null)
            return Result<LessonDto>.Failure("Child not found.");

        // 2. Load the topic
        var topic = await _db.Topics
            .FirstOrDefaultAsync(t => t.Id == request.TopicId, ct);

        if (topic is null)
            return Result<LessonDto>.Failure("Topic not found.");

        // 3. Ask the adaptive engine for the right difficulty
        var difficulty = _adaptiveEngine
            .CalculateNextDifficulty(child.LearningProgress);

        _logger.LogInformation(
            "Generating lesson for child {ChildId} on topic {TopicName} " +
            "at difficulty {Difficulty}",
            child.Id, topic.Name, difficulty.Value);

        // 4. Get aligned curriculum standards
        var standards = await _curriculumRetriever
            .FindAlignedStandardsAsync(topic, ct);

        // 5. Generate the lesson content via AI
        var lesson = await _lessonGenerator
            .GenerateAsync(topic, difficulty, ct);

        // 6. Associate curriculum standards with the lesson
        lesson.AlignWithStandards(standards);

        // 7. Persist
        _db.Lessons.Add(lesson);
        await _db.SaveChangesAsync(ct);

        // 8. Map to DTO and return
        return Result<LessonDto>.Success(lesson.ToDto());
    }
}

Look at what this handler does and doesn’t do:

  • It orchestrates — calls the adaptive engine, the lesson generator, the curriculum retriever
  • It uses domain methods — lesson.AlignWithStandards(standards) is a domain operation with business rules
  • It persists through an interface — IApplicationDbContext, not a concrete DbContext
  • It does NOT implement business rules — the difficulty calculation is in the adaptive engine, the alignment rules are on the entity
  • It does NOT know how lessons are generated — AI? Template? Doesn’t matter. ILessonGenerator abstracts that

This is the Application layer doing its job: pure orchestration.

Pipeline Behaviors

The real power of MediatR is pipeline behaviors. Every request flows through a pipeline of behaviors before reaching the handler. Think of it like ASP.NET middleware, but for your application layer.

Here’s our validation behavior that automatically validates every command:

public sealed 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 ct)
    {
        if (!_validators.Any())
            return await next();

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

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

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

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

        return await next();
    }
}

The authorization behavior checks that the current user has permission:

public sealed class AuthorizationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ICurrentUserService _currentUser;

    public AuthorizationBehavior(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var authorizeAttributes = request.GetType()
            .GetCustomAttributes<AuthorizeAttribute>();

        if (!authorizeAttributes.Any())
            return await next();

        if (_currentUser.UserId is null)
            throw new UnauthorizedAccessException(
                "User is not authenticated.");

        foreach (var attr in authorizeAttributes)
        {
            if (!string.IsNullOrEmpty(attr.Policy))
            {
                // Check policy-based authorization
                var authorized = await _currentUser
                    .AuthorizeAsync(attr.Policy);

                if (!authorized)
                    throw new ForbiddenAccessException(
                        $"Policy '{attr.Policy}' not satisfied.");
            }
        }

        return await next();
    }
}

And a logging behavior that gives us observability:

public sealed class LoggingBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
    private readonly ICurrentUserService _currentUser;

    public LoggingBehavior(
        ILogger<LoggingBehavior<TRequest, TResponse>> logger,
        ICurrentUserService currentUser)
    {
        _logger = logger;
        _currentUser = currentUser;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var requestName = typeof(TRequest).Name;
        var userId = _currentUser.UserId ?? "anonymous";

        _logger.LogInformation(
            "Handling {RequestName} for user {UserId}",
            requestName, userId);

        var sw = Stopwatch.StartNew();

        try
        {
            var response = await next();
            sw.Stop();

            _logger.LogInformation(
                "Handled {RequestName} in {ElapsedMs}ms",
                requestName, sw.ElapsedMilliseconds);

            return response;
        }
        catch (Exception ex)
        {
            sw.Stop();

            _logger.LogError(ex,
                "Failed {RequestName} after {ElapsedMs}ms: {Error}",
                requestName, sw.ElapsedMilliseconds, ex.Message);

            throw;
        }
    }
}

Registration in the DI container ties it all together:

public static class DependencyInjection
{
    public static IServiceCollection AddApplication(
        this IServiceCollection services)
    {
        var assembly = typeof(DependencyInjection).Assembly;

        services.AddMediatR(cfg =>
        {
            cfg.RegisterServicesFromAssembly(assembly);

            // Order matters — behaviors execute top to bottom
            cfg.AddBehavior(
                typeof(IPipelineBehavior<,>),
                typeof(LoggingBehavior<,>));
            cfg.AddBehavior(
                typeof(IPipelineBehavior<,>),
                typeof(AuthorizationBehavior<,>));
            cfg.AddBehavior(
                typeof(IPipelineBehavior<,>),
                typeof(ValidationBehavior<,>));
        });

        services.AddValidatorsFromAssembly(assembly);

        return services;
    }
}

Every request now flows through: Logging -> Authorization -> Validation -> Handler. You write the pipeline once, every command and query benefits automatically. Add a new GenerateQuizCommand next sprint, it gets validation and authorization for free.

MediatR pipeline — request enters, flows through ValidationBehavior, AuthorizationBehavior, LoggingBehavior, then reaches the Handler

The MediatR Licensing Situation

I need to address the elephant in the room. MediatR v12+ moved to a commercial license for certain use cases. If your company has more than $1M in annual revenue, you need a paid license. The library is still free for open-source, non-commercial, and small-company use.

Jimmy Bogard (MediatR’s creator) has every right to monetize his widely-used library. I respect that. But it created an awkward situation for teams who had built their entire application architecture around MediatR, only to discover they now needed to budget for a license. Some teams pinned to v11 (still MIT-licensed). Some paid up. Some started looking for alternatives.

For new projects in 2026, the licensing consideration is real. On a large enterprise project with 200+ developers, you’re likely fine paying for it — the cost is trivial compared to developer salaries. But if you’re a startup or small team, it’s worth evaluating alternatives. Which brings us to Wolverine.

Wolverine: The Modern Alternative

Wolverine, created by Jeremy Miller (the JasperFx maintainer), started as a messaging library and evolved into a full command-bus and mediator. After using it on two production projects over the past year, I think it’s the better choice for new .NET 10 projects. Let me show you why.

The Same Use Case with Wolverine

Here’s our GenerateLessonCommand implemented with Wolverine:

// The command — same simple record
public sealed record GenerateLessonCommand(
    Guid ChildId,
    Guid TopicId,
    Guid RequestedByParentId
);

// The handler — notice: no interface implementation needed
public static class GenerateLessonHandler
{
    public static async Task<Result<LessonDto>> HandleAsync(
        GenerateLessonCommand command,
        IApplicationDbContext db,
        IAdaptiveEngine adaptiveEngine,
        ILessonGenerator lessonGenerator,
        ICurriculumRetriever curriculumRetriever,
        ILogger logger,
        CancellationToken ct)
    {
        var child = await db.Children
            .Include(c => c.LearningProgress)
            .FirstOrDefaultAsync(c => c.Id == command.ChildId, ct);

        if (child is null)
            return Result<LessonDto>.Failure("Child not found.");

        var topic = await db.Topics
            .FirstOrDefaultAsync(t => t.Id == command.TopicId, ct);

        if (topic is null)
            return Result<LessonDto>.Failure("Topic not found.");

        var difficulty = adaptiveEngine
            .CalculateNextDifficulty(child.LearningProgress);

        logger.LogInformation(
            "Generating lesson for child {ChildId} on topic {TopicName} " +
            "at difficulty {Difficulty}",
            child.Id, topic.Name, difficulty.Value);

        var standards = await curriculumRetriever
            .FindAlignedStandardsAsync(topic, ct);

        var lesson = await lessonGenerator
            .GenerateAsync(topic, difficulty, ct);

        lesson.AlignWithStandards(standards);

        db.Lessons.Add(lesson);
        await db.SaveChangesAsync(ct);

        return Result<LessonDto>.Success(lesson.ToDto());
    }
}

Notice a few things:

No interface implementation. The handler is a static class with a method called HandleAsync. Wolverine discovers handlers by convention — it looks for classes that have a Handle or HandleAsync method whose first parameter matches a message type. No IRequestHandler<TCommand, TResponse> boilerplate.

Dependencies are method parameters, not constructor-injected. Wolverine uses source generation to wire up dependencies at compile time. The generated code calls serviceProvider.GetRequiredService<IApplicationDbContext>() and passes it directly. This means handlers can be static — no object allocation per request.

No marker interfaces on the command. GenerateLessonCommand is a plain record. It doesn’t need to implement ICommand<TResponse> or IRequest<TResponse>. Wolverine doesn’t care about marker interfaces.

Wolverine Middleware (Pipeline Behaviors)

Wolverine’s equivalent of MediatR’s pipeline behaviors is middleware, and it works differently. Instead of wrapping behaviors, Wolverine uses a “Russian doll” approach with Before and After methods:

// Validation middleware
public static class FluentValidationMiddleware
{
    public static async Task<ProblemDetails?> BeforeAsync<T>(
        T message,
        IReadOnlyList<IValidator<T>> validators,
        CancellationToken ct)
    {
        if (!validators.Any())
            return null; // continue to handler

        var context = new ValidationContext<T>(message);

        var results = await Task.WhenAll(
            validators.Select(v => v.ValidateAsync(context, ct)));

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

        if (failures.Count != 0)
        {
            return new ProblemDetails
            {
                Status = 400,
                Title = "Validation failed",
                Extensions =
                {
                    ["errors"] = failures
                        .GroupBy(f => f.PropertyName)
                        .ToDictionary(
                            g => g.Key,
                            g => g.Select(f => f.ErrorMessage).ToArray())
                }
            };
        }

        return null; // continue to handler
    }
}

The Before method runs before the handler. If it returns a non-null ProblemDetails, Wolverine short-circuits the pipeline and returns that as the response. If it returns null, execution continues.

Logging is similar:

public static class CommandLoggingMiddleware
{
    public static void Before<T>(
        T message,
        ILogger logger,
        ICurrentUserService currentUser)
    {
        logger.LogInformation(
            "Handling {MessageType} for user {UserId}",
            typeof(T).Name,
            currentUser.UserId ?? "anonymous");
    }

    public static void After<T>(
        T message,
        ILogger logger)
    {
        logger.LogInformation(
            "Completed handling {MessageType}",
            typeof(T).Name);
    }
}

Register middleware in Program.cs:

builder.Host.UseWolverine(opts =>
{
    opts.Discovery.IncludeAssembly(
        typeof(GenerateLessonHandler).Assembly);

    // Apply middleware to all handlers
    opts.Policies.ForAllChains(chain =>
    {
        chain.Middleware.Add(typeof(CommandLoggingMiddleware));
        chain.Middleware.Add(typeof(FluentValidationMiddleware));
    });
});

Source Generation — Why It Matters

Here’s the key technical advantage. MediatR uses reflection at runtime to resolve handlers. Wolverine uses Roslyn source generators at compile time to generate the dispatching code. This means:

  • AOT-friendly. .NET 10’s Native AOT compilation works with Wolverine out of the box. MediatR’s reflection-based approach has limited AOT support.
  • No runtime overhead. The generated code is just method calls. No Activator.CreateInstance, no MethodInfo.Invoke, no Expression.Compile.
  • Better debugging. You can actually step through the generated dispatch code. With MediatR, you’re stepping through generic pipeline behavior resolution.

If you’re curious, you can see the generated code in obj/Debug/net10.0/generated/. It’s readable C# that directly calls your handlers with resolved dependencies.

Built-in Messaging and Outbox

This is where Wolverine really differentiates itself. When a child completes a lesson, we might want to:

  1. Update the database (synchronous)
  2. Notify the parent via email (asynchronous)
  3. Trigger a background job to recalculate recommendations (asynchronous)

With MediatR, you’d need to add MassTransit or a custom background job system. With Wolverine, it’s built in:

public static class CompleteLessonHandler
{
    [Transactional]
    public static async Task<(Result<LessonCompletionDto>, OutgoingMessages)> HandleAsync(
        CompleteLessonCommand command,
        IApplicationDbContext db,
        IAdaptiveEngine adaptiveEngine,
        CancellationToken ct)
    {
        var lesson = await db.Lessons
            .Include(l => l.Child)
            .ThenInclude(c => c.LearningProgress)
            .FirstOrDefaultAsync(l => l.Id == command.LessonId, ct);

        if (lesson is null)
            return (Result<LessonCompletionDto>.Failure("Lesson not found."),
                    new OutgoingMessages());

        // Domain logic
        var result = lesson.Complete(command.Score, command.TimeSpentSeconds);

        if (!result.IsSuccess)
            return (Result<LessonCompletionDto>.Failure(result.Error),
                    new OutgoingMessages());

        // Recalculate difficulty for next lesson
        var nextDifficulty = adaptiveEngine
            .CalculateNextDifficulty(lesson.Child.LearningProgress);

        await db.SaveChangesAsync(ct);

        // Cascade messages — these are sent AFTER the transaction commits
        var messages = new OutgoingMessages();

        messages.Add(new NotifyParentOfCompletion(
            lesson.Child.ParentId,
            lesson.Child.Name,
            lesson.Topic.Name,
            command.Score));

        messages.Add(new RecalculateRecommendations(
            lesson.ChildId,
            nextDifficulty));

        return (Result<LessonCompletionDto>.Success(lesson.ToCompletionDto()),
                messages);
    }
}

The [Transactional] attribute wraps the handler in a database transaction. The OutgoingMessages are only dispatched after the transaction commits. This is the transactional outbox pattern — the messages are persisted alongside the data changes in the same transaction, ensuring consistency. If the database write fails, the messages never send. If the application crashes after the write, the outbox will retry the messages on startup.

With MediatR, you’d need to wire this up yourself using MassTransit’s outbox or a hand-rolled solution. With Wolverine, it’s a first-class feature.

Side-by-Side Comparison

Let me put the code differences side by side so you can see the ergonomic differences:

Command definition:

// MediatR
public sealed record GenerateLessonCommand(
    Guid ChildId, Guid TopicId, Guid RequestedByParentId
) : ICommand<Result<LessonDto>>;

// Wolverine
public sealed record GenerateLessonCommand(
    Guid ChildId, Guid TopicId, Guid RequestedByParentId
);

Handler:

// MediatR — must implement interface, constructor injection
public sealed class GenerateLessonCommandHandler
    : ICommandHandler<GenerateLessonCommand, Result<LessonDto>>
{
    private readonly IApplicationDbContext _db;
    private readonly IAdaptiveEngine _engine;
    // ... more fields, more constructor params

    public async Task<Result<LessonDto>> Handle(
        GenerateLessonCommand request,
        CancellationToken ct) { ... }
}

// Wolverine — static class, method injection
public static class GenerateLessonHandler
{
    public static async Task<Result<LessonDto>> HandleAsync(
        GenerateLessonCommand command,
        IApplicationDbContext db,
        IAdaptiveEngine engine,
        // ... dependencies as parameters
        CancellationToken ct) { ... }
}

Dispatching from an API endpoint:

// MediatR
app.MapPost("/api/lessons/generate", async (
    GenerateLessonCommand command,
    ISender sender) =>
{
    var result = await sender.Send(command);
    return result.IsSuccess
        ? Results.Ok(result.Value)
        : Results.BadRequest(result.Error);
});

// Wolverine
app.MapPostToWolverine<GenerateLessonCommand, Result<LessonDto>>(
    "/api/lessons/generate");

Wolverine’s MapPostToWolverine is opinionated — it handles the HTTP response mapping for you based on the return type. If you want more control, you can still use the explicit IMessageBus approach (Wolverine’s equivalent of ISender).

The Comparison Table

Here’s the practical comparison for making a decision in 2026:

FeatureMediatRWolverine
LicenseCommercial (v12+, >$1M revenue)MIT
AOT SupportLimited (reflection-based)Full (source generators)
Pipeline behaviorsYes (IPipelineBehavior<,>)Yes (middleware: Before/After)
Message busNo (needs MassTransit/NServiceBus)Built-in (RabbitMQ, Azure SB, etc.)
Transactional outboxNo (needs MassTransit outbox)Built-in
Learning curveLow — very simple APIMedium — more concepts
Community & ecosystemHuge — thousands of examplesGrowing — smaller but active
Handler discoveryReflection at startupSource generation at compile time
PerformanceGood (some reflection overhead)Excellent (zero reflection)
Minimal API integrationManualBuilt-in route mapping
Retry policiesManual implementationBuilt-in with configurable policies
DocumentationExcellentGood, improving rapidly
MaturityBattle-tested for yearsProduction-ready, less mileage

My honest recommendation: For new .NET 10 projects, I’d pick Wolverine. The source generation approach aligns with where .NET is heading (AOT, trimming, minimal overhead). The built-in messaging means fewer dependencies when you inevitably need async processing. And the MIT license means you’ll never have to justify a library cost to a finance department.

For existing projects already on MediatR: don’t rewrite. MediatR is fine. The licensing cost is modest for commercial use. The switching cost isn’t worth it unless you need specific Wolverine features like the built-in outbox.

If you’re on the fence: start with Wolverine. If you find the learning curve too steep for your team, fall back to MediatR (pinned to v11 if licensing is a concern, or pay for v12+). Both are solid choices — the worst thing you can do is spend more time debating this than actually building your application.

The Repository Pattern Debate

This one gets people arguing on Twitter every week. Let me present both sides honestly, then tell you what we’re doing for Kids Learn.

The Case Against Repositories

EF Core’s DbContext is already a Repository + Unit of Work. When someone writes this:

public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(Guid id, CancellationToken ct);
    Task<IReadOnlyList<T>> GetAllAsync(CancellationToken ct);
    Task AddAsync(T entity, CancellationToken ct);
    void Update(T entity);
    void Delete(T entity);
}

public class Repository<T> : IRepository<T> where T : class
{
    private readonly ApplicationDbContext _context;

    public async Task<T?> GetByIdAsync(Guid id, CancellationToken ct)
        => await _context.Set<T>().FindAsync([id], ct);

    public async Task<IReadOnlyList<T>> GetAllAsync(CancellationToken ct)
        => await _context.Set<T>().ToListAsync(ct);

    // ... more thin wrappers
}

They’ve created a leaky abstraction that:

  • Hides EF Core’s powerful LINQ capabilities behind an anemic interface
  • Forces you to add methods like GetByIdWithChildrenAndGrandchildrenAsync for every query variation
  • Doesn’t actually abstract the ORM — your application layer still thinks in terms of entities and change tracking
  • Creates a layer of indirection that adds complexity without adding value

The argument: just inject DbContext directly and use it. It’s already a well-designed abstraction.

The Case For Repositories

The counter-argument:

  • Testability. Mocking IRepository<Lesson> is trivial. Mocking DbContext with its DbSet<T>, change tracker, and LINQ provider is a nightmare (even with InMemoryDatabase, which behaves differently from real providers).
  • Portability. If you switch from EF Core to Dapper or a different ORM (it happens), your Application layer doesn’t change.
  • Encapsulation. Application layer developers can’t accidentally write a context.Database.ExecuteSqlRaw("DELETE FROM Lessons"). The repository constrains what operations are possible.
  • Query reuse. Common queries live in one place instead of being scattered across handlers.

The Pragmatic Middle Ground

Both sides have valid points. Here’s what we do for Kids Learn, and what I’ve done on the last three production projects:

Use IApplicationDbContext for commands. Use read-optimized queries for complex reads.

// Defined in Application layer
public interface IApplicationDbContext
{
    DbSet<Child> Children { get; }
    DbSet<Lesson> Lessons { get; }
    DbSet<Topic> Topics { get; }
    DbSet<LearningProgress> LearningProgress { get; }
    DbSet<CurriculumStandard> CurriculumStandards { get; }

    Task<int> SaveChangesAsync(CancellationToken ct);
}

This isn’t a generic repository. It’s a specific interface that exposes the DbSets we need. Command handlers use it to load aggregates, modify them, and save. It’s easy to mock for testing — just mock the individual DbSets you need.

For complex queries — the progress dashboard, analytics, recommendation feeds — we use a different approach:

// Also in Application layer
public interface IChildProgressReadService
{
    Task<ChildProgressDto> GetProgressAsync(
        Guid childId,
        DateRange period,
        CancellationToken ct);

    Task<IReadOnlyList<TopicMasteryDto>> GetMasteryBreakdownAsync(
        Guid childId,
        CancellationToken ct);
}

The implementation (in Infrastructure) might use Dapper, raw SQL, or even a different database entirely. The Application layer doesn’t know or care. The query handler just calls the read service:

public sealed class GetChildProgressQueryHandler
    : IQueryHandler<GetChildProgressQuery, ChildProgressDto?>
{
    private readonly IChildProgressReadService _readService;

    public GetChildProgressQueryHandler(
        IChildProgressReadService readService)
    {
        _readService = readService;
    }

    public async Task<ChildProgressDto?> Handle(
        GetChildProgressQuery request,
        CancellationToken ct)
    {
        return await _readService.GetProgressAsync(
            request.ChildId,
            request.Period,
            ct);
    }
}

This gives us the best of both worlds:

  • Commands work through IApplicationDbContext with full EF Core power — change tracking, navigation properties, transactions
  • Complex queries bypass EF Core entirely when performance demands it — hand-tuned SQL, projection-only, no entity materialization
  • Both are abstracted behind interfaces defined in the Application layer

Is this the “correct” approach? There’s no such thing. It’s the approach that works for our codebase size (medium), our team skill level (experienced with EF Core), and our performance requirements (sub-100ms API responses for queries). Your project might warrant something different.

The important thing is: don’t use a generic IRepository<T>. It almost always creates more problems than it solves. Either use IApplicationDbContext (explicit about what you’re exposing) or dedicated read/write services per aggregate (more ceremony, better encapsulation).

Defining Ports (Interfaces)

The Application layer defines several ports that Infrastructure will implement. These are the contracts — “I need something that can generate a lesson, and I don’t care how you do it.”

Here are the key interfaces for Kids Learn:

/// <summary>
/// Generates adaptive lesson content based on topic and difficulty.
/// Infrastructure implementation calls an AI provider (OpenAI, Anthropic, etc.)
/// </summary>
public interface ILessonGenerator
{
    Task<Lesson> GenerateAsync(
        Topic topic,
        DifficultyScore difficulty,
        CancellationToken ct);

    Task<Lesson> RegenerateAsync(
        Lesson existingLesson,
        string feedback,
        CancellationToken ct);
}
/// <summary>
/// Retrieves curriculum standards that align with a given topic.
/// Infrastructure implementation queries an external curriculum database
/// or API (e.g., Common Core standards, national curriculum frameworks).
/// </summary>
public interface ICurriculumRetriever
{
    Task<IReadOnlyList<CurriculumStandard>> FindAlignedStandardsAsync(
        Topic topic,
        CancellationToken ct);

    Task<IReadOnlyList<CurriculumStandard>> FindByGradeLevelAsync(
        GradeLevel gradeLevel,
        CancellationToken ct);
}
/// <summary>
/// The adaptive learning engine that determines optimal difficulty
/// and identifies knowledge gaps. Infrastructure implementation uses
/// an ML model or rule-based engine.
/// </summary>
public interface IAdaptiveEngine
{
    DifficultyScore CalculateNextDifficulty(LearningProgress progress);

    IReadOnlyList<KnowledgeGap> IdentifyGaps(LearningProgress progress);

    Topic RecommendNextTopic(
        LearningProgress progress,
        IReadOnlyList<Topic> availableTopics);
}
/// <summary>
/// Provides access to the current authenticated user's information.
/// Infrastructure implementation reads from HttpContext or JWT claims.
/// </summary>
public interface ICurrentUserService
{
    string? UserId { get; }
    string? UserName { get; }
    bool IsAuthenticated { get; }

    Task<bool> AuthorizeAsync(string policy);
    IReadOnlyList<string> GetRoles();
}
/// <summary>
/// Sends notifications to parents about their children's progress.
/// Infrastructure implementation uses email, push notifications, SMS, etc.
/// </summary>
public interface INotificationService
{
    Task NotifyLessonCompletedAsync(
        Guid parentId,
        string childName,
        string topicName,
        int score,
        CancellationToken ct);

    Task NotifyMilestoneReachedAsync(
        Guid parentId,
        string childName,
        string milestoneName,
        CancellationToken ct);
}

And the database context interface we already discussed:

/// <summary>
/// Application-level database abstraction. Exposes only the DbSets
/// the Application layer needs. Implemented by Infrastructure's
/// concrete DbContext.
/// </summary>
public interface IApplicationDbContext
{
    DbSet<Child> Children { get; }
    DbSet<Parent> Parents { get; }
    DbSet<Lesson> Lessons { get; }
    DbSet<Topic> Topics { get; }
    DbSet<LearningProgress> LearningProgress { get; }
    DbSet<CurriculumStandard> CurriculumStandards { get; }
    DbSet<LessonFeedback> LessonFeedback { get; }

    Task<int> SaveChangesAsync(CancellationToken ct);
}

A few design principles to notice:

Interfaces return domain types, not DTOs. ILessonGenerator.GenerateAsync returns a Lesson entity, not a LessonDto. The Application layer works with domain objects. Mapping to DTOs happens at the boundary (in the handler, right before returning).

CancellationToken everywhere. Every async method accepts a CancellationToken. If a user navigates away mid-request, we cancel the AI generation call instead of wasting money on a lesson nobody will see.

Interfaces are specific, not generic. We don’t have an IExternalService<T> or IProvider<T>. Each interface describes a specific capability. This makes implementations straightforward and testing obvious.

XML docs on interfaces. Since these are the contracts that Infrastructure developers will implement, we document what each interface is expected to do and hint at how it might be implemented. This is one of the rare cases where XML docs genuinely help — six months from now, when someone needs to implement ICurriculumRetriever for a new country’s standards, they’ll understand the intent.

Application layer ports — ILessonGenerator, ICurriculumRetriever, IAdaptiveEngine, IApplicationDbContext defined in Application, implemented in Infrastructure

Putting It All Together

Let’s trace a complete request through the Application layer to make sure everything clicks.

Scenario: A parent taps “Generate Lesson” for their child on the topic “Fractions.”

  1. Minimal API endpoint receives the HTTP POST and deserializes it into a GenerateLessonCommand
  2. The command is dispatched via MediatR’s ISender.Send() (or Wolverine’s IMessageBus.InvokeAsync())
  3. LoggingBehavior logs “Handling GenerateLessonCommand for user parent-123”
  4. AuthorizationBehavior checks the user is authenticated and has the “Parent” role
  5. ValidationBehavior runs GenerateLessonCommandValidator:
    • Verifies ChildId and TopicId are not empty
    • Verifies the parent owns this child’s account
    • Verifies the topic exists
  6. GenerateLessonCommandHandler runs:
    • Loads the child with learning progress from IApplicationDbContext
    • Calls IAdaptiveEngine.CalculateNextDifficulty() to determine the right level
    • Calls ICurriculumRetriever.FindAlignedStandardsAsync() to get relevant standards
    • Calls ILessonGenerator.GenerateAsync() to create AI-powered content
    • Calls lesson.AlignWithStandards() (domain method) to associate standards
    • Saves via IApplicationDbContext.SaveChangesAsync()
    • Returns Result<LessonDto>.Success()
  7. LoggingBehavior logs “Handled GenerateLessonCommand in 1,247ms”
  8. Minimal API endpoint maps the result to an HTTP 200 with the lesson DTO

The Application layer orchestrated six different operations across three external systems (AI, curriculum database, application database) without knowing how any of them work. Every dependency is an interface. Every cross-cutting concern is handled by the pipeline. The handler is focused purely on the business workflow.

That’s the Application layer doing its job.

Common Mistakes I See

Before we wrap up, let me share the mistakes I see most often when teams build their Application layer:

1. Fat handlers. The handler starts at 30 lines and grows to 300. If your handler is doing complex business logic, that logic belongs in the Domain layer. The handler should orchestrate, not implement.

2. Anemic handlers. The opposite — handlers that just call _repository.Save(entity). If your handler is one line, you probably don’t need CQRS for that use case. A simple service method is fine.

3. Calling handlers from handlers. Using _mediator.Send(new OtherCommand()) inside a handler. This creates hidden dependencies and makes the flow impossible to trace. If two use cases share logic, extract it into a domain service.

4. Returning entities from queries. Query handlers should return DTOs, not domain entities. Entities have behavior, change tracking, and navigation properties. DTOs are just data. Returning entities from queries leaks domain internals and causes serialization problems.

5. Missing CancellationToken propagation. Every async method should accept and pass through the CancellationToken. I’ve seen handlers that call three external services without cancellation support — meaning if the HTTP request is cancelled, those calls still complete and waste resources.

6. Putting infrastructure logic in handlers. “Let me just call HttpClient directly in this one handler.” No. Define an interface. The handler uses the interface. Infrastructure implements it. Yes, even if it feels like overkill for one call. You’ll thank yourself when you need to test it.

What’s Next

We’ve built the Application layer — use cases, CQRS, pipeline behaviors, ports. But none of those interfaces have implementations yet. ILessonGenerator, IApplicationDbContext, IAdaptiveEngine — they’re all promises with no fulfillment.

In Part 4, we’re going to the Infrastructure layer. EF Core 10 with the new HybridCache, implementing our ports, Minimal API endpoints, and the question every team asks: “How much should the Infrastructure layer know about the Domain?”

We’ll also tackle .NET 10 specific features that make the Infrastructure layer cleaner than it’s ever been: IExceptionHandler middleware, the built-in HybridCache, and OpenTelemetry integration that gives you distributed tracing across your entire pipeline.

See you there.


This is Part 3 of a 7-part series on Clean Architecture with .NET 10. Navigate the full series:

Export for reading

Comments