.NET Programming With Me

Boxing and Unboxing in C#: The Silent Performance Tax You Might Be Paying

Boxing and unboxing are two of the most common sources of silent, hard-to-diagnose performance regressions in .NET applications. They do not throw exceptions, they do not show up in compiler warnings, and they will not break your unit tests. They simply make your application slower, one heap allocation at a time.

By the end of this post you will understand exactly what boxing and unboxing are, why they carry a real cost, which everyday code patterns trigger them without you realising it, and how modern C# gives you the tools to avoid them entirely on hot paths.


1 The foundation: value types vs. reference types

Boxing only makes sense once you have a clear mental model of where the runtime stores data. The CLR uses two memory regions with very different characteristics.

The stack

Think of the stack like your desk: fast, tidy, and everything is right in front of you. Value types live here. The variable is the data, stored at a fixed, compile-time-known size. When a method returns, the stack frame is unwound and those values disappear instantly. No garbage collector involved.

Common value types: int, long, double, bool, char, decimal, DateTime, Guid, TimeSpan, and any struct you define yourself.

The heap

The heap is more like a filing cabinet: more room, but you need a label (a reference, or pointer) to find your file. Reference types live here. The variable holds a managed reference to data allocated on the heap, and the garbage collector is responsible for reclaiming that memory when nothing holds a reference to it anymore.

Common reference types: string, object, every class you define, arrays (even int[]), List<T>, Task, and Delegate.

// Value types: stored directly on the stack
int    count   = 42;
double price   = 19.99;
bool   isReady = true;

// Copying a value type produces independent copies
int a = 10;
int b = a;
b = 99;
Console.WriteLine(a); // 10 — a is unchanged

// Reference types: stack holds a pointer; data lives on the heap
string    name    = "Alice";
List<int> numbers = new() { 1, 2, 3 };

// Copying a reference type copies the pointer, not the data
var list1 = new List<int> { 1, 2, 3 };
var list2 = list1;   // same heap object!
list2.Add(4);
Console.WriteLine(list1.Count); // 4 — both variables see the same list

This distinction is the prerequisite for everything that follows. Boxing is what happens when the runtime is forced to treat a value type as a reference type.


2 Boxing: wrapping a value in an object

Boxing is the implicit conversion of a value type to object (or to an interface the value type implements). When it happens, the runtime allocates a new object on the heap, copies the value into it, and returns a reference to that object. The original stack value and the new heap object are completely independent from that point on.

The key word is implicit. The compiler inserts the boxing instruction for you with no visible cast syntax, which is exactly what makes accidental boxing so easy to miss.

// Implicit boxing: no cast keyword, but a heap allocation happens here
int    num = 42;
object o   = num;  // 1. allocates object on heap
                   // 2. copies 42 into it
                   // 3. o holds a reference to the new object

// Boxing to an interface also allocates
IComparable comp = num; // boxes — IComparable is a reference type

// The two copies are now independent
int x     = 10;
object bx = x;
x = 99;
Console.WriteLine(bx); // still 10 — bx points to a separate heap object

// Legacy collections box every element they store
ArrayList old = new ArrayList();
old.Add(1);   // boxes 1   → new heap object
old.Add(2);   // boxes 2   → new heap object
old.Add(3);   // boxes 3   → new heap object (3 allocations for 3 ints)
Performance note: Every boxing operation is a heap allocation. In a tight loop that runs a million times, that is a million objects queued for garbage collection. The GC will eventually collect them, but the collection pauses and the memory pressure accumulate silently and degrade throughput in ways that are very difficult to attribute to a root cause after the fact.

3 Unboxing: extracting the value back out

Unboxing is the reverse operation: extracting a value type from a boxed object. Unlike boxing, unboxing is never implicit. You must write an explicit cast. The runtime validates the cast at execution time, not at compile time, which means a wrong cast produces an InvalidCastException at runtime rather than a build error.

// Step 1: box an int
int    original = 42;
object boxed    = original;  // boxing

// Step 2: unbox with the exact original type
int extracted = (int)boxed; // correct
Console.WriteLine(extracted); // 42

// Step 3: wrong cast type throws at runtime, not at compile time
object o = 42; // was boxed as int
try
{
    double wrong = (double)o; // InvalidCastException!
}
catch (InvalidCastException ex)
{
    Console.WriteLine(ex.Message);
}

// Step 4: safe unboxing with pattern matching (modern C#, preferred)
object result = GetFromLegacyApi(); // returns object, type unknown

if (result is int value)
{
    Console.WriteLine($"Got int: {value}"); // safe and concise
}

// 'as' with Nullable<T> — also safe, returns null on mismatch
int? safe = result as int?;
if (safe.HasValue)
    Console.WriteLine(safe.Value);
Critical trap: The unboxing cast must match the exact type used during boxing, not a compatible one. A value boxed as int cannot be unboxed as long, even though an int normally widens to long without issue. The runtime checks the type metadata stamped on the heap object, not numeric compatibility. Prefer is int value pattern matching over a bare cast whenever the type is not guaranteed.

4 Pitfalls: where boxing hides in everyday code

The four patterns below are the most common sources of unintentional boxing in production .NET code. Each one compiles cleanly and runs correctly; the cost is purely in allocation count and GC pressure.

1. Legacy non-generic collections

// Every Add call boxes the integer — avoid
var bad = new ArrayList();
bad.Add(1);
bad.Add(2);
bad.Add(3);

// No boxing; values stored inline in the backing array
var good = new List<int> { 1, 2, 3 };

2. String.Format with value-type arguments

int age = 30;

// string.Format accepts object[] — every value-type argument is boxed
var s1 = string.Format("Age: {0}", age); // boxes age

// String interpolation compiles to FormattableString/string.Create paths
// that avoid boxing for known types
var s2 = $"Age: {age}"; // no boxing

3. Boxing inside a hot loop

// 1,000,000 heap allocations — do not do this on a hot path
for (int i = 0; i < 1_000_000; i++)
{
    object o = i;    // boxes every iteration
    Process(o);
}

// Use a generic method instead — zero allocation
for (int i = 0; i < 1_000_000; i++)
{
    Process(i);      // T is inferred as int; no boxing
}

static void Process<T>(T value) => Console.WriteLine(value);

4. Casting a struct to an interface it implements

struct Temperature : IComparable<Temperature>
{
    public double Celsius { get; init; }
    public int CompareTo(Temperature other) => Celsius.CompareTo(other.Celsius);
}

// Storing as the interface boxes the struct
IComparable<Temperature> t1 = new Temperature { Celsius = 22.5 }; // boxed!

// Keep the concrete type; use the interface only in generic constraints
Temperature t2 = new Temperature { Celsius = 22.5 };               // no boxing

5 Writing boxing-aware code every day

The good news is that modern C# makes the boxing-free path the natural one. These five habits cover the vast majority of cases.

Always use generic collections

// Reach for these in all new code
List<int>                ids;
Dictionary<string, int>  scores;
HashSet<Guid>            seen;
Queue<DateTime>          events;
Stack<decimal>           prices;

// ArrayList and Hashtable exist only for pre-2.0 interop; do not use them in new code

Write generic methods instead of object parameters

// Forces boxing for any value-type argument
void Log(object value) => Console.WriteLine(value);

// No boxing; the JIT specialises the method per type
void Log<T>(T value) => Console.WriteLine(value?.ToString());

// With a numeric constraint — .NET 7+
T Square<T>(T x) where T : INumber<T> => x * x;

Use Span<T> for high-performance slicing

int[] data = { 1, 2, 3, 4, 5 };

// AsSpan returns a stack-only view — no allocation, no boxing
Span<int> slice = data.AsSpan(1, 3);
foreach (var item in slice)
    Console.WriteLine(item); // 2, 3, 4

Prefer pattern matching for safe unboxing of unknown types

// Common in plugin systems, legacy APIs, or deserialization scenarios
object result = plugin.Execute();

