CQRSVSAMediatRJuly 2026 ยท 14 min read

CQRS + MediatR in VSA: Make Your Code Not Spaghetti Code

๐Ÿ Spaghetti Code Alert

We're exposing real spaghetti code โ€” the kind that lives inside traditional layered architecture controllers. Then we'll show you how CQRS + MediatR, powered by Vertical Slice Architecture, eliminates it completely.

Let's call it what it is: spaghetti code. Every .NET developer has seen it. Every developer has probably written it at 11 PM on a Friday, promising to refactor later. That controller with 700 lines of business logic, validation, database access, email sending, and who-knows-what-else โ€” all tangled together into one unmaintainable mess. That is spaghetti code. And in a traditional layered architecture, spaghetti code is not the exception. It is the inevitable destination.

Why does layered architecture breed spaghetti code? Because the architecture itself scatters related logic across multiple projects and layers. An order creation feature touches 7 files across 4 projects: OrderController.cs, IOrderService.cs, OrderService.cs, IOrderRepository.cs, OrderRepository.cs, Order.cs, CreateOrderDto.cs. To understand how an order is created, you must mentally stitch together code from all these files โ€” like following individual strands of spaghetti across the entire plate. The architecture itself creates the tangle. Spaghetti code isn't a developer problem in layered architecture; it's an architectural problem.

But there is a way out. CQRS (Command Query Responsibility Segregation) combined with MediatR, inside a Vertical Slice Architecture (VSA), eliminates spaghetti code at the architectural level. Instead of organizing code by technical layer โ€” all controllers together, all services together, all repositories together โ€” VSA organizes code by business feature. Each feature slice contains everything that feature needs: the command, the handler, the validator, and any domain logic. One folder. One feature. Zero spaghetti code.

The Problem: How Layered Architecture Creates Spaghetti Code

Before we dive into the VSA solution, let's understand exactly how layered architecture produces spaghetti code. The fundamental issue is that layered architecture groups code by what things are technically โ€” controllers, services, repositories โ€” rather than what they do for the business. This means that any single business operation is spread across multiple layers and multiple projects. When you need to trace the flow of "creating an order," you jump from the presentation layer to the application layer to the domain layer to the infrastructure layer. Each jump is an opportunity for spaghetti code to creep in.

Adding a new feature in layered architecture means touching files in every layer. Adding validation? That goes in the controller, or maybe the service, or maybe a FluentValidation validator injected into the service โ€” the decision is arbitrary, and that arbitrariness is what spawns spaghetti code. Need to send an email after order creation? Do you put it in the service? The controller? A domain event? There's no clear answer in layered architecture, so developers just stuff it wherever it fits, and the spaghetti code grows thicker with every commit.

Before: Spaghetti Code in a Layered Architecture

Here's what spaghetti code looks like in a typical layered .NET application. This is a single OrdersController that handles creating an order. Notice how validation, data access, discount calculation, tax logic, email sending, and logging are all mashed together. This is textbook spaghetti code โ€” every concern entangled with every other concern.

