No MediatR, No Exceptions: C# 15 Unions in .NET 11 APIs

Build fast Vertical Slice APIs in .NET 11 without MediatR or business exceptions using C# 15 unions, Minimal API endpoint filters, and EF Core 11.

In .NET 11, building a fast, maintainable Vertical Slice Architecture with C# 15 unions lets you delete the four-project boilerplate that has slowed enterprise .NET teams down for years: controllers or Minimal API routes doing nothing except calling await mediator.Send(command), ValidationBehavior wrappers around FluentValidation, and handlers throwing custom exceptions like OrderOutOfStockException just so a global middleware three layers away can guess which HTTP status code to return.

Vertical Slice Architecture with C# 15 Unions in .NET 11 - Replacing MediatR and Business Exceptions in Minimal APIs
Vertical Slice Architecture in .NET 11 with C# 15 unions.

Between MediatR's move to a dual RPL-1.5 and commercial license in v13, the compile-time validation source generator in ASP.NET Core 11, and native C# 15 union types, the framework now gives you everything those third-party libraries were patching over—no MediatR, no OneOf or FluentResults, and zero exceptions thrown for expected business outcomes. Need a quick refresher on the underlying primitives first? Keep our What's New in C# 15: Full Feature Guide & Code Examples and What's New in EF Core 11 and ASP.NET Core 11 (.NET 11) pillar guides open alongside this walkthrough.

Architecture Comparison: Clean Architecture vs. Vertical Slice Architecture with C# 15 Unions

Here is how a modern .NET 11 Vertical Slice stack compares side by side against the traditional MediatR + Result-wrapper + exception-driven Clean Architecture template:

Concern Traditional Clean Architecture Modern .NET 11 Vertical Slice Architectural Win
Request Dispatch IMediator.Send(command) via MediatR v12/v13 Direct DI-injected CreateOrderHandler Zero reflection/wrapper indirection, instant F12 navigation, zero RPL/commercial license keys
Business Outcomes Throwing OrderOutOfStockException or FluentResults / OneOf<T0, T1> Native C# 15 public union CreateOrderResult(...) Zero stack-unwinding allocations, compiler-enforced CS8509 exhaustiveness, zero 3rd-party packages
Input Validation FluentValidation + MediatR ValidationBehavior Built-in AddValidation() + domain ValidationError union case Compile-time Roslyn source generator + unified ValidationProblem 400 contract
Cross-Cutting Concerns IPipelineBehavior<TRequest, TResponse> Minimal API IEndpointFilter (AddEndpointFilter) Native HTTP awareness, status-code inspection, zero extra abstractions
API Contract & Docs Manual [ProducesResponseType] guesswork + Swashbuckle TypedResults + .Produces<T> on OpenAPI 3.2 Exact anyOf JSON schemas generated directly from return types
Folder Layout 4 projects (Domain, Application, Infrastructure, Api) 1 feature folder (Features/Orders/CreateOrder/) Every file you touch for a feature sits side by side

1. The Problem: Why MediatR, Result Wrappers, and Business Exceptions Hurt Production Systems

The Real Cost of Exceptions-as-Control-Flow

An out-of-stock SKU during a flash sale is not an exceptional runtime fault. Neither is a declined credit card or a duplicate SKU in a shopping cart. Those are normal, expected outcomes of running an e-commerce business.

When you model expected outcomes by throwing OrderOutOfStockException or PaymentFailedException, two bad things happen in production:

  • Runtime CPU and GC pressure: Throwing an exception in .NET allocates the exception instance on the managed heap, captures a full stack trace, unwinds the call stack frame by frame searching for a matching catch block, and executes finally blocks along the way. On a quiet internal CRUD app, nobody notices. During a checkout spike where 30% of requests hit sold-out inventory, thousands of thrown exceptions per second turn a fast HTTP pipeline into a CPU bottleneck and drive up Gen0/Gen1 garbage collection.
  • Invisible contracts (goto in disguise): A signature like Task<OrderDto> Handle(CreateOrderCommand cmd) lies to its caller. Nothing in the return type tells the endpoint which failure states exist. When another developer adds throw new CustomerCreditHoldException() six months later and forgets to register a mapping inside ExceptionHandlingMiddleware, production returns 500 Internal Server Error and pages the on-call engineer for a routine business rule.