switch (result)
{
    case int n:
        ProcessNumber(n);
        break;
    case string s:
        ProcessText(s);
        break;
    case null:
        HandleEmpty();
        break;
    default:
        throw new NotSupportedException($"Unexpected type: {result.GetType().Name}");
}

Know when boxing is legitimate

Not every boxing site is a problem. These scenarios are genuinely fine:

// COM interop: the API requires object
xlRange.Value2 = (object)42;

// Reflection: GetValue always returns object
var val = fieldInfo.GetValue(instance);

// Heterogeneous data (rare, but sometimes the right model)
object[] mixed = { 42, "hello", true, 3.14 };

// Low-frequency startup configuration: the GC cost is negligible
settings["timeout"] = (object)30;
Tooling tip: JetBrains Rider and the Heap Allocation Viewer extension for Visual Studio surface boxing sites as inline hints. BenchmarkDotNet with [MemoryDiagnoser] will give you exact allocation counts per benchmark iteration, which makes it easy to confirm a fix actually eliminated the allocation.

Wrapping up

Boxing converts a value type to a heap-allocated object implicitly and silently; unboxing reverses that with an explicit cast that can fail at runtime if the type does not match exactly. Neither operation announces itself in compiler output, which is what makes them worth understanding at a conceptual level rather than just memorising a list of rules.

The practical takeaway is straightforward: use generic collections, write generic methods, lean on pattern matching for unknown types, and reach for Span<T> on hot paths. With those habits in place, boxing becomes something you deliberately opt into for COM interop or reflection, rather than something that accumulates quietly across your codebase. A follow-up post on readonly struct and ref struct is a natural next step for anyone who wants to push further into allocation-free patterns.

Got a question or ran into a problem? Drop a comment below and I will reply.

Versioning Your ASP.NET Core API Without Breaking Existing Clients

Sooner or later every API that has real consumers needs to change in ways that are not backward-compatible. You rename a field, remove a property, change a response shape, or restructure a route. Without versioning, any of those changes breaks existing clients immediately. With versioning, you introduce the new contract alongside the old one, communicate a deprecation timeline, and let clients migrate at a pace that works for them.

By the end of this post you will have a .NET 8 Web API that supports URL segment versioning, query string versioning, and custom header versioning simultaneously using the official Asp.Versioning.Http library. You will also see how to mark versions as deprecated so API consumers get a clear signal that they need to migrate.


1 Prerequisites

Before starting, make sure you have:

  • .NET 8 SDK (8.0.100 or later)
  • An existing ASP.NET Core Web API project targeting .NET 8
  • NuGet package: Asp.Versioning.Mvc (for controller-based APIs) or Asp.Versioning.Http (for Minimal APIs)
Note: The Asp.Versioning.* packages are the official successor to the archived Microsoft.AspNetCore.Mvc.Versioning packages. If your project still references the old packages, plan a migration; they are no longer maintained and do not support .NET 7 or .NET 8 correctly.

2 Install and configure versioning

Add the package to your API project and register the versioning services in Program.cs.

// From the terminal — add the NuGet package
// dotnet add package Asp.Versioning.Mvc
// dotnet add package Asp.Versioning.Mvc.ApiExplorer   // for Swagger integration
// Program.cs — versioning service registration
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion          = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions          = true;             // adds api-supported-versions header
    options.ApiVersionReader           = ApiVersionReader.Combine(
        new UrlSegmentApiVersionReader(),
        new HeaderApiVersionReader("X-Api-Version"),
        new QueryStringApiVersionReader("api-version")
    );
})
.AddApiExplorer(options =>
{
    options.GroupNameFormat           = "'v'VVV";
    options.SubstituteApiVersionInUrl = true;
});

var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

The ApiVersionReader.Combine call means ASP.NET Core will accept the version from a URL segment, a custom header, or a query string. If more than one is present, the URL segment takes precedence. ReportApiVersions adds api-supported-versions and api-deprecated-versions response headers so clients can discover what is available without reading documentation.


3 Version your controllers with URL segments

URL segment versioning is the most explicit strategy and the one most API consumers expect to see: /api/v1/products, /api/v2/products. Each major version lives in its own controller class inside a versioned namespace.

// Controllers/V1/ProductsController.cs
namespace Catalog.Api.Controllers.V1;

