DEV Community

Ali Suleyman TOPUZ
Ali Suleyman TOPUZ

Posted on Originally published at topuzas.Medium on

I Spent Two Weeks Trying to Break CommandFlow, the “Pragmatic Alternative” to CQRS and MediatR

A hands-on investigation into whether CommandFlow actually solves the problems it claims to solve, with real code, real comparison numbers, and the cracks I found along the way

I got interested in this the way most .NET developers did in the last year: MediatR went commercial in July 2025, and suddenly a library that half the industry had wired into their Clean Architecture templates without a second thought needed a licensing conversation. I went looking for what people were replacing it with, and I kept running into a three-part Medium series by Florian Necula called CommandFlow, pitched as a pragmatic alternative to CQRS and MediatR together.

The pitch is a good one. No framework dependency, no reflection, no assembly scanning, compile-time safety, built-in exception handling, and free replay-based debugging as a side effect of how commands are structured. If even half of that holds up, it is worth stealing for real projects.

So instead of writing another “here’s a summary of that article” post, I rebuilt the pattern from scratch in my own domain, pushed on the parts that felt too good to be true, and read through the surrounding .NET ecosystem to see how CommandFlow’s claims hold up against what’s actually happening with MediatR, CQRS, and plain old service classes in 2026. This is what I found, including the parts I think the original series undersells or gets away with a little too easily.

Why this question even matters right now

If you weren’t paying attention to .NET licensing news, here’s the short version. On July 2, 2025, Jimmy Bogard moved MediatR and AutoMapper to a new company, Lucky Penny Software, and switched the licensing model starting with MediatR 13.0. New versions ship under a dual license: the Reciprocal Public License 1.5, which carries copyleft obligations that can force you to open-source a network service built on top of it, or a paid commercial license with three tiers based on team size (Standard for 1 to 10 developers, Professional for 11 to 50, Enterprise for unlimited). There is a free Community edition for companies under five million dollars in annual revenue that haven’t raised more than ten million in outside capital, plus non-profits, education, and non-production use. Anything on MediatR 12.x and earlier stays under the old Apache 2.0 license forever, but it also stops getting security patches.

None of this breaks your app overnight. The license check only logs a warning; there’s no kill switch. But it forces a decision that a lot of teams had been avoiding: do we actually need a mediator, or did we install one because every Clean Architecture template on GitHub had it wired in by default?

That question is bigger than licensing. CQRS itself has been misapplied for years. Command Query Responsibility Segregation, in its full form, means separate read and write models, potentially separate databases, eventual consistency, and sometimes entirely separate services. Most teams that say they “do CQRS” actually mean they split their code into Command and Query classes with a Command Handler each, which is really just CQS (Command Query Separation) wearing a bigger acronym. That’s the exact gap CommandFlow tries to fill: keep the part of the pattern that’s genuinely useful (the handler-per-operation structure) and drop the ceremony that most applications never needed (the read/write split, the mediator dependency, the request/response DTO pairs).

What CommandFlow actually is, stripped to the studs

CommandFlow rests on three classes, and once you understand how they relate to each other, the rest of the pattern is just consequences.

Command is a class that bundles four things that normally live in four different places: the input data, the output data, the dependencies the operation needs, and the execution context (who’s calling, when, with what correlation ID). It’s partially mutable: inputs come in as init-only properties, outputs get written by the handler as plain mutable properties.

CommandHandler is where the business logic lives. It has a parameterless constructor and exactly one method: Execute(TCommand cmd), returning Task. No constructor injection, no return type, no try-catch.

GatewayService is the thing that replaces MediatR. It has one generic method, Execute(TCommand cmd), which instantiates the handler, wires up dependencies through a shared registry, runs the handler inside a decorator chain that catches exceptions and logs everything, and hands control back to the caller.

That’s it. No IRequest, no separate response classes, no reflection-based handler discovery.

Here’s the shape from the original series, using their article-management example:

public class CreateArticleCommand : ArticleCommandBase
{
    public IArticleRepo ArticleRepo => Hub.ArticleRepo;
    public IEmailService EmailService => Hub.EmailService;
    public required ArticleModel Article { get; init; }
    public int CreatedArticleId { get; set; }
}
public class CreateArticleHandler : ICommandHandler<CreateArticleCommand>
{
    public async Task Execute(CreateArticleCommand cmd)
    {
        var article = cmd.Article.ToEntity();
        if (ArticleUtils.HasWrongContent(article.Body))
            cmd.Raise("Article body contains forbidden words");
        cmd.Validate(article);
        cmd.ArticleRepo.Add(article);
        await cmd.ArticleRepo.SaveChanges();
        cmd.CreatedArticleId = article.ArticleId;
    }
}
Enter fullscreen mode Exit fullscreen mode

Compare that to a MediatR handler doing the same job, and you’ll immediately see what’s missing: no IRequest interface, no separate response record, no constructor with injected repositories, no CancellationToken parameter cluttering the signature. All of that state lives on the command itself.

I wanted to know if that held up outside a toy article-blog example, so I rebuilt it against an order-processing domain, which has more edge cases (partial failures, external payment calls, multiple downstream effects) than a blog post creation flow.

Building it myself: an order-processing example

I kept the three-layer command hierarchy exactly as described (a reusable CommandBase, an app-specific base that carries a dependency hub, and a concrete command per operation), but wired it through a domain I actually care about testing: placing an order that needs inventory checked, a payment charged, and a confirmation email sent.

// CommandBase.cs: reusable across any application
public abstract class CommandBase
{
    public Guid CommandId { get; } = Guid.NewGuid();
    public DateTime CurrentDateTime { get; } = DateTime.UtcNow;
    public int UserId { get; set; }
    public string? UserEmail { get; set; }
    public CancellationToken CancellationToken { get; set; }
    public string? ErrorMessage { get; set; }
    public string? ErrorDetail { get; set; }
    public List<string> Warnings { get; } = new();
    public bool OK => ErrorMessage is null;
    public bool HasWarnings => Warnings.Count > 0;
    public void AddWarning(string message) => Warnings.Add(message);
    [DoesNotReturn]
    public void Raise(string errorMessage)
    {
        ErrorMessage = errorMessage;
        throw new CommandSilentException(errorMessage);
    }
    public void Validate(object entity)
    {
        var context = new ValidationContext(entity);
        var results = new List<ValidationResult>();
        if (!Validator.TryValidateObject(entity, context, results, validateAllProperties: true))
            Raise(string.Join("; ", results.Select(r => r.ErrorMessage)));
    }
}
public sealed class CommandSilentException(string message) : Exception(message);

// OrderCommandBase.cs: application-specific base
public abstract class OrderCommandBase : CommandBase
{
    internal ProviderHub Hub { get; private set; } = null!;
    public void SetHub(ProviderHub hub) => Hub = hub;
}
public sealed class ProviderHub
{
    public required IOrderRepository OrderRepo { get; init; }
    public required IInventoryService Inventory { get; init; }
    public required IPaymentGateway Payments { get; init; }
    public required IEmailService Email { get; init; }
    public required ILogger Logger { get; init; }
}

// PlaceOrderCommand.cs
public sealed class PlaceOrderCommand : OrderCommandBase
{
    public IOrderRepository OrderRepo => Hub.OrderRepo;
    public IInventoryService Inventory => Hub.Inventory;
    public IPaymentGateway Payments => Hub.Payments;
    public IEmailService Email => Hub.Email;
    public required int CustomerId { get; init; }
    public required List<OrderLine> Lines { get; init; }
    public required string PaymentToken { get; init; }
    public int CreatedOrderId { get; set; }
    public decimal ChargedAmount { get; set; }
}