Why MediatR Is No Longer Justified in Most Web APIs

Starting with MediatR v13.0, Lucky Penny Software moved MediatR from MIT to a dual Reciprocal Public License (RPL-1.5) and paid commercial license model—requiring registration and a MEDIATR_LICENSE_KEY even on the free community tier under $5M gross revenue. Open-source maintainers deserve sustainable funding, and if MediatR genuinely earns its keep in a complex desktop client with dozens of in-memory notification handlers, buy the commercial license.

In 95% of ASP.NET Core Web APIs, however, MediatR was only doing two jobs:

  • Decoupling a one-line HTTP endpoint from a single IRequestHandler<TRequest, TResponse>.
  • Running IPipelineBehavior<TRequest, TResponse> decorators for validation, logging, and latency metrics.

In .NET 11, standard Dependency Injection handles the first job with a plain Scoped class—and unlike mediator.Send(), pressing F12 jumps straight to your handler implementation instead of an interface inside a NuGet package. Minimal API Endpoint Filters (IEndpointFilter) and AddValidation() handle the second job natively inside the HTTP pipeline.

Why Third-Party Result Libraries (OneOf, ErrorOr, FluentResults) Are Obsolete

To escape exception overhead, many .NET teams adopted OneOf<OrderCreated, InsufficientInventory, PaymentDeclined>, ErrorOr<T>, or FluentResults. Those libraries solved the performance issue, but the C# compiler didn't understand them natively:

  • Generic arity signatures (OneOf<T0, T1, T2, T3>) leaked across layers and churned method signatures every time a new outcome was added.
  • Consumers had to call .Match(f0, f1, f2) with positional lambdas instead of standard C# switch expressions.
  • System.Text.Json and OpenAPI schema generators couldn't serialize or document them without custom converters and filters.

C# 15 native union types replace all three workarounds at the language and compiler level.

2. Domain Modeling: C# 15 Unions vs. Closed Class Hierarchies

C# 15 ships two complementary features in .NET 11 that unlock compiler-enforced exhaustive pattern matching: union types (public union) and closed class hierarchies (public closed class / public closed record).

Microsoft's official C# 15 design guidance draws a clear architectural line between them based on shared behavior versus independent data shapes:

Decision Factor C# 15 Union (public union) C# 15 Closed Hierarchy (public closed class)
Type Relationship Cases are independent records, classes, or primitives with no shared base type Subtypes inherit from a common abstract base class or base record
Shared Logic / State None—pure data-centric "one of these outcomes" Shared base properties, virtual/abstract methods, or polymorphic behavior
Extensibility Boundary Fixed to the exact case types listed in the union declaration New subtypes can be declared anywhere inside the same assembly, blocked outside
JSON Serialization Untagged: System.Text.Json writes only the active case directly (emits OpenAPI anyOf) Polymorphic: typically uses [JsonPolymorphic] with a $type discriminator property
Best Architectural Fit Vertical Slice handler results (CreateOrderResult), wire unions, disjoint types DDD domain state machines (OrderState), AST/expression trees, domain events

Modeling Checkout Outcomes with a C# 15 Union

In a checkout slice, an OrderCreated response DTO, a business ValidationError, an inventory shortage, and a payment gateway decline don't share business methods or an inheritance hierarchy. Forcing them to inherit from an empty CreateOrderOutcome base class is pure ceremony. A C# 15 union expresses the exact contract in one declaration:

namespace Store.Api.Features.Orders.CreateOrder;

public sealed record OrderCreated(
    Guid OrderId,
    decimal TotalAmount,
    DateTimeOffset CreatedAtUtc);

public sealed record ValidationError(
    IDictionary<string, string[]> Errors);

public sealed record InsufficientInventory(
    string Sku,
    int RequestedQuantity,
    int AvailableQuantity);

public sealed record PaymentDeclined(
    string DeclineCode,
    string Reason);

// C# 15 native union: compiler generates implicit conversions + [Union] metadata
public union CreateOrderResult(
    OrderCreated,
    ValidationError,
    InsufficientInventory,
    PaymentDeclined);