๐Ÿ SPAGHETTI CODE โ€” Layered Architecture (BEFORE)
// Controllers/OrdersController.cs
// Spaghetti code: 7 concerns in 1 method across 4+ projects

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly AppDbContext _context;
    private readonly IEmailService _emailService;
    private readonly ILogger _logger;

    [HttpPost]
    public async Task Create(CreateOrderDto dto)
    {
        // Spaghetti concern #1: Validation mixed inline
        if (string.IsNullOrWhiteSpace(dto.CustomerEmail))
            return BadRequest("Email required");
        if (dto.Items == null || dto.Items.Count == 0)
            return BadRequest("No items");

        // Spaghetti concern #2: Direct data access in controller
        var customer = await _context.Customers
            .FirstOrDefaultAsync(c => c.Email == dto.CustomerEmail);
        if (customer == null) return BadRequest("Customer not found");

        decimal subtotal = 0;
        var orderItems = new List();

        foreach (var item in dto.Items)
        {
            var product = await _context.Products.FindAsync(item.ProductId);
            if (product == null)
                return BadRequest($"Product {item.ProductId} not found");

            // Spaghetti concern #3: Business rules tangled with DB
            if (product.Stock < item.Quantity)
                return BadRequest($"Out of stock: {product.Name}");

            // Spaghetti concern #4: Discount logic hardcoded
            decimal discount = 0;
            if (customer.IsPremium && product.Category == "Electronics")
                discount = product.Price * 0.10m;
            else if (customer.IsPremium)
                discount = product.Price * 0.05m;
            else if (item.Quantity > 10)
                discount = product.Price * 0.02m;

            var lineTotal = (product.Price - discount) * item.Quantity;
            subtotal += lineTotal;

            orderItems.Add(new OrderItem
            {
                ProductId = product.Id,
                Quantity = item.Quantity,
                UnitPrice = product.Price,
                Discount = discount,
                LineTotal = lineTotal
            });

            product.Stock -= item.Quantity;
        }

        // Spaghetti concern #5: Tax logic mixed in
        decimal taxRate = customer.Country switch
        {
            "US" => 0.07m,
            "UK" => 0.20m,
            "AU" => 0.10m,
            _ => 0.10m
        };
        decimal tax = subtotal * taxRate;
        decimal total = subtotal + tax;

        var order = new Order
        {
            CustomerId = customer.Id,
            Subtotal = subtotal,
            Tax = tax,
            Total = total,
            Status = "Pending",
            CreatedAt = DateTime.UtcNow,
            Items = orderItems
        };

        _context.Orders.Add(order);
        await _context.SaveChangesAsync();

        // Spaghetti concern #6: Email sending inside controller
        await _emailService.SendAsync(customer.Email,
            "Order Confirmed",
            $"Dear {customer.Name}, order #{order.Id} total: {total:C}");

        // Spaghetti concern #7: Logging embedded
        _logger.LogInformation("Order {Id} created for {Email}",
            order.Id, customer.Email);

        return Ok(new { order.Id, order.Total });
    }
}

Look at this spaghetti code carefully. Seven distinct concerns โ€” validation, data access, business rules, discount calculation, tax calculation, email notification, and logging โ€” are all entangled in a single method. This is what layered architecture produces over time. Each new requirement adds another strand of spaghetti to the plate. Need to support a new country's tax rate? You're editing the same method that also handles email sending. Want to change how premium discounts work? You're touching code that also validates email addresses. The spaghetti code entanglement means you cannot change one thing without risking everything else.

And testing this spaghetti code? Forget about it. The discount logic is buried inside a loop that's inside a method that directly instantiates AppDbContext. You can't unit-test the discount calculation without spinning up a database. You can't test the tax logic without mocking an email service โ€” which has nothing to do with tax. Spaghetti code doesn't just look bad. It actively prevents you from writing tests, which means you can't refactor, which means the spaghetti code gets worse. It's a death spiral.

The Solution: VSA + CQRS + MediatR = No Spaghetti Code

Vertical Slice Architecture flips the script entirely. Instead of organizing code by technical layer, VSA organizes by business feature. Every feature lives in its own folder. Inside that folder, you'll find the command/query, the handler, the validator, and any domain logic โ€” all in one place. CQRS, implemented through MediatR, provides the messaging backbone. Commands (writes) and Queries (reads) are clearly separated. Cross-cutting concerns like validation and logging are handled by MediatR pipeline behaviors โ€” written once, applied everywhere.

In VSA, the folder structure for our order creation feature looks like this:

Features/
โ””โ”€โ”€ Orders/
    โ”œโ”€โ”€ CreateOrder/
    โ”‚   โ”œโ”€โ”€ CreateOrderCommand.cs      โ† The command (DTO)
    โ”‚   โ”œโ”€โ”€ CreateOrderHandler.cs      โ† Business logic only
    โ”‚   โ”œโ”€โ”€ CreateOrderValidator.cs    โ† Validation rules
    โ”‚   โ””โ”€โ”€ CreateOrderEndpoint.cs     โ† Minimal API endpoint
    โ”œโ”€โ”€ GetOrderById/
    โ”‚   โ”œโ”€โ”€ GetOrderByIdQuery.cs
    โ”‚   โ””โ”€โ”€ GetOrderByIdHandler.cs
    โ””โ”€โ”€ Shared/
        โ”œโ”€โ”€ Order.cs                   โ† Domain entity
        โ””โ”€โ”€ OrderItem.cs

