BrandGhost
Feature Slicing Without MediatR in C#: Plain Handlers That Actually Work

Feature Slicing Without MediatR in C#: Plain Handlers That Actually Work

Feature Slicing Without MediatR in C#: Plain Handlers That Actually Work

Every tutorial on feature slicing in C# reaches the same point: "And here you would use MediatR to dispatch your commands." Then it adds a NuGet package, an IRequest<T> interface, a CommandHandler : IRequestHandler<Command, Result>, a registration step, and a pipeline behavior.

For many teams, this is too much. MediatR introduced a commercial license requirement for business use starting with version 12 (check the MediatR repository for current licensing terms, as policies can change). The indirection makes stack traces harder to read. The abstraction adds concepts your team needs to learn and maintain.

Feature slicing in C# does not require MediatR. The organizational benefits -- one folder per feature, self-contained use cases, clear ownership -- come from the folder structure, not from any dispatch library. This article shows you how to build feature slices with nothing but plain C# classes and ASP.NET Core Minimal APIs.

What MediatR Actually Does (and What You Replace It With)

MediatR solves two problems in a feature-sliced application:

  1. Handler discovery -- given a command object, find the handler class for it
  2. Pipeline behaviors -- inject cross-cutting concerns (logging, validation, caching) into the dispatch path

Without MediatR, you solve these differently:

MediatR mechanism Plain C# replacement
IRequest<T> + IRequestHandler<TRequest, TResult> Direct handler class injected by DI
mediator.Send(command) handler.HandleAsync(request)
IPipelineBehavior<TRequest, TResult> Decorator pattern or middleware
Handler registration via assembly scan services.AddScoped<CreateTaskHandler>()

The tradeoff is clear: you write slightly more explicit code in exchange for less abstraction, a simpler dependency graph, and no licensing concern.

Building a Feature Without MediatR

Let us build a complete task management feature using direct handler dispatch. The domain is a task tracker where users can create, complete, and retrieve tasks.

The Handler Pattern

Each feature gets a handler class with a HandleAsync method. No base class, no interface required:

// Features/Tasks/CreateTask/CreateTaskHandler.cs
namespace TaskTracker.Features.Tasks.CreateTask;

public sealed class CreateTaskHandler
{
    private readonly AppDbContext _db;
    private readonly TimeProvider _time;

    public CreateTaskHandler(AppDbContext db, TimeProvider time)
    {
        _db = db;
        _time = time;
    }

    public async Task<CreateTaskResponse> HandleAsync(
        CreateTaskRequest request,
        CancellationToken cancellationToken = default)
    {
        var task = new TaskEntity
        {
            Id = Guid.NewGuid(),
            Title = request.Title.Trim(),
            Description = request.Description?.Trim(),
            ProjectId = request.ProjectId,
            CreatedAt = _time.GetUtcNow(),
            IsCompleted = false
        };

        _db.Tasks.Add(task);
        await _db.SaveChangesAsync(cancellationToken);

        return new CreateTaskResponse(task.Id, task.Title, task.CreatedAt);
    }
}

The handler receives its dependencies through the constructor. No IMediator in sight. The test you need to write for this handler is straightforward: construct the handler with a real or in-memory database context and a fake TimeProvider, call HandleAsync, assert the result.

The Endpoint

The endpoint wires the HTTP route directly to the handler:

// Features/Tasks/CreateTask/CreateTaskEndpoint.cs
namespace TaskTracker.Features.Tasks.CreateTask;

public static class CreateTaskEndpoint
{
    public static void Map(IEndpointRouteBuilder routes)
    {
        routes.MapPost("/tasks", async (
            CreateTaskRequest request,
            CreateTaskHandler handler,
            CancellationToken cancellationToken) =>
        {
            var response = await handler.HandleAsync(request, cancellationToken);
            return Results.Created($"/tasks/{response.TaskId}", response);
        })
        .WithName("CreateTask")
        .WithTags("Tasks")
        .Produces<CreateTaskResponse>(StatusCodes.Status201Created)
        .ProducesValidationProblem();
    }
}

ASP.NET Core's Minimal API DI integration resolves CreateTaskHandler from the container automatically. No dispatcher, no pipeline -- the request flows directly from the HTTP binding to your handler.

A State-Changing Handler: CompleteTask