[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _service;

    public ProductsController(IProductService service)
        => _service = service;

    [HttpGet("{id:int}")]
    [ProducesResponseType(typeof(ProductResponseV1), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult<ProductResponseV1>> GetById(
        int id, CancellationToken ct)
    {
        var product = await _service.GetByIdAsync(id, ct);
        return product is null ? NotFound() : Ok(ProductResponseV1.From(product));
    }
}
// Controllers/V2/ProductsController.cs
namespace Catalog.Api.Controllers.V2;

[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _service;

    public ProductsController(IProductService service)
        => _service = service;

    [HttpGet("{id:int}")]
    [ProducesResponseType(typeof(ProductResponseV2), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult<ProductResponseV2>> GetById(
        int id, CancellationToken ct)
    {
        var product = await _service.GetByIdAsync(id, ct);
        return product is null ? NotFound() : Ok(ProductResponseV2.From(product));
    }
}

Separating each version into its own namespace keeps the folder structure self-documenting. A developer navigating to Controllers/V2/ immediately knows they are looking at the current contract.

Tip: Create separate response DTO types per version rather than adding optional fields to a single type. Separate DTOs make it obvious what changed, keep serialization clean, and avoid nullable sprawl in your shared models.

4 Support header and query string versioning on the same endpoints

Because ApiVersionReader.Combine is already registered, no additional controller changes are required. A client can reach the same v2 endpoint three ways.

// Three equivalent requests for the v2 endpoint:

// 1. URL segment (most explicit — preferred)
// GET /api/v2/products/42

// 2. Query string (useful for quick testing and exploration)
// GET /api/products/42?api-version=2.0

// 3. Custom header (common in B2B integrations where the URL must not change)
// GET /api/products/42
// X-Api-Version: 2.0

When none of the three is supplied and AssumeDefaultVersionWhenUnspecified is true, ASP.NET Core uses the default version defined in DefaultApiVersion, which is 1.0 in this configuration. Clients that have never heard of versioning continue to work against the v1 contract without modification.


5 Deprecate old versions gracefully

Marking a version as deprecated does not remove it. It signals to consumers through response headers that the version has an end-of-life date and they should plan to migrate. The endpoint continues to function normally.

// Controllers/V1/ProductsController.cs — mark version 1.0 as deprecated
[ApiController]
[ApiVersion("1.0", Deprecated = true)]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase
{
    // ... existing v1 actions unchanged
}

With Deprecated = true and ReportApiVersions = true, every response to a v1 endpoint now includes both headers, giving clients a clear machine-readable signal.

// Response headers returned on any v1 endpoint call:
// api-supported-versions: 2.0
// api-deprecated-versions: 1.0

Complement the headers with a Sunset header indicating the retirement date. You can inject this via middleware or by overriding the response in a filter.

// Api/Filters/SunsetHeaderFilter.cs
public class SunsetHeaderFilter : IActionFilter
{
    private static readonly DateTimeOffset V1Sunset =
        new DateTimeOffset(2025, 12, 31, 0, 0, 0, TimeSpan.Zero);

    public void OnActionExecuting(ActionExecutingContext context) { }

    public void OnActionExecuted(ActionExecutedContext context)
    {
        var requestedVersion = context.HttpContext
            .GetRequestedApiVersion()?.ToString();

        if (requestedVersion == "1.0")
        {
            context.HttpContext.Response.Headers["Sunset"] =
                V1Sunset.ToString("R");   // RFC 1123 date format
        }
    }
}

// Program.cs — register as a global filter
builder.Services.AddControllers(options =>
    options.Filters.Add<SunsetHeaderFilter>());

6 Running and testing it

Run the project and confirm that the correct controller action is invoked based on the version provided.

// dotnet run --project Catalog.Api

// URL segment — should return v2 response shape
// GET https://localhost:7001/api/v2/products/1

// Query string — should return v1 response shape with deprecation headers
// GET https://localhost:7001/api/products/1?api-version=1.0

// No version — should return v1 response (default) with deprecation headers
// GET https://localhost:7001/api/products/1

// Unsupported version — should return 400 with Problem Details
// GET https://localhost:7001/api/v99/products/1

When a client requests a version that no controller supports, ASP.NET Core returns a 400 Bad Request with a Problem Details body automatically. No additional error handling code is needed.

// Integration test verifying version routing
[Theory]
[InlineData("/api/v1/products/1", "1.0")]
[InlineData("/api/v2/products/1", "2.0")]
public async Task GetById_RoutesToCorrectVersion(string url, string expectedVersion)
{
    await using var factory = new WebApplicationFactory<Program>();
    var client = factory.CreateClient();

    var response = await client.GetAsync(url);

    Assert.True(response.IsSuccessStatusCode);

    // Confirm the supported version header is present and correct
    Assert.True(response.Headers.Contains("api-supported-versions"));
}

Wrapping up

A versioned .NET 8 Web API needs four things: the Asp.Versioning.Mvc package, a versioning configuration that accepts the version from URL segments, headers, and query strings, controller classes organized by version namespace, and the Deprecated flag on any version that has an end-of-life date. Together these give you a versioning strategy that is transparent to consumers, easy to extend, and capable of signaling retirement without breaking anything.

This post completes the ASP.NET Core Controller API Patterns series. The patterns covered across these posts, from routing model selection through project structure, validation, error handling, and versioning, form a baseline that scales from a small internal API to a multi-team public contract.

Got a question or ran into a problem? Drop a comment below and I will reply.

Error Handling and Problem Details in ASP.NET Core Controller APIs

Returning a raw 500 with an exception stack trace is not an error handling strategy; it is an absence of one. API consumers need consistent, machine-readable error responses so they can distinguish a "not found" from a "validation failure" from a genuine server fault and react accordingly. ASP.NET Core has supported the Problem Details standard since .NET 5, and in .NET 7 and .NET 8 the built-in support became comprehensive enough that you no longer need a third-party library to get it right.

By the end of this post you will have a working error handling pipeline that maps domain exceptions to Problem Details responses, produces consistent JSON for validation errors and unhandled exceptions, and never leaks internal stack traces to API clients.


1 Prerequisites

Before starting, make sure you have:

  • .NET 8 SDK (8.0.100 or later)
  • An existing ASP.NET Core Web API project targeting .NET 8
  • Basic familiarity with middleware and the ASP.NET Core request pipeline
Note: The AddProblemDetails and UseExceptionHandler APIs described here are available from .NET 7 onward. On .NET 6 you will need a slightly different setup; the middleware exists but the unified IProblemDetailsService abstraction does not.

2 What Problem Details actually is

Problem Details is a machine-readable format for HTTP error responses defined in RFC 9457 (which supersedes the earlier RFC 7807). An ASP.NET Core controller decorated with [ApiController] already returns validation errors in this format automatically. The goal of this post is to extend that same format to all other error conditions: domain exceptions, authorization failures, and unhandled exceptions.

A minimal Problem Details response looks like this:

// Typical 404 Problem Details JSON response
// {
//   "type":   "https://tools.ietf.org/html/rfc9110#section-15.5.5",
//   "title":  "Not Found",
//   "status": 404,
//   "detail": "Product with id 42 was not found.",
//   "traceId": "00-a1b2c3d4e5f6a7b8-c9d0e1f2a3b4c5d6-00"
// }

The type field is a URI that identifies the problem type. The title is a short, human-readable label. The detail is specific to this occurrence. The traceId field, added automatically by ASP.NET Core, lets you correlate the response to a specific request in your logging system. You can also add custom extension members alongside these standard fields.


3 Register the built-in Problem Details service

Add AddProblemDetails and configure UseExceptionHandler to point at the built-in Problem Details pipeline. This is the minimum setup that gives you consistent JSON errors for unhandled exceptions.

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddProblemDetails();                    // register IProblemDetailsService

var app = builder.Build();

app.UseExceptionHandler();                               // use built-in exception handler
app.UseStatusCodePages();                                // convert 4xx with no body to Problem Details

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

With just these three lines, any unhandled exception that escapes a controller action is caught by UseExceptionHandler and converted to a Problem Details response with a 500 status code. In development the response includes the exception detail and stack trace; in production it is suppressed automatically.

Important: Call app.UseExceptionHandler() before any other middleware that produces responses. If you call it after app.MapControllers(), it will never intercept exceptions from controller actions.

4 Define domain exceptions and map them to status codes

The built-in handler covers unhandled exceptions with a 500. What it does not do by default is map your domain-specific exceptions, such as a NotFoundException, to the correct HTTP status code. The cleanest way to handle this is a custom IExceptionHandler registered before the built-in fallback.

// Exceptions/NotFoundException.cs
public class NotFoundException : Exception
{
    public NotFoundException(string resourceName, object key)
        : base($"{resourceName} with id {key} was not found.")
    {
        ResourceName = resourceName;
        Key = key;
    }

    public string ResourceName { get; }
    public object Key { get; }
}

// Exceptions/ConflictException.cs
public class ConflictException : Exception
{
    public ConflictException(string message) : base(message) { }
}

// Exceptions/ValidationException.cs — distinct from FluentValidation's own type
public class DomainValidationException : Exception
{
    public DomainValidationException(string message) : base(message) { }
}
// Infrastructure/ExceptionHandlers/DomainExceptionHandler.cs
public class DomainExceptionHandler : IExceptionHandler
{
    private readonly IProblemDetailsService _problemDetails;

    public DomainExceptionHandler(IProblemDetailsService problemDetails)
        => _problemDetails = problemDetails;

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken ct)
    {
        (int statusCode, string title) = exception switch
        {
            NotFoundException nfe     => (StatusCodes.Status404NotFound,     "Resource not found"),
            ConflictException         => (StatusCodes.Status409Conflict,     "Conflict"),
            DomainValidationException => (StatusCodes.Status422UnprocessableEntity, "Validation error"),
            _                         => (0, string.Empty)   // not handled here
        };

        if (statusCode == 0)
            return false;  // let the next handler take it

        httpContext.Response.StatusCode = statusCode;

        return await _problemDetails.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            Exception   = exception,
            ProblemDetails = new ProblemDetails
            {
                Status = statusCode,
                Title  = title,
                Detail = exception.Message
            }
        });
    }
}
// Program.cs — register before AddProblemDetails fallback
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
builder.Services.AddProblemDetails();

ASP.NET Core calls IExceptionHandler implementations in registration order. If DomainExceptionHandler returns false, the next handler in the chain gets the exception. The built-in fallback added by UseExceptionHandler() is always last, so unrecognized exceptions always get a 500 response.


5 Customize the Problem Details output

If you need to add extension fields to every Problem Details response, such as a correlation ID, an error code, or an environment name, configure the AddProblemDetails options delegate.

// Program.cs — custom Problem Details fields
builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions["traceId"] =
            Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;

        ctx.ProblemDetails.Extensions["requestId"] =
            ctx.HttpContext.TraceIdentifier;

        if (ctx.Exception is NotFoundException nfe)
        {
            ctx.ProblemDetails.Extensions["resource"] = nfe.ResourceName;
            ctx.ProblemDetails.Extensions["key"]      = nfe.Key;
        }
    };
});

The CustomizeProblemDetails delegate runs for every Problem Details response, including automatic validation error responses from [ApiController]. This is the single place to add cross-cutting fields without modifying any handler.


6 Running and testing the error responses

Call an endpoint that throws a NotFoundException and inspect the response body to confirm the Problem Details format is correct.

// Controllers/ProductsController.cs — throw domain exception
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductResponse>> GetById(int id, CancellationToken ct)
{
    var product = await _productService.GetByIdAsync(id, ct);

    if (product is null)
        throw new NotFoundException(nameof(Product), id);

    return Ok(product);
}
// Expected JSON response for GET /api/products/999 (non-existent)
// HTTP 404
// {
//   "type":      "https://tools.ietf.org/html/rfc9110#section-15.5.5",
//   "title":     "Resource not found",
//   "status":    404,
//   "detail":    "Product with id 999 was not found.",
//   "traceId":   "00-a1b2c3d4e5f6a7b8-c9d0e1f2a3b4c5d6-00",
//   "resource":  "Product",
//   "key":       999
// }