// PlaceOrderHandler.cs: the actual business logic
public sealed class PlaceOrderHandler : ICommandHandler<PlaceOrderCommand>
{
    public async Task Execute(PlaceOrderCommand cmd)
    {
        if (cmd.Lines.Count == 0)
            cmd.Raise("Order must contain at least one line item");
        foreach (var line in cmd.Lines)
        {
            var available = await cmd.Inventory.CheckStock(line.ProductId, cmd.CancellationToken);
            if (available < line.Quantity)
                cmd.Raise($"Product {line.ProductId} is out of stock");
        }
        var total = cmd.Lines.Sum(l => l.UnitPrice * l.Quantity);
        var chargeResult = await cmd.Payments.Charge(cmd.PaymentToken, total, cmd.CancellationToken);
        if (!chargeResult.Success)
            cmd.Raise($"Payment declined: {chargeResult.DeclineReason}");
        cmd.ChargedAmount = total;
        var order = new Order
        {
            CustomerId = cmd.CustomerId,
            Lines = cmd.Lines,
            Total = total,
            PlacedAt = cmd.CurrentDateTime
        };
        await cmd.OrderRepo.Add(order, cmd.CancellationToken);
        cmd.CreatedOrderId = order.Id;
        if (total > 1000m)
            cmd.AddWarning("High-value order flagged for manual review");
        await cmd.Email.SendOrderConfirmation(cmd.CustomerId, order.Id, cmd.CancellationToken);
    }
}
Enter fullscreen mode Exit fullscreen mode

The controller, for the record, really does come out to three lines:

[HttpPost("orders")]
public async Task<IActionResult> PlaceOrder(PlaceOrderRequest request)
{
    var cmd = new PlaceOrderCommand
    {
        CustomerId = request.CustomerId,
        Lines = request.Lines,
        PaymentToken = request.PaymentToken
    };
    await orderGateway.Execute<PlaceOrderHandler, PlaceOrderCommand>(cmd);
    return this.GetResponse(cmd, new { cmd.CreatedOrderId, cmd.ChargedAmount, cmd.Warnings });
}
Enter fullscreen mode Exit fullscreen mode

Once I had this running against fakes for IInventoryService and IPaymentGateway, the claims about testability held up better than I expected. There's no mocking framework ceremony beyond substituting the three or four dependencies the command actually declares, and because PlaceOrderCommand exposes exactly Inventory, Payments, Email, and OrderRepo, a reviewer can tell what this operation touches without opening the handler. That's a genuinely nice property, and it's the part of CommandFlow I'd steal even if I rejected everything else.

But two things surfaced immediately that the original series glosses over.

First, the out-of-stock check runs inside a loop that calls cmd.Raise(), which throws. Raise is annotated [DoesNotReturn] so the compiler treats it correctly, but it's still a thrown-and-caught exception on every validation failure, and .NET exceptions are not cheap. Building a stack trace and unwinding it costs on the order of microseconds, not nanoseconds, which is irrelevant for an order placement flow that happens a few times a second, but becomes a real cost if you reuse this pattern for something like bulk row validation in an import job that processes ten thousand records and rejects a meaningful fraction of them. The original series calls this "not using exceptions as control flow," and I think that's fair when Raise terminates the whole operation on a genuine rule violation. It stops being fair the moment someone builds a handler that calls Raise inside a loop for routine per-item validation, because at that point it is functioning exactly like control flow, just with a more expensive substrate underneath it. I'd draw the line at: use Raise for validation that should abort the operation entirely and happens rarely; don't use it for expected, high-frequency, per-item rejection.

Second, ProviderHub starts small (four services in my order example, two in the original blog example) and grows with every feature you add, because it's shared across every command in the application. In a real production system with forty or fifty distinct commands, that hub tends to accumulate every repository and every external service the application talks to, because there's no natural pressure pushing back against it the way there is with constructor injection (where a handler with twelve constructor parameters is an obvious code smell that gets refactored). The series explicitly defends this: "Is ProviderHub a Service Locator? No, it's just a bag of interfaces." I'd push back on that a little. It's true that a command only exposes the specific properties it declares, so a given handler can't reach into services it didn't ask for, and that's a real, meaningful difference from IServiceProvider.GetService(). But the hub itself is still a single object that every command in the application depends on and that grows monotonically over the app's lifetime, and that's the same shape of problem constructor injection was designed to surface early. It doesn't feel like Service Locator from inside a single handler. It starts to feel like one when you're the person maintaining GatewayService's constructor six months later and it's populating thirty properties.