One feature, one folder. No jumping between projects. No hunting through layers. No spaghetti code. When you need to understand how an order is created, you open the CreateOrder folder and everything is right there. When you need to add a new field to orders, you change files in one folder โ€” not across four projects. This is the architectural antidote to spaghetti code.

After: Clean Code with VSA, CQRS, and MediatR

Now let's see the same order creation feature rebuilt with CQRS + MediatR inside a Vertical Slice Architecture. Every concern is separated. Every class has a single responsibility. There is not a trace of spaghetti code anywhere.

โœ… CLEAN CODE โ€” VSA + CQRS + MediatR (AFTER)

1. The Command โ€” A Simple, Focused DTO

The command is a plain record. It carries data. Nothing else. This is not spaghetti code; it's a single-purpose data structure.

โœ… Features/Orders/CreateOrder/CreateOrderCommand.cs
public record CreateOrderCommand(
    string CustomerEmail,
    List Items
) : IRequest;

public record OrderItemDto(Guid ProductId, int Quantity);
public record CreateOrderResult(Guid OrderId, decimal Total);

2. The Validator โ€” Validation Lives in Its Own Class

Validation is extracted into a dedicated FluentValidation validator. No validation logic pollutes the handler or the controller. This is how you prevent spaghetti code: one class, one job.

โœ… Features/Orders/CreateOrder/CreateOrderValidator.cs
public class CreateOrderValidator
    : AbstractValidator
{
    public CreateOrderValidator()
    {
        RuleFor(x => x.CustomerEmail)
            .NotEmpty()
            .EmailAddress();
        RuleFor(x => x.Items)
            .NotEmpty()
            .Must(items => items.Count > 0);
        RuleForEach(x => x.Items).ChildRules(item =>
        {
            item.RuleFor(i => i.ProductId)
                .NotEqual(Guid.Empty);
            item.RuleFor(i => i.Quantity)
                .InclusiveBetween(1, 1000);
        });
    }
}

3. The Handler โ€” Business Logic Only, Zero Spaghetti Code

The handler is the heart of the feature slice. It contains only business logic. No validation (that's already done by the pipeline). No logging (also in the pipeline). No email sending (that's a domain event). Just pure, focused business logic โ€” the polar opposite of spaghetti code.

โœ… Features/Orders/CreateOrder/CreateOrderHandler.cs
public class CreateOrderHandler
    : IRequestHandler
{
    private readonly AppDbContext _context;

    public CreateOrderHandler(AppDbContext context)
        => _context = context;

    public async Task Handle(
        CreateOrderCommand cmd, CancellationToken ct)
    {
        var customer = await _context.Customers
            .Where(c => c.Email == cmd.CustomerEmail)
            .FirstOrDefaultAsync(ct);

        if (customer is null)
            throw new NotFoundException("Customer not found");

        var order = Order.Create(customer.Id);

        foreach (var item in cmd.Items)
        {
            var product = await _context.Products
                .FindAsync([item.ProductId], ct);

            if (product is null)
                throw new NotFoundException(
                    $"Product {item.ProductId}");

            product.ReserveStock(item.Quantity);

            var discount = customer.CalculateDiscount(
                product, item.Quantity);

            order.AddItem(product, item.Quantity, discount);
        }

        order.CalculateTotals(customer.Country);

        _context.Orders.Add(order);
        await _context.SaveChangesAsync(ct);

        order.AddDomainEvent(
            new OrderCreatedDomainEvent(order.Id, customer.Email));

        return new CreateOrderResult(order.Id, order.Total);
    }
}

4. Domain Logic โ€” Rich, Testable, No Spaghetti Code

Business rules live in the domain entities themselves โ€” where they belong. The discount calculation and stock reservation are methods on the entity, not buried in a controller. This is how you eliminate spaghetti code: put logic where it's discoverable.