When you return new OrderCreated(...) or new InsufficientInventory(...) from a method returning CreateOrderResult, the compiler uses synthesized implicit conversion operators—no .FromT0() or Result.Fail() wrappers required.

Enforcing Exhaustiveness Without Discard (_) and Escalating CS8509

The biggest architectural win arrives inside your endpoint's switch expression—as long as you never write a _ => discard wildcard:

// Suppose next sprint you add FraudCheckFlagged to CreateOrderResult:
public union CreateOrderResult(
    OrderCreated,
    ValidationError,
    InsufficientInventory,
    PaymentDeclined,
    FraudCheckFlagged);

// Every switch expression without '_' immediately triggers CS8509 at build time!
return result switch
{
    OrderCreated created        => TypedResults.Created($"/api/orders/{created.OrderId}", created),
    ValidationError invalid     => TypedResults.ValidationProblem(invalid.Errors),
    InsufficientInventory stock => TypedResults.Conflict(stock),
    PaymentDeclined declined    => TypedResults.UnprocessableEntity(declined)
    // Compiler error CS8509: The switch expression does not handle all possible
    // values of its input type (it is not exhaustive). For example, the pattern
    // 'FraudCheckFlagged' is not covered.
};
Compiler Rule (CS8509): By default, Roslyn emits CS8509 as a warning for backwards compatibility across C# switch expressions. On a production .NET 11 team, always promote CS8509 to a build-breaking error in Directory.Build.props so an unmapped domain case can never merge to main.
<Project>
  <PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <!-- Make non-exhaustive union and closed-hierarchy switches fail the build -->
    <WarningsAsErrors>$(WarningsAsErrors);CS8509</WarningsAsErrors>
  </PropertyGroup>
</Project>

When to Choose closed record Instead:
Inside your Order entity itself, if an order transitions through PendingPayment, Paid, Shipped, and Cancelled—and every state shares an OrderId property and a CanCancel() method—model that internal state machine as public closed record OrderState; rather than a union. Use closed for polymorphic domain state; use union for Vertical Slice operation outcomes.

3. The Complete Vertical Slice Anatomy (Full Runnable .NET 11 Code)

In Vertical Slice Architecture, code is organized by business capability rather than technical layer. Everything required to create an order lives inside one self-contained folder:

src/Store.Api/
├── Directory.Build.props
├── Program.cs
├── Common/
│   └── Filters/
│       └── ObservabilityFilter.cs
└── Features/
    └── Orders/
        └── CreateOrder/
            ├── CreateOrderRequest.cs
            ├── CreateOrderResult.cs
            ├── CreateOrderHandler.cs
            └── CreateOrderEndpoint.cs

In Vertical Slice Architecture, coupling files within a single feature slice is desirable cohesion; decoupling across unrelated slices is what keeps a 200-endpoint codebase maintainable.

Principal .NET Architecture Principle

When product requirements change how checkout handles out-of-stock items, you open Features/Orders/CreateOrder/ and every file you need sits right in front of you. Below is the complete, compilable net11.0 implementation.

File 1: CreateOrderRequest.cs (Request Record + Native Validation)

ASP.NET Core 11's Microsoft.Extensions.Validation source generator inspects Minimal API parameters at compile time—including DataAnnotations placed directly on record positional parameters—and supports non-blocking I/O checks via AsyncValidationAttribute:

using System.ComponentModel.DataAnnotations;

namespace Store.Api.Features.Orders.CreateOrder;

public sealed record CreateOrderRequest(
    [Required] Guid CustomerId,
    [Required, MinLength(1), MaxLength(50)] IReadOnlyList<OrderLineRequest> Lines,
    [Required, StringLength(64, MinimumLength = 8)] string PaymentMethodToken);

public sealed record OrderLineRequest(
    [Required, StringLength(32, MinimumLength = 3)] string Sku,
    [Range(1, 100)] int Quantity);

Syntactic input rules (Quantity <= 0, empty Lines, missing PaymentMethodToken) are rejected automatically at the HTTP edge by builder.Services.AddValidation() before CreateOrderHandler ever runs. Stateful or cross-item business validation inside the handler returns the ValidationError union case below—and both produce the exact same 400 Bad Request (HttpValidationProblemDetails) JSON contract for API clients.