Write an integration test against WebApplicationFactory<T> to ensure the error shape does not regress as the codebase evolves.

// Tests/ErrorHandlingIntegrationTests.cs
[Fact]
public async Task GetById_WithNonExistentId_ReturnsProblemDetails404()
{
    await using var factory = new WebApplicationFactory<Program>();
    var client = factory.CreateClient();

    var response = await client.GetAsync("/api/products/999999");

    Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);

    var body = await response.Content.ReadFromJsonAsync<ProblemDetails>();
    Assert.NotNull(body);
    Assert.Equal(404, body.Status);
    Assert.Equal("Resource not found", body.Title);
}

Wrapping up

A complete error handling pipeline in .NET 8 needs three things: AddProblemDetails to register the service, UseExceptionHandler to catch unhandled exceptions, and one or more IExceptionHandler implementations to map domain exceptions to the right HTTP status codes. The CustomizeProblemDetails delegate handles any cross-cutting fields you need in every response.

The next post in this series covers API versioning: how to support multiple versions of your API simultaneously without breaking existing clients and without duplicating controller code.

Got a question or ran into a problem? Drop a comment below and I will reply.

Validation in ASP.NET Core: FluentValidation vs. Data Annotations

Validation is one of the first real decisions you make when building an ASP.NET Core API, and the answer is almost always one of two options: Data Annotations or FluentValidation. Both plug into the ASP.NET Core model binding pipeline, both return 400 responses with structured error details when validation fails, and both have years of production use behind them. What differs is where the rules live, how complex they can get, and how easily you can test them in isolation.

Data Annotations are attributes placed directly on your request DTO properties. FluentValidation is a separate library where validation rules live in dedicated validator classes. This post compares both approaches using the same request model, walks through five tradeoffs, and gives you a clear framework for choosing the right tool for your project.

By the end you will have working code for both approaches, an understanding of where each breaks down, and a decision you can commit to before you write your first endpoint.


1 A quick history first

Data Annotations ship with the .NET base class library under System.ComponentModel.DataAnnotations. They predate ASP.NET Core by over a decade, originating in WPF and WCF validation scenarios. In ASP.NET Core, the [ApiController] attribute triggers automatic model state validation before the action method is called, so invalid requests are rejected without any filter or explicit check in the action body.

FluentValidation is a popular open-source library by Jeremy Skinner, available on NuGet as FluentValidation.AspNetCore. It integrates with ASP.NET Core's model validation pipeline through an adapter, so it participates in the same automatic validation behavior that Data Annotations use. Rules are expressed in C# via a fluent builder API inside a dedicated validator class, keeping the DTO free of attributes.

Both libraries ultimately populate ModelState and produce the same 400 response shape when integrated correctly. The difference is entirely in developer ergonomics and long-term maintainability.


2 The same task, two ways

Consider a CreateProductRequest that requires a non-empty name, a positive price, a stock count between zero and ten thousand, and an optional description capped at 500 characters.

Data Annotations approach

// Models/Requests/CreateProductRequest.cs
public class CreateProductRequest
{
    [Required(ErrorMessage = "Name is required.")]
    [MaxLength(200, ErrorMessage = "Name cannot exceed 200 characters.")]
    public string Name { get; set; } = string.Empty;

    [Range(0.01, 999999.99, ErrorMessage = "Price must be between 0.01 and 999,999.99.")]
    public decimal Price { get; set; }

    [Range(0, 10000, ErrorMessage = "Stock must be between 0 and 10,000.")]
    public int Stock { get; set; }

    [MaxLength(500, ErrorMessage = "Description cannot exceed 500 characters.")]
    public string? Description { get; set; }
}

FluentValidation approach

// Models/Requests/CreateProductRequest.cs — no attributes
public class CreateProductRequest
{
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public string? Description { get; set; }
}

// Validators/CreateProductRequestValidator.cs
public class CreateProductRequestValidator
    : AbstractValidator<CreateProductRequest>
{
    public CreateProductRequestValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Name is required.")
            .MaximumLength(200).WithMessage("Name cannot exceed 200 characters.");

        RuleFor(x => x.Price)
            .InclusiveBetween(0.01m, 999_999.99m)
            .WithMessage("Price must be between 0.01 and 999,999.99.");

        RuleFor(x => x.Stock)
            .InclusiveBetween(0, 10_000)
            .WithMessage("Stock must be between 0 and 10,000.");

        RuleFor(x => x.Description)
            .MaximumLength(500)
            .WithMessage("Description cannot exceed 500 characters.")
            .When(x => x.Description is not null);
    }
}

Register FluentValidation in Program.cs so it participates in automatic model validation alongside Data Annotations.

// Program.cs — FluentValidation registration
builder.Services.AddControllers();
builder.Services.AddValidatorsFromAssemblyContaining<CreateProductRequestValidator>();

// If you want FluentValidation to replace Data Annotations in ModelState:
builder.Services.AddFluentValidationAutoValidation()
                .AddFluentValidationClientsideAdapters();

Both approaches cause ASP.NET Core to return a 400 with a structured error body when the incoming request fails validation. The controller action is never reached for invalid input.


3 The real tradeoffs

1. Separation of concerns

Data Annotations embed validation rules directly on the model class as attributes. This is convenient for simple cases but couples the model's shape to its validation policy. If the same DTO is used in different contexts where different rules should apply (create vs. update, admin vs. regular user), attributes cannot express that distinction without branching into custom attribute implementations.

FluentValidation keeps each set of rules in its own class. You can have CreateProductRequestValidator and AdminCreateProductRequestValidator with different rule sets for the same DTO, registered conditionally. The model stays a plain data carrier.

2. Complex and conditional validation

This is where Data Annotations reach their practical ceiling. Rules that depend on the values of multiple properties, on data retrieved from a database, or on conditional logic require custom attribute classes that become non-trivial to write and test.

// FluentValidation handles multi-property and async rules cleanly
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
    private readonly IProductRepository _products;

    public CreateOrderRequestValidator(IProductRepository products)
        => _products = products;

    public CreateOrderRequestValidator()
    {
        RuleFor(x => x.DeliveryDate)
            .GreaterThan(x => x.OrderDate)
            .WithMessage("Delivery date must be after the order date.");

        RuleFor(x => x.ProductId)
            .MustAsync(async (id, ct) => await _products.ExistsAsync(id, ct))
            .WithMessage("The specified product does not exist.");
    }
}

Data Annotations cannot express either of those rules without a custom attribute that takes constructor parameters, which quickly becomes awkward to maintain and impossible to inject services into cleanly.

3. Reusability and composition

FluentValidation supports reusing rule sets via SetValidator and the ChildRules pattern. If a dozen request models all share an address sub-object, one AddressValidator class can be composed into each parent validator. Data Annotations on nested objects require the [ValidateNever] and [ValidateAlways] markers plus explicit recursion to achieve the same effect.

// FluentValidation composition example
public class AddressValidator : AbstractValidator<AddressDto>
{
    public AddressValidator()
    {
        RuleFor(x => x.Street).NotEmpty().MaximumLength(200);
        RuleFor(x => x.PostCode).NotEmpty().Matches(@"^\d{4,10}$");
        RuleFor(x => x.CountryCode).NotEmpty().Length(2);
    }
}

public class CreateCustomerRequestValidator
    : AbstractValidator<CreateCustomerRequest>
{
    public CreateCustomerRequestValidator()
    {
        RuleFor(x => x.Email).NotEmpty().EmailAddress();
        RuleFor(x => x.ShippingAddress).SetValidator(new AddressValidator());
        RuleFor(x => x.BillingAddress).SetValidator(new AddressValidator());
    }
}