โœ… Features/Orders/Shared/Order.cs (Domain Entity)
public class Order
{
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public decimal Subtotal { get; private set; }
    public decimal Tax { get; private set; }
    public decimal Total { get; private set; }
    public OrderStatus Status { get; private set; }
    public List Items { get; private set; } = [];

    public static Order Create(Guid customerId)
        => new() { Id = Guid.NewGuid(), CustomerId = customerId,
                   Status = OrderStatus.Pending };

    public void AddItem(Product product, int quantity, decimal discount)
    {
        var lineTotal = (product.Price - discount) * quantity;
        Items.Add(new OrderItem
        {
            ProductId = product.Id,
            Quantity = quantity,
            UnitPrice = product.Price,
            Discount = discount,
            LineTotal = lineTotal
        });
        Subtotal += lineTotal;
    }

    public void CalculateTotals(string country)
    {
        Tax = Subtotal * TaxRate.For(country);
        Total = Subtotal + Tax;
    }

    // Rich domain behavior โ€” no spaghetti code here
    public void MarkAsShipped() =>
        Status = OrderStatus.Shipped;
    public void Cancel() =>
        Status = OrderStatus.Cancelled;
}

5. Pipeline Behaviors โ€” Cross-Cutting Concerns, Applied Once

MediatR pipeline behaviors are the secret weapon against spaghetti code. Instead of copying validation and logging code into every handler (which is how spaghetti code starts), you write it once as a behavior and it applies to every handler automatically. This is the DRY principle at the architectural level.

โœ… Pipeline/ValidationBehavior.cs โ€” applied to ALL handlers
public class ValidationBehavior
    : IPipelineBehavior
    where TRequest : IRequest
{
    private readonly IServiceProvider _sp;

    public async Task Handle(TRequest request,
        RequestHandlerDelegate next,
        CancellationToken ct)
    {
        var validator = _sp.GetService<
            IValidator>();

        if (validator is not null)
        {
            var result = await validator
                .ValidateAsync(request, ct);
            if (!result.IsValid)
                throw new ValidationException(
                    result.Errors);
        }

        return await next();
    }
}
โœ… Pipeline/LoggingBehavior.cs โ€” applied to ALL handlers
public class LoggingBehavior
    : IPipelineBehavior
    where TRequest : IRequest
{
    private readonly ILogger> _logger;

    public async Task Handle(TRequest request,
        RequestHandlerDelegate next,
        CancellationToken ct)
    {
        var name = typeof(TRequest).Name;
        _logger.LogInformation("โžก๏ธ {Name} started", name);
        var sw = Stopwatch.StartNew();
        var response = await next();
        sw.Stop();
        _logger.LogInformation(
            "โœ… {Name} completed in {Ms}ms", name, sw.ElapsedMilliseconds);
        return response;
    }
}

6. Domain Events โ€” Side Effects, Not Spaghetti Code

Email sending after order creation is a side effect โ€” it doesn't belong in the handler. With domain events, the handler raises an event, and a separate handler sends the email. No spaghetti code entanglement between order creation and email sending.

โœ… Features/Orders/CreateOrder/OrderCreatedDomainEventHandler.cs
public class OrderCreatedDomainEventHandler
    : INotificationHandler
{
    private readonly IEmailService _email;
    private readonly AppDbContext _context;

    public async Task Handle(
        OrderCreatedDomainEvent evt, CancellationToken ct)
    {
        var customer = await _context.Customers
            .FindAsync([evt.CustomerId], ct);

        await _email.SendAsync(customer!.Email,
            "Order Confirmed โœ…",
            $"Your order #{evt.OrderId} is being processed.");
    }
}

7. The Endpoint โ€” Thin, Clean, No Spaghetti Code

In VSA, the endpoint (whether a controller action or Minimal API) is razor-thin. It does exactly one thing: send the command through MediatR. That's it. This is what "no spaghetti code" looks like at the API surface.