File 2: CreateOrderResult.cs (The C# 15 Domain Union)

namespace Store.Api.Features.Orders.CreateOrder;

public sealed record OrderCreated(
    Guid OrderId,
    decimal TotalAmount,
    DateTimeOffset CreatedAtUtc);

public sealed record ValidationError(
    IDictionary<string, string[]> Errors);

public sealed record InsufficientInventory(
    string Sku,
    int RequestedQuantity,
    int AvailableQuantity);

public sealed record PaymentDeclined(
    string DeclineCode,
    string Reason);

public union CreateOrderResult(
    OrderCreated,
    ValidationError,
    InsufficientInventory,
    PaymentDeclined);

File 3: CreateOrderHandler.cs (Plain DI Handler + EF Core 11, Zero Exceptions)

No IRequest<T>, no IRequestHandler<T, R>, and zero throw statements for domain outcomes—just a sealed class resolved by ASP.NET Core DI that coordinates domain rules and EF Core 11 persistence:

using Microsoft.EntityFrameworkCore;
using Store.Api.Data;

namespace Store.Api.Features.Orders.CreateOrder;

public sealed class CreateOrderHandler(
    AppDbContext db,
    IPaymentGateway payments,
    TimeProvider clock)
{
    public async Task<CreateOrderResult> HandleAsync(
        CreateOrderRequest request,
        CancellationToken ct)
    {
        // 1. Domain validation: reject duplicate SKUs across line items
        var duplicateSkus = request.Lines
            .GroupBy(l => l.Sku, StringComparer.OrdinalIgnoreCase)
            .Where(g => g.Count() > 1)
            .Select(g => g.Key)
            .ToArray();

        if (duplicateSkus.Length > 0)
        {
            return new ValidationError(new Dictionary<string, string[]>
            {
                [nameof(request.Lines)] =
                    [$"Duplicate SKUs are not allowed: {string.Join(", ", duplicateSkus)}. Merge quantities instead."]
            });
        }

        var requestedSkus = request.Lines.Select(l => l.Sku).ToArray();

        var inventoryBySku = await db.InventoryItems
            .Where(i => requestedSkus.Contains(i.Sku))
            .ToDictionaryAsync(i => i.Sku, ct);

        decimal totalAmount = 0m;

        // 2. Check inventory across all lines before mutating or charging
        foreach (var line in request.Lines)
        {
            if (!inventoryBySku.TryGetValue(line.Sku, out var item) ||
                item.AvailableQuantity < line.Quantity)
            {
                // Implicit conversion to CreateOrderResult — zero exceptions thrown!
                return new InsufficientInventory(
                    Sku: line.Sku,
                    RequestedQuantity: line.Quantity,
                    AvailableQuantity: item?.AvailableQuantity ?? 0);
            }

            totalAmount += item.UnitPrice * line.Quantity;
        }

        // 3. Authorize payment via external gateway (returns its own result)
        var charge = await payments.ChargeAsync(
            request.CustomerId,
            totalAmount,
            request.PaymentMethodToken,
            ct);

        if (!charge.Succeeded)
        {
            return new PaymentDeclined(charge.DeclineCode!, charge.FailureReason!);
        }

        // 4. Apply state changes and persist atomically in EF Core 11
        foreach (var line in request.Lines)
        {
            inventoryBySku[line.Sku].AvailableQuantity -= line.Quantity;
        }

        var now = clock.GetUtcNow();
        var order = new Order
        {
            Id = Guid.CreateVersion7(),
            CustomerId = request.CustomerId,
            TotalAmount = totalAmount,
            PaymentReference = charge.TransactionId!,
            CreatedAtUtc = now,
            Lines = [.. request.Lines.Select(l => new OrderLine
            {
                Sku = l.Sku,
                Quantity = l.Quantity,
                UnitPrice = inventoryBySku[l.Sku].UnitPrice
            })]
        };

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

        return new OrderCreated(order.Id, order.TotalAmount, order.CreatedAtUtc);
    }
}

File 4: CreateOrderEndpoint.cs (Minimal API Mapping + OpenAPI 3.2 anyOf)

In ASP.NET Core 11, Minimal APIs pair TypedResults (Results<T1, T2, T3, T4>) and .Produces<T>() metadata with OpenAPI 3.2 schema generation. Even better, .NET 11 fixed a frustrating .NET 10 limitation where multiple .Produces<T>() declarations on the same status code overwrote earlier ones—now every response type is preserved, merging same-status JSON models into an OpenAPI 3.2 anyOf schema:

using Microsoft.AspNetCore.Http.HttpResults;

namespace Store.Api.Features.Orders.CreateOrder;

public static class CreateOrderEndpoint
{
    public static IEndpointRouteBuilder MapCreateOrder(this IEndpointRouteBuilder app)
    {
        app.MapPost("/orders", HandleAsync)
            .WithName("CreateOrder")
            .WithSummary("Creates a new customer order")
            .Produces<OrderCreated>(StatusCodes.Status201Created)
            .ProducesValidationProblem()
            .Produces<InsufficientInventory>(StatusCodes.Status409Conflict)
            .Produces<PaymentDeclined>(StatusCodes.Status422UnprocessableEntity);

        return app;
    }

    private static async Task<Results<
        Created<OrderCreated>,
        ValidationProblem,
        Conflict<InsufficientInventory>,
        UnprocessableEntity<PaymentDeclined>>> HandleAsync(
        CreateOrderRequest request,
        CreateOrderHandler handler,
        CancellationToken ct)
    {
        CreateOrderResult result = await handler.HandleAsync(request, ct);

        // Exhaustive switch: no '_' arm! Adding a 5th case to CreateOrderResult
        // fails compilation with CS8509 until this endpoint maps it to HTTP.
        return result switch
        {
            OrderCreated created =>
                TypedResults.Created($"/api/orders/{created.OrderId}", created),

            ValidationError invalid =>
                TypedResults.ValidationProblem(invalid.Errors),

            InsufficientInventory shortage =>
                TypedResults.Conflict(shortage),

            PaymentDeclined declined =>
                TypedResults.UnprocessableEntity(declined)
        };
    }
}

What if two distinct failure cases map to 409 Conflict (say, InsufficientInventory and DuplicateOrderKey)? Chain both .Produces<InsufficientInventory>(StatusCodes.Status409Conflict) and .Produces<DuplicateOrderKey>(StatusCodes.Status409Conflict) on the endpoint—ASP.NET Core 11 emits both inside an OpenAPI 3.2 anyOf schema under 409 instead of silently dropping the first schema. And if an internal RPC-style endpoint returns a C# 15 union directly, ApiExplorer inspects JsonTypeInfoKind.Union via JsonSchemaExporter and automatically emits an untagged anyOf schema referencing each union case.

Wiring the Slice in Program.cs

using Store.Api.Common.Filters;
using Store.Api.Features.Orders.CreateOrder;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddValidation(); // Compile-time Minimal API validation
builder.Services.AddOpenApi();    // Emits OpenAPI 3.2 by default in .NET 11

// Register vertical slice handlers (plain scoped classes)
builder.Services.AddScoped<CreateOrderHandler>();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

var api = app.MapGroup("/api")
    .AddEndpointFilter<ObservabilityFilter>();

api.MapCreateOrder();

app.Run();

Multi-Project Validation Gotcha:
If you extract vertical slices into separate feature class libraries (such as Store.Orders.dll), remember that the validation source generator only scans the assembly where AddValidation() is invoked. Expose a module registration method inside Store.Orders that calls services.AddValidation() and call it from Program.cs—otherwise invalid requests bypass validation and return 200 OK.

4. Cross-Cutting Concerns Without MediatR IPipelineBehavior

The first question senior engineers ask when dropping MediatR is: "Where do my logging, latency metrics, and transaction IPipelineBehavior classes live now?"

They become Minimal API Endpoint Filters (IEndpointFilter), attached once to your root MapGroup("/api") so every vertical slice inherits them automatically. Even better, endpoint filters have something MediatR behaviors never had: direct access to HttpContext and the actual HTTP status code produced after mapping your C# 15 union.

Logging & Latency Filter (ObservabilityFilter.cs)

In a MediatR LoggingBehavior<TRequest, TResponse>, detecting business failures meant either catching exceptions or reflecting over generic Result<T> wrappers. Inside an endpoint filter, your route has already mapped the union to a TypedResults instance implementing IStatusCodeHttpResult, making status inspection fast and allocation-free:

using System.Diagnostics;

namespace Store.Api.Common.Filters;

public sealed class ObservabilityFilter(
    ILogger<ObservabilityFilter> logger) : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var http = context.HttpContext;
        var endpointName = http.GetEndpoint()?.DisplayName ?? http.Request.Path;
        long startTimestamp = Stopwatch.GetTimestamp();

        var result = await next(context);

        TimeSpan elapsed = Stopwatch.GetElapsedTime(startTimestamp);
        int statusCode = (result as IStatusCodeHttpResult)?.StatusCode
                         ?? http.Response.StatusCode;

        if (statusCode >= 400)
        {
            logger.LogWarning(
                "Endpoint {Endpoint} returned non-success {StatusCode} in {ElapsedMs:F1}ms",
                endpointName,
                statusCode,
                elapsed.TotalMilliseconds);
        }
        else
        {
            logger.LogInformation(
                "Endpoint {Endpoint} succeeded with {StatusCode} in {ElapsedMs:F1}ms",
                endpointName,
                statusCode,
                elapsed.TotalMilliseconds);
        }

        return result;
    }
}