The replay feature, tested against a real failure

The part of CommandFlow I was most skeptical of going in was the “time travel debugging” claim: because commands carry their full input, output, and context, you can serialize one from production logs and replay it locally against the exact failing state.

I tried this against my order-placement handler by deliberately breaking CheckStock to return a stale count, capturing the resulting command as JSON, and replaying it against a PlaceOrderHandler instance with fake dependencies swapped in.

var json = await File.ReadAllTextAsync("failing-order-command.json");
var cmd = JsonSerializer.Deserialize<PlaceOrderCommand>(json, options)!;
cmd.SetHub(testHub);
var handler = new PlaceOrderHandler();
await handler.Execute(cmd);
Console.WriteLine(cmd.OK ? "Succeeded" : cmd.ErrorMessage);
Enter fullscreen mode Exit fullscreen mode

It worked, and it worked with almost no setup, which is the honest headline. But it’s worth being precise about what this actually is, because the marketing language (“time travel debugging”) oversells it slightly. This isn’t event sourcing. It’s not replaying a sequence of state-changing events to reconstruct history; it’s re-running a single, already-decided operation against a fresh copy of its own inputs. If the bug was caused by something the command didn’t capture (a race condition between two concurrent requests, the exact row-level state of the database at that millisecond, a clock skew issue), replay won’t reproduce it, because the command object only knows what it was told to know. It’s a genuinely useful debugging aid for “reproduce this exact business logic path with this exact input,” which is a large share of production bugs. It is not a substitute for distributed tracing or an actual audit log with before/after state, and I’d be cautious about a team believing it replaces those.

Comparing this to where MediatR and CQRS actually stand in 2026

FEATURE MATRIX - request dispatch approaches, .NET 10/11, mid-2026
=====================================================================
Concern | MediatR 13+ | Plain services | CommandFlow
----------------------------------------------------------------------
Dispatch mechanism | ISender.Send, | Direct interface | GatewayService
                            | runtime resolve | call | .Execute<>,
                            | | | compile-time
----------------------------------------------------------------------
Go-to-definition from | Lands on Send, | Lands on the | Lands on the
caller | not the handler | implementation | handler
----------------------------------------------------------------------
Startup cost | Assembly scan, | None beyond | None, no
                            | grows with app | normal DI | scanning
----------------------------------------------------------------------
Cross-cutting concerns | IPipelineBehavior| Decorators via | Fixed decorator
(logging, validation, | ordered, generic,| Scrutor, per | chain in
transactions) | applies to all | interface | ServiceBase,
                            | requests | | not user-
                            | | | configurable
                            | | | per handler
----------------------------------------------------------------------
Built-in audit/replay | No, build it | No, build it | Yes, comes
                            | yourself | yourself | free from the
                            | | | command shape
----------------------------------------------------------------------
License risk | RPL-1.5 or paid | None, your code | None, your
                            | above $5M rev | | code, ~300
                            | | | lines you own
----------------------------------------------------------------------
Native AOT / trimming | Needs care, | Clean | Clean, no
                            | reflection-based | | reflection
----------------------------------------------------------------------
Community, docs, analyzers | Large, mature, | N/A, it's your | One author,
                            | Roslyn analyzers | own code | one GitHub
                            | | | repo, no
                            | | | analyzers
----------------------------------------------------------------------
Notifications / fan-out | Built-in | Hand-rolled | Not addressed
(pub-sub to many handlers) | INotification | | in the series
=====================================================================
Enter fullscreen mode Exit fullscreen mode