โœ… Features/Orders/CreateOrder/CreateOrderEndpoint.cs
public static class CreateOrderEndpoint
{
    public static void Map(IEndpointRouteBuilder app)
    {
        app.MapPost("/api/orders", async (
            CreateOrderCommand cmd,
            IMediator mediator,
            CancellationToken ct) =>
        {
            var result = await mediator.Send(cmd, ct);
            return Results.Created(
                $"/api/orders/{result.OrderId}", result);
        })
        .WithName("CreateOrder")
        .WithTags("Orders");
    }
}

The Spaghetti Code Comparison: Side by Side

Let's put it side by side so the difference is undeniable. On the left: the spaghetti code layered approach. On the right: the clean VSA + CQRS + MediatR approach.

๐Ÿ Layered Spaghetti Codeโœ… VSA + CQRS + MediatR
7 files across 4+ projects per feature3-4 files in 1 folder per feature
Validation scattered across controllers and servicesSingle FluentValidation validator per command
Business logic mixed with data accessHandler = business logic only
Logging copied into every methodOne LoggingBehavior for all handlers
Email sending embedded in controllerDecoupled domain event handler
Impossible to unit test individual concernsEvery class is independently testable
One change touches unrelated codeChanges stay within the feature folder

Why CQRS + MediatR + VSA Is the Spaghetti Code Antidote

The combination of these three patterns doesn't just reduce spaghetti code โ€” it makes it architecturally impossible. Here's why each piece matters:

Vertical Slice Architecture groups code by feature, not by technical layer. Since all the code for "create an order" lives in one folder, there's no temptation to scatter logic across projects. The architecture itself enforces cohesion. You cannot create spaghetti code when everything for a feature is co-located.

CQRS forces you to separate reads from writes. A command handler only handles commands. A query handler only handles queries. You can't accidentally mix read logic into a write operation because the interfaces are different: IRequest for commands, IRequest for queries. The type system itself prevents spaghetti code.

MediatR provides the pipeline that handles cross-cutting concerns. Validation, logging, transaction management, authorization โ€” these are all behaviors that execute before and after your handler. Your handler doesn't know about them. They don't know about your handler. This is the separation of concerns that spaghetti code violates, enforced by the framework.

๐Ÿ”ฅ Indotalent Insight

Every Indotalent product โ€” from the CRM to the WMS, from the HRM to the CMS โ€” is built with exactly this architecture: VSA + CQRS + MediatR. Not a single line of spaghetti code. You get the complete source code for $21, and you can see for yourself how a production-grade .NET 10 application looks when it's clean from the ground up.

Key Takeaways

  • Layered architecture breeds spaghetti code by scattering related logic across multiple projects and layers โ€” 7 files in 4 projects for one feature.
  • VSA eliminates spaghetti code by grouping all code for a feature in a single folder โ€” 3-4 files, one place, full cohesion.
  • CQRS prevents concerns from mixing โ€” commands and queries are different types, so you can't accidentally blend read and write logic.
  • MediatR pipeline behaviors handle cross-cutting concerns once, stopping the copy-paste spaghetti code cycle dead in its tracks.
  • Domain events decouple side effects โ€” email sending, audit logging, and cache invalidation live in their own handlers, not tangled into business logic.
  • The result is fully testable code โ€” every class has one responsibility, making unit testing trivial compared to the spaghetti code alternative.

Stop Writing Spaghetti Code. Start with VSA.

Spaghetti code isn't a personality trait. It's not something you're doomed to write because you're a "messy" developer. Spaghetti code is a symptom of the wrong architecture. When your architecture scatters related code across layers and projects, it rewards tangling. When your architecture groups code by feature, it rewards cleanliness. VSA + CQRS + MediatR is that clean architecture โ€” and once you try it, you'll wonder how you ever tolerated spaghetti code.

The next time you open a controller and find 700 lines of intertwined logic, remember: it's not your fault. But it is your responsibility to fix it. And the fix has a name: CQRS + MediatR, inside a Vertical Slice Architecture. No more spaghetti code. Just clean, maintainable, testable .NET applications that make you proud to call yourself a developer.

Explore VSA + CQRS + MediatR in Production

See how Indotalent products implement this exact architecture. Zero spaghetti code. Complete source โ€” $21 each.

Explore Products
Write file to 'blog/cqrs-mediatr-no-spaghetti-code.html'.