4. Error messages and localization

Data Annotations support ErrorMessage string properties and resource-file-based localization through ErrorMessageResourceType and ErrorMessageResourceName. It works, but the setup is verbose and the pattern is unfamiliar to developers who have not worked with .NET resource files before. FluentValidation has a built-in localization pipeline with language packs available as separate NuGet packages, and adding a custom message store is straightforward.

5. Testability

FluentValidation validators are plain classes with no framework dependency. You can instantiate a validator and call ValidateAsync on a test object in a regular xUnit test without any mocking, middleware, or HTTP context.

// Direct unit test of a FluentValidation validator
[Fact]
public async Task Price_BelowMinimum_FailsValidation()
{
    var validator = new CreateProductRequestValidator();
    var request = new CreateProductRequest { Name = "Widget", Price = -1m, Stock = 10 };

    var result = await validator.ValidateAsync(request);

    Assert.False(result.IsValid);
    Assert.Contains(result.Errors,
        e => e.PropertyName == nameof(CreateProductRequest.Price));
}

[Fact]
public async Task ValidRequest_PassesValidation()
{
    var validator = new CreateProductRequestValidator();
    var request = new CreateProductRequest
    {
        Name = "Widget",
        Price = 9.99m,
        Stock = 100
    };

    var result = await validator.ValidateAsync(request);
    Assert.True(result.IsValid);
}

Testing Data Annotations rules in unit tests requires calling Validator.TryValidateObject from System.ComponentModel.DataAnnotations, which is workable but less clean. More commonly, Data Annotations are tested indirectly through integration tests that exercise the full HTTP pipeline.


4 Which one should you choose?

The right choice depends on the complexity of your validation logic and your team's appetite for an additional dependency.

Choose Data Annotations when:

  • Your validation rules are simple: required fields, length limits, range checks, and regex patterns
  • You want zero additional NuGet dependencies and are comfortable keeping rules on the model
  • The project is small or a proof of concept that does not need complex conditional rules
  • Your team is new to .NET and you want to minimize the number of patterns they need to learn at once

Choose FluentValidation when:

  • Any validation rule depends on values from multiple properties or on external data (database lookups, HTTP calls)
  • The same DTO needs different rule sets in different contexts (create vs. update, admin vs. standard user)
  • You want to unit-test validators in isolation without spinning up an HTTP pipeline
  • You are building a multi-team or enterprise API where consistency and composability of validation logic matter
  • You have nested or shared sub-object validation that needs composable validators

A common middle-ground approach is to use Data Annotations for simple structural constraints (non-null, length limits) and FluentValidation for business rule validation (cross-field checks, database existence checks). Both validators run in sequence, and ModelState accumulates all errors from both.


5 Quick reference summary
Concern Data Annotations FluentValidation
Location of rulesOn the model class as attributesSeparate validator class
Simple rulesExcellent; terse and familiarGood; more verbose for trivial rules
Multi-property rulesRequires custom attributesFirst-class; built-in cross-property support
Async validationNot supportedFirst-class via MustAsync
ComposabilityLimited; nested object recursion onlyStrong; SetValidator and child rules
Unit testabilityRequires Validator.TryValidateObjectInstantiate and call directly
External dependenciesNone; ships with .NETFluentValidation NuGet package required
LocalizationResource file-based; verbose setupBuilt-in pipeline; language packs available

Final take

For any API that will run in production with real business logic, FluentValidation is the stronger default. The separation of validation rules from model definitions pays for itself the first time you encounter a conditional rule or a cross-field dependency. Data Annotations remain the right choice for small projects and prototypes where adding a NuGet package and learning a new library is unnecessary overhead.

If your current project uses Data Annotations and you are hitting their limits, you can introduce FluentValidation incrementally: register it alongside existing annotations and migrate validators one DTO at a time.

What's next?
  • Error handling: The next post in this series covers how to use ASP.NET Core's Problem Details standard to return consistent, spec-compliant error responses across your entire API.
  • FluentValidation docs: The official FluentValidation documentation at docs.fluentvalidation.net covers every built-in rule, extension points, and async scenarios with clear examples.
  • Custom validators: Once you are comfortable with the basics, look into AbstractValidator constructor injection to build validators that query your database or call external services during validation.
Got a question or a scenario I did not cover? Drop a comment below and I will reply.

Structuring a .NET 8 Web API

A .NET 8 Web API that starts as a tidy three-file project has a way of becoming a navigational puzzle by the time it reaches twenty endpoints and three developers. The folder structure you choose in week one silently shapes every refactor, every onboarding session, and every pull request review that follows. Getting it right early is far cheaper than untangling it later.

By the end of this post you will have a concrete, battle-tested folder layout, a layer model with clear responsibilities, and a set of naming conventions you can copy directly into your next project or use to evaluate your current one.


1 Prerequisites

Before starting, make sure you have:

  • .NET 8 SDK (8.0.100 or later)
  • Visual Studio 2022 17.8+, JetBrains Rider, or VS Code with the C# Dev Kit extension
  • Familiarity with dependency injection and the ASP.NET Core request pipeline
Note: The folder structure described here targets controller-based APIs. If your team has chosen Minimal APIs, the layer model and naming conventions still apply; only the Controllers folder becomes an Endpoints folder of static extension classes.

2 Start with a solution, not just a project

Even for a single API, wrapping it in a solution from day one pays dividends when you add a test project or an SDK library later. Create the solution structure with the CLI so the layout is deliberate rather than IDE-generated defaults.

// Run these in your terminal — not C# code, shown here for clarity
// dotnet new sln -n Catalog
// dotnet new webapi -n Catalog.Api --no-openapi
// dotnet new classlib -n Catalog.Core
// dotnet new classlib -n Catalog.Infrastructure
// dotnet new xunit -n Catalog.Tests
// dotnet sln add **/*.csproj

This gives you a four-project solution from the start. Catalog.Api owns HTTP concerns. Catalog.Core owns domain logic and interfaces. Catalog.Infrastructure owns data access and external integrations. Catalog.Tests tests all three. Project references flow inward: Api references Core and Infrastructure; Infrastructure references Core; Core references nothing in the solution.

Tip: Resist the urge to collapse everything into a single project to keep things simple. A single project forces you to enforce layer boundaries by convention alone; separate projects make accidental cross-layer dependencies a compile error rather than a code-review catch.

3 The folder layout inside each project

The structure below is organized by layer first and by feature second inside each layer. This is the layout that scales most predictably for teams of two to ten developers on a single API.

Catalog.Api

// Catalog.Api/
// ├── Controllers/
// │   ├── ProductsController.cs
// │   └── CategoriesController.cs
// ├── Filters/
// │   └── ValidationExceptionFilter.cs
// ├── Models/                        // Request and response DTOs for the HTTP layer
// │   ├── Requests/
// │   │   ├── CreateProductRequest.cs
// │   │   └── UpdateProductRequest.cs
// │   └── Responses/
// │       └── ProductResponse.cs
// ├── Extensions/
// │   └── ServiceCollectionExtensions.cs
// └── Program.cs

Catalog.Core

// Catalog.Core/
// ├── Domain/
// │   ├── Entities/
// │   │   └── Product.cs
// │   └── ValueObjects/
// │       └── Money.cs
// ├── Interfaces/
// │   ├── IProductRepository.cs
// │   └── IProductService.cs
// └── Services/
//     └── ProductService.cs

Catalog.Infrastructure

// Catalog.Infrastructure/
// ├── Persistence/
// │   ├── CatalogDbContext.cs
// │   ├── Configurations/
// │   │   └── ProductConfiguration.cs
// │   └── Repositories/
// │       └── ProductRepository.cs
// └── Extensions/
//     └── ServiceCollectionExtensions.cs

Keep the Extensions folder in each project; it is where IServiceCollection extension methods live. This prevents Program.cs from becoming a registration dump that grows to 200 lines.


4 Naming conventions that communicate intent

Consistent naming reduces the cognitive load of reading unfamiliar code. Apply these rules across the entire solution and enforce them in pull request reviews.