The row I keep coming back to is cross-cutting concerns, because it’s the one place MediatR still wins outright if you actually use pipeline behaviors for validation, logging, caching, and transactions across a large number of request types with one registration. CommandFlow’s decorator chain (LoggerDecorator wrapping ActionDecorator) gives you the same "wrap every execution" property, but it's a fixed chain baked into ServiceBase, not a composable, orderable pipeline you register per concern the way IPipelineBehavior is. If you need five ordered behaviors (say: authorization, then validation, then transaction scope, then caching, then logging) with the flexibility to skip specific ones per handler, MediatR's pipeline is still more mature machinery than what CommandFlow ships out of the box. You could extend ServiceBase to support an ordered list of decorators yourself; it just isn't there today.

On the licensing question specifically, I want to correct a misconception that’s floating around: MediatR going commercial is not a five-alarm fire for most teams. If your company is under five million dollars in annual revenue, you can keep using current MediatR for free under the Community tier, and the honest test for whether you even need a mediator at all is simpler than any of this: search your codebase for IPipelineBehavior and INotification. If you find neither, MediatR was functioning as an expensive method-call wrapper, and removing it (or never adopting it) makes your code more navigable regardless of the license.

Where CommandFlow genuinely earns its claims

Being fair to it, three things held up under real use for me.

The compile-time dependency contract is real and useful. Because a command explicitly exposes IArticleRepo ArticleRepo => Hub.ArticleRepo, a handler that tries to use a service the command doesn't declare gets a compiler error, not a runtime null reference or a missing DI registration discovered in production. That's a stronger guarantee than constructor injection gives you, where nothing stops a handler constructor from asking for ten dependencies and using two of them.

Zero assembly scanning is real and measurable. MediatR’s handler discovery scans assemblies at startup, and in applications with hundreds of handlers, that shows up as milliseconds of added cold-start time, which matters disproportionately for serverless functions and containers that scale to zero. CommandFlow’s Execute() resolves everything through generic constraints at compile time, so there's genuinely nothing to scan.

Ownership is real. It’s roughly three hundred lines of code you write once, understand completely, and never have to negotiate a license for. For a team that got burned by the MediatR transition and doesn’t want to be in that position again with the next dependency, that’s not a small thing.

The cracks: what I’d want fixed before shipping this

I’ll list these plainly, because the original series is thorough on features and thinner on limitations.

No ordered, composable pipeline. As covered above, the decorator chain is fixed, not a registry of behaviors you can add, remove, or reorder per handler type.

The GatewayService bypass is a real, acknowledged hole. The series admits controllers can instantiate a CommandHandler directly and skip GatewayService entirely, which means skipping logging, exception handling, and the audit trail all at once, silently. Their stated position is that this is an acceptable tradeoff because preventing it "complicates the design significantly." I'd rather see this enforced with an internal constructor and a factory, or a Roslyn analyzer that flags direct handler instantiation outside the gateway, than accepted as a known gap. For a pattern whose entire pitch is reliability and auditability, a silent way to lose the audit trail is a bigger deal than it's treated as.

No pub-sub or fan-out story. MediatR’s INotification lets one event trigger multiple independent handlers. CommandFlow doesn't address this at all; you'd be building your own event dispatcher on top of it, which is fine, but it means the "no MediatR needed" claim only fully holds if your application doesn't need notification fan-out anywhere.

No tooling. MediatR ships Roslyn analyzers, has years of Stack Overflow answers, and integrates with most APM tooling out of the box. CommandFlow is one author’s ~300-line pattern with one demo repository. That’s not a knock on the code quality, but “zero framework lock-in” also means zero ecosystem, and teams should budget for building their own tooling, test helpers, and onboarding docs, because nobody else has written them yet.

ProviderHub growth, covered above, is the long-term maintenance risk I'd watch closest in a codebase that lives for years.

My actual decision

I’m not adopting CommandFlow wholesale, and I’m not staying on MediatR by default either. Here’s where I landed, and the reasoning behind each call:

DECISION TABLE - what I'd actually reach for, by scenario
=====================================================================
Scenario | My pick
----------------------------------------------------------------------
New CRUD-heavy API, no complex audit needs | Plain service classes,
                                              | constructor injection,
                                              | Scrutor decorators for
                                              | cross-cutting concerns
----------------------------------------------------------------------
Compliance-heavy domain (fintech, health, | CommandFlow-style
insurance) where "who did what and when" | pattern, because the
is a real, recurring support request | replay/audit trail is
                                              | free and genuinely useful
----------------------------------------------------------------------
Large team, 100+ request types, heavy use | Stay on MediatR, pay for
of ordered pipeline behaviors already | it or qualify for the
                                              | Community tier under $5M
----------------------------------------------------------------------
Serverless / cold-start-sensitive workload | Avoid MediatR's assembly
                                              | scan; plain services or
                                              | CommandFlow, both are
                                              | scan-free
----------------------------------------------------------------------
Small team, tight deadline, no time to | Plain service classes.
build and maintain a custom framework | Least new surface area,
                                              | most Stack Overflow
                                              | coverage
=====================================================================
Enter fullscreen mode Exit fullscreen mode

For my own current project, which handles order processing and does get “why did this order fail” support tickets often enough that I care, I’m taking CommandFlow’s command shape (input, output, context, and dependencies bundled per operation) and its replay capability, but I’m replacing the fixed decorator chain with an ordered list of behaviors so I don’t lose MediatR’s one real advantage. That’s maybe forty extra lines on top of the ~300 the pattern already needs.

Getting it running yourself, without paying for anything

If you want to try this before committing, you don’t need MediatR’s commercial license, a hosted database, or any paid service to evaluate it end to end. Here’s the fully self-hosted path I used.

dotnet new webapi -n CommandFlowDemo
cd CommandFlowDemo
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
Enter fullscreen mode Exit fullscreen mode

Wire the gateway and hub in Program.cs:

builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IInventoryService, InventoryService>();
builder.Services.AddScoped<IPaymentGateway, FakePaymentGateway>();
builder.Services.AddScoped<IEmailService, ConsoleEmailService>();
builder.Services.AddScoped<IOrderGateway, GatewayService>();
Enter fullscreen mode Exit fullscreen mode

For local testing without touching any paid infrastructure, skip cloud SQL entirely and run against SQLite or a local Postgres container:

# docker-compose.yml: local Postgres, no cloud account needed
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: localdev
      POSTGRES_DB: commandflow_demo
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:

docker compose up -d
dotnet ef database update
dotnet run
Enter fullscreen mode Exit fullscreen mode

That’s the entire stack: no NuGet license key, no external API, nothing metered. The full source for the original author’s article-management demo, including the ASP.NET Core 8 implementation and MSTest suite, is on GitHub if you want a second reference point before writing your own version: github.com/langdiana/CommandFlowDemo.

Where this leaves the broader argument

The most useful reframing I took from this whole investigation wasn’t about CommandFlow specifically, it was this: CQRS’s genuinely valuable part was never the read/write split, it was the discipline of one handler per business operation instead of fat, fifty-method services. MediatR was never required to get that discipline, it just made it convenient, at the cost of indirection and, now, a licensing decision. CommandFlow is a serious, well-reasoned attempt to keep the discipline and drop both the ceremony and the license risk, and after two weeks of trying to break it, most of what it claims is real. The parts I’d fix before trusting it with a large team are the fixed decorator chain, the unenforced gateway bypass, and the long-term growth of ProviderHub. None of those are fatal. They're the kind of thing you fix once, in your own copy of the ~300 lines, and then you own the result completely, license-free, for as long as the project lives.

That last part, owning it completely, is worth more than it sounds like on first read. It’s the difference between architecture and a dependency wearing architecture’s clothes.

Tags: Dotnet, CSharp, Software Architecture, Software Engineering, MediatR

Top comments (0)