Pair this filter with ASP.NET Core 11's built-in OpenTelemetry HTTP server span attributes (SuppressActivityOpenTelemetryData now defaults to false), and you get full distributed tracing plus structured outcome logging with zero third-party pipeline abstractions.

5. The Hard Reality: Edge Cases, Gotchas & Limitations

Before rolling C# 15 unions out across a production microservice fleet, make sure your team knows four sharp edges documented in .NET 11.

1. Unions Do NOT Bind from Route, Query, Header, or Form Sources

In ASP.NET Core 11, C# 15 union types bind exclusively from JSON request and response bodies via System.Text.Json. They are not supported on [FromRoute], [FromQuery], [FromHeader], or [FromForm].

public union OrderLookupKey(Guid, string);

// BROKEN: Minimal API route/query binding cannot bind C# 15 unions!
// app.MapGet("/orders/{key}", (OrderLookupKey key) => ...);

// WORKING: Bind a string from the route and parse into your union explicitly
app.MapGet("/orders/{rawKey}", (string rawKey) =>
{
    OrderLookupKey key = Guid.TryParse(rawKey, out Guid id) ? id : rawKey;
    return Results.Ok(key.ToString());
});

Why? Non-body inputs arrive as flat raw strings (?key=12345) without JSON token metadata, so the model binder cannot tell whether "12345" should become an int or a string inside union IntOrString(int, string). Keep unions in JSON bodies and handler return types; bind route and query values as concrete scalars or custom IParsable<T> types.