Controllers

Use the plural resource name followed by Controller: ProductsController, OrdersController, CategoriesController. Route templates use the conventional [controller] token so the route mirrors the class name automatically. Every controller action should return Task<IActionResult> or a typed ActionResult<T> rather than a bare type, so the framework can generate accurate OpenAPI response descriptions.

// Controllers/ProductsController.cs
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    public ProductsController(IProductService productService)
        => _productService = productService;

    [HttpGet("{id:int}")]
    [ProducesResponseType(typeof(ProductResponse), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult<ProductResponse>> GetById(
        int id, CancellationToken ct)
    {
        var product = await _productService.GetByIdAsync(id, ct);
        return product is null ? NotFound() : Ok(product);
    }
}

DTOs and request/response models

Name request bodies with the verb and the resource: CreateProductRequest, UpdateProductRequest. Name response models with the resource and the suffix Response: ProductResponse, ProductSummaryResponse. Never expose your domain entity directly as an API response type; the shape of your persistence model and the shape of your public API contract will diverge, and the day they need to diverge is not the day you want to write a refactor.

Interfaces and services

Prefix interfaces with I and name them after the capability: IProductService, IProductRepository. Implementations drop the prefix: ProductService, ProductRepository. Avoid descriptive suffixes like ProductServiceImpl or ConcreteProductRepository; they carry no information a developer cannot infer from the file location.

// Core/Interfaces/IProductService.cs
public interface IProductService
{
    Task<ProductResponse?> GetByIdAsync(int id, CancellationToken ct = default);
    Task<IReadOnlyList<ProductSummaryResponse>> GetAllAsync(CancellationToken ct = default);
    Task<int> CreateAsync(CreateProductRequest request, CancellationToken ct = default);
    Task UpdateAsync(int id, UpdateProductRequest request, CancellationToken ct = default);
    Task DeleteAsync(int id, CancellationToken ct = default);
}

5 Dependency registration as extension methods

Each project that registers services should expose a single AddX extension method on IServiceCollection. Program.cs then reads as a declaration of intent rather than an implementation listing.

// Infrastructure/Extensions/ServiceCollectionExtensions.cs
public static class InfrastructureServiceCollectionExtensions
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddDbContext<CatalogDbContext>(opts =>
            opts.UseSqlServer(configuration.GetConnectionString("Catalog")));

        services.AddScoped<IProductRepository, ProductRepository>();

        return services;
    }
}
// Api/Extensions/ServiceCollectionExtensions.cs
public static class ApiServiceCollectionExtensions
{
    public static IServiceCollection AddApplicationServices(
        this IServiceCollection services)
    {
        services.AddScoped<IProductService, ProductService>();
        return services;
    }
}
// Program.cs — lean and declarative
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddApplicationServices();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

Notice that Program.cs does not know which concrete types are registered. Adding a new service or repository is a change inside one extension method, not a change to the entry point. This matters when merge conflicts and code ownership come into play across a team.


6 Running and verifying the structure

With the structure in place, run the project and open the Swagger UI to verify that your controllers, their routes, and their response types are all reflected correctly in the generated document.

// From the solution root
// dotnet build
// dotnet run --project Catalog.Api

// Expected output includes:
// info: Microsoft.Hosting.Lifetime[14]
//       Now listening on: https://localhost:7001
// Navigate to https://localhost:7001/swagger to see the generated API document

At this point your Swagger UI should list every controller action with its route, HTTP method, and declared response types. If a controller action is missing, check that MapControllers() is called in Program.cs and that the controller class is in the same assembly as the API project or is explicitly included via AddApplicationPart.


Wrapping up

A well-structured .NET 8 Web API separates concerns across projects, organizes code by layer inside each project, applies consistent naming conventions, and keeps Program.cs declarative through extension methods. None of these choices are exotic; their value is in being applied uniformly so that every developer on the team can navigate, extend, and test the codebase without a guide.

The next post in this series covers validation: specifically whether FluentValidation or Data Annotations is the right choice for your API, with working code for both.

Got a question or ran into a problem? Drop a comment below and I will reply.

Controller vs. Minimal API in .NET 8

If you have started a new ASP.NET Core project in the last year, you have almost certainly paused at the same fork: controllers or Minimal APIs? In .NET 8 both approaches are mature, well-documented, and production-ready. They share the same middleware pipeline, the same dependency injection container, and they produce identical HTTP responses on the wire. What differs is the programming model, the amount of ceremony, and how well each scales as your team and codebase grow.

Controller-based APIs and Minimal APIs are the two official routing models in ASP.NET Core today. Both get you to a working endpoint; the question this post answers is which model fits your project size, your team's background, and your long-term maintenance plan.

You will walk away with runnable side-by-side examples, an honest breakdown of five real tradeoffs, and a summary table suitable for your team's architecture decision record.


1 A quick history first

Controller-based APIs trace their lineage to ASP.NET MVC, which shipped in 2009 and was rebuilt as ASP.NET Core from the ground up in 2016. The model is object-oriented: a class annotated with [ApiController] groups related endpoints, and the framework uses attribute routing, convention-based model binding, and action filters to wire everything together.

Minimal APIs arrived in .NET 6 as a deliberate reaction to the ceremony that controllers introduced for small or service-oriented scenarios. Instead of classes and attributes, you call app.MapGet, app.MapPost, and their siblings directly on the WebApplication instance or in extension methods. The model is functional: a route maps to a delegate.

Critically, both models compile to the same underlying endpoint routing infrastructure introduced in ASP.NET Core 3. There is no meaningful difference in the HTTP contract they produce; every observable difference lives in how you write and organize your code.


2 The same task, two ways

To ground the comparison, here is a GET /api/products/{id} endpoint backed by an injected service, written both ways. Both examples handle cancellation, declare response types for OpenAPI, and return a 404 when the product is not found.

Controller-based approach

// Controllers/ProductsController.cs
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _service;

    public ProductsController(IProductService service)
        => _service = service;

    [HttpGet("{id:int}")]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetById(int id, CancellationToken ct)
    {
        var product = await _service.GetByIdAsync(id, ct);
        return product is null ? NotFound() : Ok(product);
    }
}

Minimal API approach

// Endpoints/ProductEndpoints.cs
public static class ProductEndpoints
{
    public static IEndpointRouteBuilder MapProductEndpoints(
        this IEndpointRouteBuilder routes)
    {
        var group = routes.MapGroup("/api/products")
                          .WithTags("Products");

        group.MapGet("/{id:int}", async (
            int id,
            IProductService service,
            CancellationToken ct) =>
        {
            var product = await service.GetByIdAsync(id, ct);
            return product is null ? Results.NotFound() : Results.Ok(product);
        })
        .Produces<ProductDto>()
        .ProducesProblem(StatusCodes.Status404NotFound);

        return routes;
    }
}

Notice that the Minimal API version uses MapGroup to avoid repeating the base path and is placed in a dedicated static class rather than Program.cs. That is the idiomatic .NET 8 pattern for organizing Minimal API endpoints at any meaningful scale. Dumping all MapGet calls into Program.cs works for demos but does not survive a real API.


3 The real tradeoffs

1. Boilerplate and ceremony

Controllers require inheriting from ControllerBase, marking the class with [ApiController], and defining a class per resource group. For a 30-endpoint API this structure is a feature; it is predictable and easy to navigate. For a three-endpoint microservice it feels like architecture for architecture's sake. Minimal APIs strip the class and attributes away, replacing them with terse lambda handlers and fluent builder calls.

Does this matter to you? If your API has more than ten distinct route templates or your team rotates developers frequently, the controller convention repays its upfront cost. If you are building a self-contained service that does one thing well, the Minimal API ceremony saving is real.

2. Testability

Controllers are plain classes. You can instantiate them in a unit test, inject a mock, and call action methods directly without spinning up an HTTP server.