Here is a second handler that illustrates a more complex case -- updating existing state and handling not-found scenarios:

// Features/Tasks/CompleteTask/CompleteTaskHandler.cs
namespace TaskTracker.Features.Tasks.CompleteTask;

public sealed record CompleteTaskResult(bool Found, bool AlreadyCompleted = false);

public sealed class CompleteTaskHandler
{
    private readonly AppDbContext _db;
    private readonly TimeProvider _time;

    public CompleteTaskHandler(AppDbContext db, TimeProvider time)
    {
        _db = db;
        _time = time;
    }

    public async Task<CompleteTaskResult> HandleAsync(
        Guid taskId,
        CancellationToken cancellationToken = default)
    {
        var task = await _db.Tasks.FindAsync([taskId], cancellationToken);

        if (task is null)
        {
            return new CompleteTaskResult(Found: false);
        }

        if (task.IsCompleted)
        {
            return new CompleteTaskResult(Found: true, AlreadyCompleted: true);
        }

        task.IsCompleted = true;
        task.CompletedAt = _time.GetUtcNow();

        await _db.SaveChangesAsync(cancellationToken);

        return new CompleteTaskResult(Found: true);
    }
}
// Features/Tasks/CompleteTask/CompleteTaskEndpoint.cs
namespace TaskTracker.Features.Tasks.CompleteTask;

public static class CompleteTaskEndpoint
{
    public static void Map(IEndpointRouteBuilder routes)
    {
        routes.MapPost("/tasks/{taskId:guid}/complete", async (
            Guid taskId,
            CompleteTaskHandler handler,
            CancellationToken cancellationToken) =>
        {
            var result = await handler.HandleAsync(taskId, cancellationToken);

            return result switch
            {
                { Found: false } => Results.NotFound(),
                { AlreadyCompleted: true } => Results.Conflict("Task is already completed."),
                _ => Results.NoContent()
            };
        })
        .WithName("CompleteTask")
        .WithTags("Tasks");
    }
}

The handler returns a discriminated result type instead of throwing exceptions for expected business states. The endpoint translates that result to the appropriate HTTP response. Logic and transport are clearly separated.

A Query Handler: GetTasks

Queries follow the same pattern as commands -- a handler class, a request type, a response type:

// Features/Tasks/GetTasks/GetTasksHandler.cs
namespace TaskTracker.Features.Tasks.GetTasks;

public sealed class GetTasksHandler
{
    private readonly AppDbContext _db;

    public GetTasksHandler(AppDbContext db)
    {
        _db = db;
    }

    public async Task<IReadOnlyList<GetTasksResponse>> HandleAsync(
        GetTasksQuery query,
        CancellationToken cancellationToken = default)
    {
        var tasksQuery = _db.Tasks
            .Where(t => t.ProjectId == query.ProjectId)
            .AsQueryable();

        if (query.IsCompleted.HasValue)
        {
            tasksQuery = tasksQuery.Where(t => t.IsCompleted == query.IsCompleted.Value);
        }

        return await tasksQuery
            .OrderByDescending(t => t.CreatedAt)
            .Select(t => new GetTasksResponse(t.Id, t.Title, t.IsCompleted, t.CreatedAt))
            .ToListAsync(cancellationToken);
    }
}
// Features/Tasks/GetTasks/GetTasksQuery.cs
namespace TaskTracker.Features.Tasks.GetTasks;

public sealed record GetTasksQuery(Guid ProjectId, bool? IsCompleted = null);

public sealed record GetTasksResponse(
    Guid Id,
    string Title,
    bool IsCompleted,
    DateTimeOffset CreatedAt);

Handling Cross-Cutting Concerns Without a Pipeline

With MediatR, cross-cutting concerns like logging, validation, and performance measurement plug into the pipeline as IPipelineBehavior<TRequest, TResponse> implementations. Without MediatR, you have three clean alternatives:

Option 1: Decorator Pattern

Wrap a handler with a decorator that adds behavior. Use composition -- not inheritance from the concrete handler. Inheriting from a concrete class and using public new method hiding is dangerous: if the wrapper is stored or injected as the base type, the added behavior is silently bypassed.

Instead, write a standalone wrapper class that holds the inner handler as a dependency and delegates to it:

// Shared/Decorators/LoggingCreateTaskHandler.cs
namespace TaskTracker.Shared.Decorators;