2. Deserializing Inbound Unions with Ambiguous JSON Object Cases

Serializing an outbound union works effortlessly because System.Text.Json writes only the active case value directly—no $type wrapper needed. Deserializing an inbound request union is trickier when multiple cases are JSON objects ({ ... }), because JsonTokenType.StartObject alone doesn't identify which C# record to instantiate.

In .NET 11, opt into structural classification via JsonUnionTypeStructuralClassifier:

using System.Text.Json.Serialization;

[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union PaymentMethodInput(CardPayment, BankTransfer);

[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record CardPayment(
    required string CardToken,
    required int ExpiryMonth,
    required int ExpiryYear);

[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record BankTransfer(
    required string Iban,
    required string Bic);

Structural Classifier Boundary:
JsonUnionTypeStructuralClassifier only inspects root-level property presence (relying on required properties and JsonUnmappedMemberHandling.Disallow) to eliminate non-matching cases until a single match remains. It cannot inspect property values, nested child objects, or array elements. If two inbound DTOs share identical root property names, use a closed record with [JsonPolymorphic] and an explicit $type discriminator instead.

3. Database Transaction Rollbacks When No Exception Is Thrown

This is the #1 bug that catches teams migrating off MediatR + exceptions. In legacy Clean Architecture, a TransactionBehavior typically opened a database transaction, called await next(), and unconditionally called await tx.CommitAsync()—relying on thrown exceptions like PaymentFailedException to skip CommitAsync() and trigger an automatic rollback on dispose.

Silent Commit Trap: When a handler returns a failure union case instead of throwing an exception, await next(context) completes normally! A naive transaction wrapper will commit dirty intermediate writes right alongside a 409 Conflict or 422 Unprocessable Entity response.

Follow two rules in EF Core 11:

  • Prefer single-SaveChangesAsync handlers: As demonstrated in CreateOrderHandler above, validate invariants first and call await db.SaveChangesAsync(ct) once on the success path—EF Core 11 already wraps a single SaveChangesAsync inside an implicit database transaction.
  • Make multi-step transaction filters status-code aware: Whenever a slice genuinely requires an explicit transaction across multiple SaveChangesAsync calls, inspect the mapped HTTP status code before committing:
namespace Store.Api.Common.Filters;

public sealed class TransactionFilter(AppDbContext db) : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var strategy = db.Database.CreateExecutionStrategy();

        return await strategy.ExecuteAsync(async () =>
        {
            await using var tx = await db.Database.BeginTransactionAsync(
                context.HttpContext.RequestAborted);

            var result = await next(context);

            int statusCode = (result as IStatusCodeHttpResult)?.StatusCode
                             ?? context.HttpContext.Response.StatusCode;

            // Only commit on 2xx/3xx; roll back if the union mapped to 4xx/5xx!
            if (statusCode is >= 200 and < 400)
            {
                await tx.CommitAsync(context.HttpContext.RequestAborted);
            }
            else
            {
                await tx.RollbackAsync(context.HttpContext.RequestAborted);
            }

            return result;
        });
    }
}

4. Value-Type Boxing in High-Frequency Tight Loops

Shorthand C# 15 union declarations (public union Result(int, string);) lower to a struct implementing IUnion backed by an object? Value field. For HTTP vertical slices returning reference-type record outcomes once per web request, that struct wrapper adds zero heap overhead and is dramatically cheaper than throwing an exception. Only when wrapping readonly record struct cases inside a tight loop running millions of times per second should you write a custom [Union] struct with typed TryGetValue(out T) methods to avoid boxing (covered with full code in our Top 20 C# 15 and .NET 11 Senior Interview Questions).

Quick Architecture Review Challenge: Spot the Two Production Bugs

Scenario: During a pull request review, a developer migrates a refund slice from MediatR to a C# 15 union. They wrap the endpoint in a legacy BeginTransactionAsync filter that always calls await tx.CommitAsync() after await next(context), and in the endpoint's switch expression they end with _ => TypedResults.StatusCode(500) "just to be safe." What two bugs will hit production?

Check Your Diagnosis:

1. Silent compiler blindness: Adding the _ => discard arm disables compiler warning CS8509! Next month, when someone adds RefundWindowExpired to RefundOrderResult, the build will succeed silently and return false 500 Internal Server Error alerts in production instead of 409 Conflict.

2. Dirty database commits on business failure: Because returning RefundWindowExpired completes await next(context) without throwing an exception, the naive filter runs await tx.CommitAsync() and permanently commits any intermediate EF Core 11 state changes alongside an error HTTP status code.

6. Production Migration Checklist: Moving Legacy MediatR Slices to C# 15 Unions

Don't halt product delivery for a three-month "architecture rewrite." Migrate slice by slice alongside normal sprint work using these seven steps:

  1. Escalate CS8509 to a build error first: Add <WarningsAsErrors>$(WarningsAsErrors);CS8509</WarningsAsErrors> to Directory.Build.props before writing your first union so compiler exhaustiveness is enforced from day one.
  2. Enable builder.Services.AddValidation(): Replace MediatR's ValidationBehavior + FluentValidation on new Minimal API routes with built-in DataAnnotations and AsyncValidationAttribute (calling AddValidation() in every referenced endpoint assembly).
  3. Move cross-cutting behaviors to a root MapGroup: Port logging, correlation IDs, and status-aware transactions from IPipelineBehavior<,> into IEndpointFilter classes attached to app.MapGroup("/api").
  4. Convert your hottest exception-throwing command first: Check OpenTelemetry or Application Insights for the business exception thrown most often under peak traffic—typically inventory checks, coupon rules, or idempotency conflicts—and migrate that slice first for an immediate P99 latency win.
  5. Define a feature-scoped C# 15 union per write slice: Avoid creating one giant GlobalAppError union shared across 80 endpoints. Keep CreateOrderResult local to Features/Orders/CreateOrder/ so each endpoint's OpenAPI 3.2 spec documents only the failure cases it can actually return.
  6. Delete dead business exceptions from global middleware: Once all callers of OrderOutOfStockException return union cases mapped to TypedResults, delete the exception class so nobody reintroduces throw by copy-pasting legacy handlers. Reserve IExceptionHandler strictly for unexpected bugs and infrastructure faults.
  7. Remove MediatR and OneOf / FluentResults once the last handler moves: Drop the NuGet packages and MEDIATR_LICENSE_KEY configuration as soon as the final IRequestHandler is converted to a plain scoped handler.

7. Frequently Asked Questions

Should I stop using exceptions completely in .NET 11?

No—only stop using exceptions for expected business outcomes such as out-of-stock items, declined payments, duplicate keys, or validation errors. Keep exceptions for genuine infrastructure and programming faults like dropped database connections, unhandled NullReferenceException bugs, or missing startup configuration, and let ASP.NET Core's global IExceptionHandler log them and return a safe 500 ProblemDetails response.

When should I choose a C# 15 union over a closed class hierarchy?

Choose a C# 15 union when grouping independent data shapes—such as Vertical Slice command outcomes (OrderCreated, ValidationError, InsufficientInventory, PaymentDeclined)—that share no base state or methods and serialize cleanly without a $type discriminator. Choose a closed class or closed record when modeling an object-oriented domain hierarchy inside a single assembly where subtypes share base properties, virtual methods, or [JsonPolymorphic] serialization.

Does replacing MediatR with direct DI handlers violate Clean Architecture or SOLID?

Not at all. Injecting a focused, single-purpose CreateOrderHandler directly into an endpoint honors Single Responsibility and Dependency Inversion without routing every call through an in-process service-locator mediator. In Vertical Slice Architecture, high cohesion inside a feature folder and low coupling across separate feature folders keep large codebases easy to change and test.

Why doesn't my C# 15 union bind from a Minimal API route or query parameter?

ASP.NET Core 11 binds C# 15 union types exclusively from JSON request bodies via System.Text.Json. Route, query, header, and form inputs arrive as flat strings without JSON token metadata, making unions like union Id(int, string) ambiguous. Bind route or query parameters as string (or a custom IParsable<T> type) and construct the union explicitly inside your endpoint.

How do EF Core 11 transactions roll back if a Vertical Slice handler returns an error union instead of throwing?

Because returning a failure union case completes normally without throwing an exception, a naive BeginTransactionAsync filter would commit partial writes. Structure your handler so all domain checks run before a single atomic SaveChangesAsync() call, or inspect IStatusCodeHttpResult.StatusCode inside a Minimal API IEndpointFilter and call RollbackAsync() whenever the mapped HTTP status code is 400 or higher.

Test Your .NET 11 & C# 15 Architectural Mastery

Now that you've seen how C# 15 unions, Minimal API validation, OpenAPI 3.2, and EF Core 11 fit together in a real Vertical Slice architecture, put your readiness to the test with realistic senior interview questions and scenario-based practice tests:

Source:
Microsoft Learn — What's New in C# 15 (Union Types & Closed Hierarchies) | What's New in ASP.NET Core 11 | Validation in ASP.NET Core | OpenAPI 3.2 in ASP.NET Core 11 | Filters in Minimal APIs | What's New in EF Core 11

About the author

MOHAMMED KADI
Software Engineer. Passionate about IT certifications, automation, and building scalable tech solutions.

Post a Comment

Welcome to Iwalen.com! If you have any questions or need assistance with any of our resources, feel free to ask. Please keep the discussion professional and avoid posting external links. All comments are moderated to ensure a high-quality community experience.