// Direct unit test of a controller action
[Fact]
public async Task GetById_ReturnsNotFound_WhenProductMissing()
{
    var mockService = new Mock<IProductService>();
    mockService.Setup(s => s.GetByIdAsync(99, default))
               .ReturnsAsync((ProductDto?)null);

    var controller = new ProductsController(mockService.Object);
    var result = await controller.GetById(99, default);

    Assert.IsType<NotFoundResult>(result);
}

Minimal API handlers are delegates rather than public methods on an instantiable class, so the same pattern is not available. The canonical testing approach is WebApplicationFactory<T> integration tests, which are valuable but carry more setup weight per test. Teams that want fast, isolated unit tests close to the HTTP boundary will find controllers more accommodating.

3. Cross-cutting concerns

Controllers support action filters, result filters, and exception filters applied via attributes or registered globally through MvcOptions. This is a mature, well-understood model for concerns like audit logging, caching headers, and conditional response transformation.

// Attribute-applied action filter on a controller action
[ServiceFilter(typeof(AuditLogFilter))]
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateProductRequest request,
                                         CancellationToken ct) { ... }

Minimal APIs support endpoint filters via .AddEndpointFilter<T>() and .AddEndpointFilterFactory(), which covers most of the same ground. The ecosystem of pre-built filters is smaller, however, and the model is less familiar to teams coming from an MVC background. For application-wide concerns such as exception handling and authentication, middleware applies equally to both models.

4. Code organization at scale

As an API grows past a handful of routes, the structural question becomes more important than the syntactic one. Controllers encode the convention: one class per resource, one action per HTTP verb. New developers know exactly where to look. With Minimal APIs the structure is self-imposed; MapGroup combined with one static extension method per feature area is the current community standard, but enforcing it consistently across a team requires deliberate decision-making that controllers handle for you automatically.

5. OpenAPI support

In .NET 8 both models have first-class OpenAPI support. The built-in Microsoft.AspNetCore.OpenApi package handles document generation for either model; controllers use [ProducesResponseType] attributes while Minimal APIs use the fluent .Produces<T>() builder methods. Neither approach produces materially better documentation. Teams already invested in Swashbuckle will find the controller attribute style familiar, but the Minimal API fluent style is equally expressive.


4 Which one should your team use?

The right choice depends on project scope and team dynamics more than on any benchmark. Here is a practical decision guide.

Choose Controllers when:

  • Your team has existing ASP.NET Core MVC experience and the controller pattern is already second nature
  • The API has many endpoints across multiple resource types and you need the framework to enforce organizational conventions
  • You rely on action filters for cross-cutting concerns and want attribute-based application
  • You want to unit-test action methods directly without a full WebApplicationFactory setup
  • You are extending or maintaining an existing controller-based codebase

Choose Minimal APIs when:

  • You are building a microservice or a focused API with a small, stable route surface
  • Your team is comfortable defining its own organizational structure and does not need the framework to enforce it
  • You prefer integration tests over action-level unit tests and already have a WebApplicationFactory test project in place
  • Startup latency matters, such as in Azure Functions or container workloads with fast cold-start requirements
  • You want to minimize the number of abstractions new team members need to learn

Mixing both in a single application is also a legitimate pattern. A common approach is using controllers for the primary resource API while using Minimal APIs for lightweight utility endpoints.

// Program.cs — valid mixed configuration
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddScoped<IProductService, ProductService>();

var app = builder.Build();

app.MapControllers();                                    // controller-based routes
app.MapHealthChecks("/health");                          // lightweight utility endpoint
app.MapGet("/version", () => Results.Ok(new            // simple info endpoint
{
    Version = "2.1.0",
    Runtime = Environment.Version.ToString()
}));

app.Run();

5 Quick reference summary
Concern Controllers Minimal APIs
Ceremony / boilerplateHigher; class, attributes, inheritance requiredLower; delegates and fluent builders
Unit testabilityDirect; instantiate the class in any testIndirect; integration tests preferred
Filters and cross-cuttingMature attribute-based ecosystemSupported via endpoint filters; smaller ecosystem
Organization at scaleConvention enforced by the frameworkSelf-imposed; requires team discipline
OpenAPI / SwaggerFirst-class; attribute-drivenFirst-class; fluent builder-driven
Startup performanceSlightly higher route registration costMarginally leaner startup
Learning curve (MVC background)Low; same model as ASP.NET MVCMedium; new patterns to internalize

Final take

For most teams building a multi-resource API with five or more controllers' worth of endpoints, controllers remain the pragmatic default. The conventions are well understood, the tooling is battle-tested, and onboarding a new developer into a controller-based project requires almost no orientation. Minimal APIs earn their place in focused microservices and internal tools where the reduction in ceremony translates directly into a codebase that is faster to read and easier to keep lean.

Lean toward controllers when you are building a product API that will grow. Lean toward Minimal APIs when you are building a service that does one thing and should stay small.

What's next?
  • Project structure: The next post in this series covers how to organize folders, layers, and naming conventions in a .NET 8 Web API so the codebase stays navigable as it grows.
  • Validation strategy: Once your routing model is settled, the next meaningful decision is FluentValidation versus Data Annotations, covered later in this series.
  • Official reference: The ASP.NET Core Minimal APIs comparison docs at learn.microsoft.com are kept up to date with each .NET release and are worth bookmarking.
Got a question or a scenario I did not cover? Drop a comment below and I will reply.

Raw SQL, FromSqlRaw, and Dapper Side-by-Side in a .NET 8 API

EF Core's LINQ query builder handles the vast majority of data access scenarios cleanly, but every production application eventually hits a query complex enough that generating it through LINQ is more effort than writing the SQL directly. At that point you have three realistic options: EF Core's own raw SQL APIs (FromSql and ExecuteSqlRaw), Dapper running over the same database connection, or a mix of both.

All three approaches talk to the same database, can share the same connection and transaction, and produce parameterized queries. The differences are in what they give back, how much ceremony they require, and where they break down.

By the end of this post you will have working code for all three approaches solving the same query, a clear decision framework for choosing between them, and a pattern for combining EF Core and Dapper in the same API without fighting over the connection.


1 A quick history first

EF Core raw SQL (FromSqlRaw, FromSql, ExecuteSqlRaw) was added to give EF developers an escape hatch when the LINQ translator cannot express a query, while still returning tracked entities and allowing LINQ composition on top of the raw SQL result.

Dapper is a micro-ORM by the Stack Overflow team built on top of IDbConnection. It maps query results to objects via property name matching and supports multi-mapping for joins. It has no change tracking, no relationship model, and no query builder; you write SQL, Dapper materializes the result.

Both evolved alongside LINQ-to-SQL and NHibernate as answers to the same question: what do you do when a generated query is not good enough?


2 The same task, three ways

The scenario: fetch orders for a given customer that include their line items and the product name for each line. This query involves a join, a filter, and a result shape that does not map cleanly to a single entity.

EF Core LINQ (baseline)

var orders = await _context.Orders
    .AsNoTracking()
    .Include(o => o.Lines)
        .ThenInclude(l => l.Product)
    .Where(o => o.CustomerId == customerId
             && o.Status == OrderStatus.Pending)
    .OrderByDescending(o => o.PlacedAt)
    .ToListAsync(ct);

EF Core FromSql approach