// Wraps CreateTaskHandler with logging via composition.
// The endpoint receives LoggingCreateTaskHandler directly via DI.
public sealed class LoggingCreateTaskHandler
{
    private readonly CreateTaskHandler _inner;
    private readonly ILogger<LoggingCreateTaskHandler> _logger;

    public LoggingCreateTaskHandler(
        CreateTaskHandler inner,
        ILogger<LoggingCreateTaskHandler> logger)
    {
        _inner = inner;
        _logger = logger;
    }

    public async Task<CreateTaskResponse> HandleAsync(
        CreateTaskRequest request,
        CancellationToken cancellationToken = default)
    {
        _logger.LogInformation("Creating task: {Title}", request.Title);
        var stopwatch = Stopwatch.StartNew();

        var response = await _inner.HandleAsync(request, cancellationToken);

        _logger.LogInformation("Task created in {ElapsedMs}ms: {TaskId}",
            stopwatch.ElapsedMilliseconds, response.TaskId);

        return response;
    }
}

Register both classes in DI, and update the endpoint to receive LoggingCreateTaskHandler:

// Program.cs
builder.Services.AddScoped<CreateTaskHandler>();
builder.Services.AddScoped<LoggingCreateTaskHandler>();

If you need polymorphism (so that the decorator and the real handler are interchangeable), define a minimal interface like ICreateTaskHandler and have both implement it. For the "no interfaces required" approach, the endpoint simply asks for LoggingCreateTaskHandler directly.

For a full-featured decorator approach, Scrutor supports open generic decorators that work similarly to MediatR pipeline behaviors.

Option 2: Middleware

For concerns that apply to all HTTP requests (authentication, exception handling, request logging), ASP.NET Core middleware is the right place -- not the handler pipeline. This is what middleware is designed for:

// Program.cs
app.UseMiddleware<RequestLoggingMiddleware>();
app.UseMiddleware<GlobalExceptionHandlerMiddleware>();

Cross-cutting concerns that truly apply to all requests belong in middleware. Only concerns that need feature-specific context belong closer to the handler. Use decorators or endpoint filters for feature-level concerns like input validation, idempotency checks, or domain-scoped logging -- middleware is too broad for these.

Option 3: Validation in the Handler or Endpoint

Input validation can live directly in the endpoint before calling the handler:

routes.MapPost("/tasks", async (
    CreateTaskRequest request,
    CreateTaskHandler handler,
    CreateTaskValidator validator,
    CancellationToken cancellationToken) =>
{
    var validationResult = validator.Validate(request);
    if (!validationResult.IsValid)
    {
        return Results.ValidationProblem(validationResult.ToDictionary());
    }

    var response = await handler.HandleAsync(request, cancellationToken);
    return Results.Created($"/tasks/{response.TaskId}", response);
});

Or use ASP.NET Core's built-in endpoint filter to apply validation as a reusable filter:

// Shared/Filters/ValidationFilter.cs
namespace TaskTracker.Shared.Filters;

// IValidator<T> is from FluentValidation (NuGet: FluentValidation.DependencyInjectionExtensions).
// Register validators with: builder.Services.AddValidatorsFromAssemblyContaining<Program>();
public sealed class ValidationFilter<TRequest> : IEndpointFilter
{
    private readonly IValidator<TRequest> _validator;

    public ValidationFilter(IValidator<TRequest> validator)
    {
        _validator = validator;
    }

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var request = context.Arguments.OfType<TRequest>().FirstOrDefault();

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

        return await next(context);
    }
}

Then apply it to the endpoint:

routes.MapPost("/tasks", async (CreateTaskRequest request, CreateTaskHandler handler, ...) =>
{
    // ...
})
.AddEndpointFilter<ValidationFilter<CreateTaskRequest>>();

Registration in Program.cs

With direct handler dispatch, registration is explicit and visible. For small-to-medium projects, explicit registration is fine:

// Program.cs
builder.Services.AddScoped<CreateTaskHandler>();
builder.Services.AddScoped<CompleteTaskHandler>();
builder.Services.AddScoped<GetTasksHandler>();
builder.Services.AddScoped<CreateProjectHandler>();
builder.Services.AddScoped<GetProjectsHandler>();

For larger projects, convention-based scanning keeps Program.cs short:

// Using Scrutor
builder.Services.Scan(scan => scan
    .FromAssemblyOf<Program>()
    .AddClasses(classes => classes.Where(t => t.Name.EndsWith("Handler")))
    .AsSelf()
    .WithScopedLifetime());

This gives you the same auto-discovery benefit as MediatR handler scanning without the package dependency.

When MediatR Is Still Worth It

Being clear: MediatR is not inherently bad. There are scenarios where it earns its place:

  • Complex pipeline behaviors that need to run on every command with different logic per type -- MediatR's generic IPipelineBehavior<TRequest, TResponse> handles this cleanly
  • Large teams where the behavioral contract (IRequest<T> / IRequestHandler<TRequest, TResult>) reduces disagreements about handler structure
  • Existing codebases where MediatR is already in use and the overhead of removing it outweighs the benefits

The point is not that MediatR is wrong -- it is that feature slicing does not depend on it. The organizational benefits are structural, not dispatcher-dependent. For a comparison of how these approaches connect to clean architecture, CQRS Pattern in C# and Clean Architecture provides useful context.

If you are starting a new project and want CQRS patterns without the dispatch framework, the Unit of Work pattern in C# for Clean Architecture is another pattern worth understanding as a complement to direct handlers.

The MediatR licensing change in v12 has pushed many teams to evaluate alternatives. For teams starting fresh, direct handler dispatch in a feature-sliced structure gives you most of what MediatR delivers with significantly fewer moving parts.

Frequently Asked Questions

Does feature slicing require MediatR in C#?

No. Feature slicing is a code organization approach based on folder structure and cohesion. MediatR is a dispatch library that some teams use alongside feature slicing for its pipeline behavior support. The organizational benefits of feature slicing are independent of any library choice.

What changed with MediatR licensing?

MediatR version 12 introduced a commercial license requirement for business use. This prompted many teams to evaluate whether the library's benefits justify the licensing overhead. Direct handler dispatch is the most common alternative for teams that want feature-sliced organization without the package dependency.

How do I handle cross-cutting concerns without MediatR's IPipelineBehavior?

Three options: the decorator pattern (wrap handlers with additional behavior), ASP.NET Core middleware (for request-level concerns like authentication and exception handling), or endpoint filters (for endpoint-specific concerns like input validation). Each addresses a different scope and is more targeted than a generic pipeline behavior.

Is direct handler dispatch harder to test than MediatR handlers?

No -- it is often easier. With direct dispatch, you instantiate the handler class in your test and call HandleAsync directly. There is no mediator to mock or configure. The handler's dependencies are its constructor parameters, which you supply in the test. This makes test setup simpler and test intent clearer.

How do you keep Program.cs from getting too long with direct registration?

For small projects, explicit registration is fine and readable. For larger projects, use convention scanning (Scrutor or custom reflection) to automatically register all handler classes by naming convention. This gives you one line of registration for the whole project: all types whose names end in Handler are registered as scoped services.

Can I still use CQRS patterns without MediatR?

Yes. CQRS is the design pattern of separating reads and writes. MediatR is a mechanism that makes CQRS dispatch convenient. You can have CreateTaskHandler (command) and GetTasksHandler (query) as separate classes in separate folders, following the CQRS separation, without any dispatch framework. The naming and folder structure carry the CQRS intent.

What about the Unit of Work pattern -- does it work with direct handlers?

Yes. If you need Unit of Work semantics, inject the AppDbContext and call SaveChangesAsync at the end of the handler as a single unit. For cross-feature transactions, pass the same DbContext instance (via DI scoping) to multiple handlers within a single request scope. The Unit of Work pattern in C# article covers how this fits into a clean architecture context.

Feature Slice Folder Structure in .NET: Organizing a Real Project

Learn how to design a feature slice folder structure in .NET with real-world examples, structure decisions, and guidance on handling shared code and nested features.

C# Clean Architecture with MediatR: How To Build For Flexibility

Explore the integration of C# Clean Architecture with MediatR for maintainable, scalable code! Learn how these work together with C# code examples!

Feature Slicing in C#: Organizing Code by Feature

Learn what feature slicing is in C# and how to organize your .NET projects by feature instead of by layer. Practical examples using a task app, no MediatR required.

An error has occurred. This application may no longer respond until reloaded. Reload