// FromSql (EF Core 8+) uses interpolated strings safely — parameters are never inlined
var orders = await _context.Orders
    .FromSql($@"
        SELECT o.*
        FROM   orders o
        WHERE  o.customer_id = {customerId}
          AND  o.status      = {(int)OrderStatus.Pending}
        ORDER BY o.placed_at DESC")
    .AsNoTracking()
    .Include(o => o.Lines)
        .ThenInclude(l => l.Product)
    .ToListAsync(ct);

Dapper approach

using var connection = _context.Database.GetDbConnection();
await connection.OpenAsync(ct);

var orderDict = new Dictionary<int, Order>();

await connection.QueryAsync<Order, OrderLine, Product, Order>(
    sql: @"
        SELECT o.id, o.customer_id, o.status, o.placed_at,
               l.id, l.order_id, l.quantity, l.unit_price,
               p.id, p.name, p.sku
        FROM   orders       o
        JOIN   order_lines  l ON l.order_id  = o.id
        JOIN   products     p ON p.id        = l.product_id
        WHERE  o.customer_id = @CustomerId
          AND  o.status      = @Status
        ORDER BY o.placed_at DESC",
    map: (order, line, product) =>
    {
        if (!orderDict.TryGetValue(order.Id, out var existing))
        {
            existing = order;
            existing.Lines = [];
            orderDict[order.Id] = existing;
        }
        line.Product = product;
        existing.Lines.Add(line);
        return existing;
    },
    param: new { CustomerId = customerId, Status = (int)OrderStatus.Pending },
    splitOn: "id,id");

var orders = orderDict.Values.ToList();

The SQL in the EF and Dapper versions is nearly identical. The biggest visible difference is that EF Core can compose Include on top of the raw SQL, while Dapper requires you to write the full join and wire up the object graph yourself.


3 The real tradeoffs

1. What you get back

EF Core raw SQL returns full entity instances that participate in the change tracker and can have navigation properties populated via Include. Dapper returns plain objects: whatever class you tell it to map to, with no change tracking and no navigation resolution built in. If you want the graph assembled from a join, you write the multi-mapping lambda yourself as shown above.

Does this matter? If you need to modify returned records or rely on navigation lazy loading, EF Core raw SQL is the right tool. If you are building a read model or projecting to a DTO, Dapper's lack of overhead is an advantage.

2. SQL injection safety

All three options support parameterized queries and are safe when used correctly, but the APIs make it easier or harder to do the wrong thing.

// Safe: FromSql with interpolated string — EF Core parameterizes {customerId} automatically
var orders = await _context.Orders
    .FromSql($"SELECT * FROM orders WHERE customer_id = {customerId}")
    .ToListAsync(ct);

// UNSAFE: FromSqlRaw with string interpolation — inlines the value directly into SQL
var orders = await _context.Orders
    .FromSqlRaw($"SELECT * FROM orders WHERE customer_id = {customerId}") // never do this
    .ToListAsync(ct);

// Safe: FromSqlRaw requires explicit parameter objects
var orders = await _context.Orders
    .FromSqlRaw("SELECT * FROM orders WHERE customer_id = {0}", customerId)
    .ToListAsync(ct);

The rule for FromSqlRaw: treat the first argument exactly like a string.Format call and never pass a user-derived value as anything other than a positional placeholder argument. For new code in EF Core 8, prefer FromSql (the interpolated string overload) because the compiler enforces correct usage.

3. LINQ composability

EF Core raw SQL results can have additional LINQ operators chained after them as long as the raw SQL forms the entire FROM clause:

// Legal: filter and sort on top of raw SQL
var page = await _context.Orders
    .FromSql($"SELECT * FROM orders WHERE customer_id = {customerId}")
    .Where(o => o.Status == OrderStatus.Pending)
    .OrderByDescending(o => o.PlacedAt)
    .Skip(offset).Take(pageSize)
    .ToListAsync(ct);

Dapper has no composition layer: once the SQL is written, the result is fixed. Any further filtering or pagination must happen either in SQL or in memory after the query returns, which can be a significant limitation for dynamic filter scenarios.

4. Non-query commands: ExecuteSqlRaw vs Dapper Execute

For UPDATE, DELETE, or stored procedure calls that do not return rows, both approaches are equivalent in ceremony:

// EF Core: bulk update without loading entities
int rows = await _context.Database.ExecuteSqlRawAsync(
    "UPDATE orders SET status = {0} WHERE customer_id = {1} AND placed_at < {2}",
    (int)OrderStatus.Cancelled, customerId, cutoff, ct);

// Dapper: same operation
using var connection = _context.Database.GetDbConnection();
int rows = await connection.ExecuteAsync(
    "UPDATE orders SET status = @Status WHERE customer_id = @CustomerId AND placed_at < @Cutoff",
    new { Status = (int)OrderStatus.Cancelled, CustomerId = customerId, Cutoff = cutoff });

For bulk operations in EF Core 8, prefer ExecuteUpdateAsync and ExecuteDeleteAsync over raw SQL commands because they compose with Global Query Filters and keep the tenant and soft-delete predicates active.

5. Sharing a transaction across EF Core and Dapper

Because Dapper works directly on an IDbConnection, it can participate in the same transaction that EF Core opened:

using var tx = await _context.Database.BeginTransactionAsync(ct);

// EF Core write
_context.Orders.Add(newOrder);
await _context.SaveChangesAsync(ct);

// Dapper write in the same transaction
var connection   = _context.Database.GetDbConnection();
var dbTransaction = _context.Database.CurrentTransaction!.GetDbTransaction();

await connection.ExecuteAsync(
    "INSERT INTO audit_log (order_id, action, ts) VALUES (@OrderId, @Action, @Ts)",
    new { OrderId = newOrder.Id, Action = "created", Ts = DateTimeOffset.UtcNow },
    transaction: dbTransaction);

await tx.CommitAsync(ct);

This pattern lets you use EF Core's change tracking for the entities it manages well and fall back to Dapper for queries or commands that do not fit the EF Core model, all within the same transactional boundary.


4 Which one should you choose?

Choose based on what you need from the result, not on habit or familiarity with one library.

Use EF Core LINQ when:

  • The query can be expressed in LINQ without gymnastics and the generated SQL is acceptable.
  • You need to modify returned entities in the same unit of work.
  • You want Global Query Filters (soft delete, multi-tenancy) applied automatically.
  • The query benefits from Include navigation loading without writing a join manually.

Use EF Core FromSql when:

  • The LINQ translation produces a suboptimal query (extra joins, missing index hints, missing CTEs) and you want to write the SQL yourself.
  • You still need to return tracked entities and compose LINQ on top of the result.
  • You are calling a view or a stored procedure that returns a shape matching an existing entity.
  • You want the safety of parameterized queries with minimal friction over raw ADO.NET.

Use Dapper when:

  • The result does not map to an entity: reporting queries, aggregations, complex multi-table projections, or custom DTO shapes.
  • You need multi-mapping (assembling a graph from a single join query) with fine-grained control over the mapping logic.
  • Performance is critical and you want the thinnest possible layer between SQL and the materialized object.
  • You are integrating a legacy stored-procedure-heavy database where no EF entity model exists.

5 Quick reference summary
Concern EF Core LINQ EF Core FromSql Dapper
Change trackingYesYes (unless AsNoTracking)No
LINQ composabilityFullPartial (on top of FROM)None
Global Query FiltersAutomaticAutomaticMust write manually
Custom SQL controlNoneFull for the FROM clauseFull
Non-entity result shapesVia Select projectionRequires keyless entity typeAny class or primitive
Multi-mapping joinsVia IncludeVia IncludeManual but flexible
Transaction sharing with EFNativeNativeVia GetDbTransaction()
SQL injection safetyAutomaticSafe with FromSql/positional paramsSafe with @param syntax

Final take

These three tools are not competitors; they are layers. Start with EF Core LINQ. Drop to FromSql when the generated SQL is the problem but you still want entity tracking and LINQ composition. Drop to Dapper when the result shape does not fit the entity model at all or when you need the absolute minimum between your SQL and your object.

If you find yourself using FromSqlRaw everywhere because LINQ frustrates you, that is a signal your entity model may be fighting your query patterns; revisit the schema or consider a dedicated read model with Dapper for query-side endpoints.

What's next?
  • Add a keyless entity type: If you have reporting queries that return custom shapes, register them with modelBuilder.Entity<ReportRow>().HasNoKey().ToView(null) and use FromSqlRaw to return them through EF Core with full LINQ composability.
  • Benchmark your heavy queries: Use BenchmarkDotNet with both approaches on your most-called endpoints and measure allocations, not just execution time.
  • Review your stored procedures: If your team owns legacy stored procedures, Dapper's commandType: CommandType.StoredProcedure support makes it the cleanest integration path without touching EF's entity model.
Got a question or a scenario I did not cover? Drop a comment below and I will reply.