Your First Slice Without MediatR: Minimal API, a Handler Class, and Endpoint Registration
A slice without MediatR is built from request and response records, a handler class that uses DbContext directly, and an IEndpoint that maps its own route, with an assembly scan doing the registration. We write Bookstore's PlaceOrder and GetOrderDetails slices with .NET 10 AddValidation, TypedResults, and EF Core projections, and show how a naming convention can take down every endpoint and how to catch that at startup.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
In the previous post a single conflict was left in the sliced Bookstore: every slice registered its handler in Program.cs by hand. In this post we remove those lines with a convention: every class under the Features namespace whose name ends in Handler is registered in the DI (dependency injection) container automatically.
foreach (Type type in types.Where(t => t.Name.EndsWith("Handler", StringComparison.Ordinal)))
services.TryAddScoped(type);
A developer renames GetOrderDetailsHandler, the class that reads order details, to GetOrderDetailsQuery because "it's really a query". The code compiles, no warnings. Guess: what happens when the application starts? Does it refuse to start, does only the order details endpoint break, or something else?
The Parts of a Slice: Request, Response, and Result
Bookstore's first real slice is PlaceOrder. This time the folder has three files: the types, the handler, and the endpoint. The types first:
using System.ComponentModel.DataAnnotations;
namespace Bookstore.Api.Features.Orders.PlaceOrder;
public sealed record PlaceOrderRequest(
[Range(1, int.MaxValue)] int CustomerId,
[Required, MinLength(1), MaxLength(20)] List<PlaceOrderLine> Lines);
public sealed record PlaceOrderLine([Range(1, int.MaxValue)] int BookId, [Range(1, 5)] int Quantity);
public sealed record PlaceOrderResponse(int OrderId, decimal Total);
public abstract record PlaceOrderResult
{
public sealed record Placed(PlaceOrderResponse Response) : PlaceOrderResult;
public sealed record UnknownBooks(int[] BookIds) : PlaceOrderResult;
}
The request and response belong to this slice; no other slice uses them. PlaceOrderResult describes the two outcomes the handler can return with a closed record hierarchy: the order was placed, or there are unknown books. Expressing an expected business outcome as a type instead of an exception is a small, slice-specific form of the Result pattern from the Design Patterns series post "Code-Level Patterns Beyond GoF: Null Object, Specification, Result and Pipeline". A generic Result<T> library would work too; here there are two cases and their names explain themselves.
The Handler: Using DbContext Directly
using Bookstore.Api.Domain;
using Bookstore.Api.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Api.Features.Orders.PlaceOrder;
public sealed class PlaceOrderHandler(BookstoreDbContext db, TimeProvider clock)
{
public async Task<PlaceOrderResult> HandleAsync(PlaceOrderRequest request, CancellationToken ct)
{
int[] ids = [.. request.Lines.Select(l => l.BookId).Distinct()];
Dictionary<int, decimal> prices = await db.Books
.Where(b => ids.Contains(b.Id))
.ToDictionaryAsync(b => b.Id, b => b.Price, ct);
int[] unknown = [.. ids.Where(id => !prices.ContainsKey(id))];
if (unknown.Length > 0)
return new PlaceOrderResult.UnknownBooks(unknown);
Order order = new()
{
CustomerId = request.CustomerId,
PlacedAtUtc = clock.GetUtcNow().UtcDateTime,
Items = [.. request.Lines.Select(l => new OrderItem { BookId = l.BookId, Quantity = l.Quantity, UnitPrice = prices[l.BookId] })]
};
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
decimal total = order.Items.Sum(i => i.UnitPrice * i.Quantity);
return new PlaceOrderResult.Placed(new PlaceOrderResponse(order.Id, total));
}
}
The handler doesn't implement an interface and takes BookstoreDbContext directly. That's a deliberate decision. DbContext is already a unit of work and DbSet<T> is already a repository; in the Entity Framework Core (EF Core) series post that also covers the read side of CQRS (Command Query Responsibility Segregation), "EF Core and Architecture: Repositories, the Read Side of CQRS and Moving to Dapper" we discussed what putting a generic repository on top of that gains and costs. In a slice, the classic argument for a repository gets weaker as well: no other slice shares the query, it lives in one place, and it is exactly as big as this use case needs.
The only external dependency the handler needs is the clock, and we take it as TimeProvider, part of the BCL (Base Class Library) since .NET 8, so a test can pass a fixed time easily. The handler doesn't know any HTTP (Hypertext Transfer Protocol) type either: it doesn't return IResult and doesn't take HttpContext. That separation is the precondition for calling the same handler tomorrow from a background job or a message consumer.
The Endpoint: TypedResults and Results<...>
using System.Diagnostics;
using Bookstore.Api.Infrastructure;
using Microsoft.AspNetCore.Http.HttpResults;
namespace Bookstore.Api.Features.Orders.PlaceOrder;
public sealed class PlaceOrderEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder app) =>
app.MapPost("/orders", HandleAsync).WithName("PlaceOrder");
private static async Task<Results<Created<PlaceOrderResponse>, ValidationProblem>> HandleAsync(
PlaceOrderRequest request, PlaceOrderHandler handler, CancellationToken ct) =>
await handler.HandleAsync(request, ct) switch
{
PlaceOrderResult.Placed(PlaceOrderResponse r) => TypedResults.Created($"/api/orders/{r.OrderId}", r),
PlaceOrderResult.UnknownBooks(int[] ids) => TypedResults.ValidationProblem(
new Dictionary<string, string[]> { ["Lines"] = [$"Unknown book ids: {string.Join(", ", ids)}"] }),
_ => throw new UnreachableException()
};
}
The endpoint's only job is translating between HTTP and the handler: the route, binding the request, and turning the handler's result into an HTTP response. Its return type is Results<Created<PlaceOrderResponse>, ValidationProblem>; as we saw in the ASP.NET Core Web API (Application Programming Interface) series post "What's New in ASP.NET Core with .NET 7: MapGroup, TypedResults, Rate Limiting and Output Caching", this type tells both the compiler and the OpenAPI document which responses the endpoint can return. We split the handler's result with positional patterns in a switch expression. The compiler can't know that an abstract record will have no other subtypes; delete the _ arm and it reports CS8509 ("The switch expression does not handle all possible values of its input type"), which is why that arm throws UnreachableException. The union types expected in C# 15 will make that arm unnecessary.
A Read Slice: Fetching Only What You Need with a Projection
Order details is a query. In read slices, instead of loading entities and mapping them to a DTO (Data Transfer Object), we project straight into the response type:
using Bookstore.Api.Infrastructure;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Api.Features.Orders.GetOrderDetails;
public sealed record OrderDetails(int Id, string Status, DateTime PlacedAtUtc, decimal Total, List<OrderDetailsLine> Lines);
public sealed record OrderDetailsLine(string Title, int Quantity, decimal UnitPrice);
public sealed class GetOrderDetailsHandler(BookstoreDbContext db)
{
public Task<OrderDetails?> HandleAsync(int id, CancellationToken ct) =>
db.Orders
.Where(o => o.Id == id)
.Select(o => new OrderDetails(
o.Id,
o.Status,
o.PlacedAtUtc,
o.Items.Sum(i => i.UnitPrice * i.Quantity),
o.Items.Select(i => new OrderDetailsLine(i.Book!.Title, i.Quantity, i.UnitPrice)).ToList()))
.SingleOrDefaultAsync(ct);
}
public sealed class GetOrderDetailsEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder app) =>
app.MapGet("/orders/{id:int}", async Task<Results<Ok<OrderDetails>, NotFound>> (int id, GetOrderDetailsHandler handler, CancellationToken ct) =>
await handler.HandleAsync(id, ct) is { } details ? TypedResults.Ok(details) : TypedResults.NotFound());
}
In a short slice the handler and the endpoint can live in the same file; whether it's a folder or a file, decide by the size of the slice. The SQL (Structured Query Language) EF Core generates computes the total in the database and joins only the book title, without an Include:
SELECT "o2"."Id", "o2"."Status", "o2"."PlacedAtUtc", "o2"."c", "s"."Title", "s"."Quantity", "s"."UnitPrice", "s"."Id", "s"."Id0"
FROM (
SELECT "o"."Id", "o"."Status", "o"."PlacedAtUtc", (
SELECT COALESCE(ef_sum(ef_multiply("o0"."UnitPrice", CAST("o0"."Quantity" AS TEXT))), '0.0')
FROM "OrderItem" AS "o0"
WHERE "o"."Id" = "o0"."OrderId") AS "c"
FROM "Orders" AS "o"
WHERE "o"."Id" = @id
LIMIT 2
) AS "o2"
LEFT JOIN (
SELECT "b"."Title", "o1"."Quantity", "o1"."UnitPrice", "o1"."Id", "b"."Id" AS "Id0", "o1"."OrderId"
FROM "OrderItem" AS "o1"
INNER JOIN "Books" AS "b" ON "o1"."BookId" = "b"."Id"
) AS "s" ON "o2"."Id" = "s"."OrderId"
ORDER BY "o2"."Id", "s"."Id"
The value of projections is covered in detail in the EF Core series post "EF Core Performance Basics: N+1, Projection, AsNoTracking and Compiled Queries"; what matters from a VSA (Vertical Slice Architecture) point of view is that every read slice can fetch exactly as many columns as its own screen needs. With a shared OrderDto, the screen that wants the most fields would bloat everyone's query.
Automatic Registration: IEndpoint, Handler Scanning, and MapGroup
The code that wires slices into the application is a single file:
using System.Reflection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Bookstore.Api.Infrastructure;
public interface IEndpoint
{
void Map(IEndpointRouteBuilder app);
}
public static class SliceExtensions
{
// Registers every IEndpoint and *Handler class under Features in one go
public static IServiceCollection AddSlices(this IServiceCollection services, Assembly assembly)
{
Type[] types = [.. assembly.GetTypes().Where(t => t is { IsClass: true, IsAbstract: false } && t.Namespace?.Contains(".Features.") == true)];
foreach (Type type in types.Where(t => t.IsAssignableTo(typeof(IEndpoint))))
services.TryAddEnumerable(ServiceDescriptor.Singleton(typeof(IEndpoint), type));
foreach (Type type in types.Where(t => t.Name.EndsWith("Handler", StringComparison.Ordinal)))
services.TryAddScoped(type);
return services;
}
public static IEndpointRouteBuilder MapSlices(this IEndpointRouteBuilder app, string prefix)
{
RouteGroupBuilder group = app.MapGroup(prefix);
foreach (IEndpoint endpoint in app.ServiceProvider.GetServices<IEndpoint>())
endpoint.Map(group);
// Build the endpoints now: a broken slice should fail at startup, not on the first request
foreach (EndpointDataSource source in app.DataSources)
_ = source.Endpoints;
return app;
}
}
Endpoints are registered as singletons and handlers as scoped; a handler depends on DbContext, so it must share its lifetime. All slices hang under a single MapGroup("/api"), so shared settings such as the prefix, authorization, or rate limiting live in one place. We'll see shortly why the last foreach is there. Program.cs no longer knows about any slice:
using Bookstore.Api.Domain;
using Bookstore.Api.Infrastructure;
using Microsoft.EntityFrameworkCore;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<BookstoreDbContext>(options => options
.UseSqlite(builder.Configuration.GetConnectionString("Bookstore"))
.UseSeeding((context, _) =>
{
if (!context.Set<Book>().Any())
{
context.Set<Book>().AddRange(
new Book { Title = "Dune", Price = 12.50m },
new Book { Title = "Emma", Price = 8.00m },
new Book { Title = "The Hobbit", Price = 10.00m });
context.SaveChanges();
}
}));
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddProblemDetails();
builder.Services.AddValidation();
builder.Services.AddSlices(typeof(Program).Assembly);
WebApplication app = builder.Build();
using (IServiceScope scope = app.Services.CreateScope())
{
BookstoreDbContext db = scope.ServiceProvider.GetRequiredService<BookstoreDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
}
app.MapSlices("/api");
app.Run();
public partial class Program;
We use SQLite to keep the examples short; in post 05 we run the tests against a real SQL Server. I repeated the Ada and Alan scenario from the previous post in this project: CancelOrder and GetCustomerOrders on two separate branches, each adding only its own folder.
$ git checkout cancel-order
$ git merge customer-orders
Merge made by the 'ort' strategy.
.../GetCustomerOrders/GetCustomerOrdersEndpoint.cs | 10 ++++++++++
.../GetCustomerOrders/GetCustomerOrdersHandler.cs | 15 +++++++++++++++
2 files changed, 25 insertions(+)
create mode 100644 Features/Customers/GetCustomerOrders/GetCustomerOrdersEndpoint.cs
create mode 100644 Features/Customers/GetCustomerOrders/GetCustomerOrdersHandler.cs
No conflict, no shared file changed. Adding two features meant adding two folders.
Validation: .NET 10's AddValidation
The AddValidation() call in Program.cs is the built-in validation we met in the ASP.NET Core Web API series post "What's New in .NET 10: AddValidation, OpenAPI 3.1 and Server-Sent Events in ASP.NET Core". The Data Annotations attributes on the request record are checked before the handler runs:
POST /api/orders {"customerId":0,"lines":[{"bookId":1,"quantity":9}]}
HTTP 400
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"CustomerId":["The field CustomerId must be between 1 and 2147483647."],"Lines[0].Quantity":["The field Quantity must be between 1 and 5."]},"traceId":"00-7ab1943d6ceb66fbd37871ad4d0a3436-b11a69942ba2a755-00"}
POST /api/orders {"customerId":7,"lines":[{"bookId":1,"quantity":1},{"bookId":42,"quantity":1}]}
HTTP 400
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Lines":["Unknown book ids: 42"]},"traceId":"00-4d2d96b91263abbda4b37faf33c17248-80386d004972a1ef-00"}
POST /api/orders {"customerId":7,"lines":[{"bookId":1,"quantity":2},{"bookId":3,"quantity":1}]}
HTTP 201
{"orderId":1,"total":35.0}
GET /api/orders/1
HTTP 200
{"id":1,"status":"Placed","placedAtUtc":"2026-11-07T21:51:45.923871","total":35.0,"lines":[{"title":"Dune","quantity":2,"unitPrice":12.5},{"title":"The Hobbit","quantity":1,"unitPrice":10.0}]}
GET /api/orders/99
HTTP 404
The first response is input validation: whether the request is consistent in itself, without going to the database. The second is a business check: does the book actually exist, which only the handler can know. The two live in different places but reach the client in the same ValidationProblem shape. AddValidation() doesn't use reflection; it relies on a source generator that scans Map* calls at build time. Mapping endpoints in their own classes instead of Program.cs made no difference: the generator found the MapPost in PlaceOrderEndpoint too.
The Price of a Convention
Back to the opening question. I renamed GetOrderDetailsHandler to GetOrderDetailsQuery and started the application without the last foreach in MapSlices. It started without complaint. I tried placing an order first, then order details:
POST /api/orders -> 500
GET /api/orders/1 -> 500
Nobody had touched the place-order slice, and it returns 500 too. The details of the error:
System.InvalidOperationException: Body was inferred but the method does not allow inferred body parameters.
Below is the list of parameters that we found:
Parameter | Source
---------------------------------------------------------------------------------
id | Route (Inferred)
handler | Body (Inferred)
Did you mean to register the "Body (Inferred)" parameter(s) as a Service or apply the [FromServices] or [FromBody] attribute?
Let's unpack it step by step. Because the name no longer ends in Handler, the class wasn't registered in DI. Minimal API asks the container whether a parameter is a service; when the answer is "no", it assumes it is the request body. A GET endpoint can't have a body, so the endpoint can't be built. And because ASP.NET Core builds endpoints all together when the first request arrives, one slice's mistake brings down the whole route table: every endpoint returns 500. The claim that "slices are independent of each other" was quietly broken by a shared convention.
The fix has two steps. First, move the failure to startup: the loop at the end of MapSlices reads the Endpoints property of every data source, which forces the endpoints to be built right away. With the same broken code, the application now doesn't start at all:
Unhandled exception. System.InvalidOperationException: Body was inferred but the method does not allow inferred body parameters.
...
at Bookstore.Api.Infrastructure.SliceExtensions.MapSlices(IEndpointRouteBuilder app, String prefix) in .../Infrastructure/SliceExtensions.cs:line 32
at Program.<Main>$(String[] args) in .../Program.cs:line 34
An application crashing at startup in CI (continuous integration) or on a developer's machine is much cheaper than a deployed application crashing on its first request. Second, write the convention down: the rule "a slice handler ends in Handler" should be a README line or an architecture test for the team; with the ArchUnitNET setup from the Clean, Onion, and Hexagonal Architecture series post "Testing and Protecting Clean Architecture: Core Tests, Adapter Tests, and Architecture Tests", it's a one-test job. You could choose a marker interface instead of a naming convention; then the rule becomes "don't forget to implement the interface". Every convention is a rule, and every rule needs protecting somewhere.
Common Pitfalls
Burying the handler inside the endpoint lambda. It's the shortest path, and on day one it works fine:
using Bookstore.Api.Domain;
using Bookstore.Api.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Api.Features.Orders.PlaceOrder;
// Everything inside the lambda: it works, but it can only be called over HTTP
public sealed class PlaceOrderEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder app) =>
app.MapPost("/orders", async (PlaceOrderRequest request, BookstoreDbContext db, CancellationToken ct) =>
{
int[] ids = [.. request.Lines.Select(l => l.BookId).Distinct()];
Dictionary<int, decimal> prices = await db.Books.Where(b => ids.Contains(b.Id)).ToDictionaryAsync(b => b.Id, b => b.Price, ct);
int[] unknown = [.. ids.Where(id => !prices.ContainsKey(id))];
if (unknown.Length > 0)
return Results.ValidationProblem(new Dictionary<string, string[]> { ["Lines"] = [$"Unknown book ids: {string.Join(", ", unknown)}"] });
Order order = new()
{
CustomerId = request.CustomerId,
PlacedAtUtc = DateTime.UtcNow,
Items = [.. request.Lines.Select(l => new OrderItem { BookId = l.BookId, Quantity = l.Quantity, UnitPrice = prices[l.BookId] })]
};
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return Results.Created($"/api/orders/{order.Id}", new PlaceOrderResponse(order.Id, order.Items.Sum(i => i.UnitPrice * i.Quantity)));
});
}
The trouble starts not today but when the slice grows. The workflow is tangled with HTTP responses; trying the "unknown book" branch means spinning up a web server; DateTime.UtcNow can't be pinned; calling the same job from a background job means copying the code. Once the handler is separate, the same branch is exercised by a single test, without HTTP:
using Bookstore.Api.Domain;
using Bookstore.Api.Features.Orders.PlaceOrder;
using Bookstore.Api.Infrastructure;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Api.Tests;
public sealed class PlaceOrderHandlerTests : IDisposable
{
private readonly SqliteConnection _connection = new("Data Source=:memory:");
private readonly BookstoreDbContext _db;
public PlaceOrderHandlerTests()
{
_connection.Open();
_db = new BookstoreDbContext(new DbContextOptionsBuilder<BookstoreDbContext>().UseSqlite(_connection).Options);
_db.Database.EnsureCreated();
_db.Books.Add(new Book { Title = "Dune", Price = 12.50m });
_db.SaveChanges();
}
[Fact]
public async Task Unknown_book_is_reported_and_nothing_is_saved()
{
PlaceOrderHandler handler = new(_db, TimeProvider.System);
PlaceOrderRequest request = new(7, [new(1, 1), new(42, 1), new(43, 2)]);
PlaceOrderResult result = await handler.HandleAsync(request, TestContext.Current.CancellationToken);
PlaceOrderResult.UnknownBooks unknown = Assert.IsType<PlaceOrderResult.UnknownBooks>(result);
Assert.Equal([42, 43], unknown.BookIds);
Assert.Equal(0, await _db.Orders.CountAsync(TestContext.Current.CancellationToken));
}
public void Dispose()
{
_db.Dispose();
_connection.Dispose();
}
}
Test run summary: Passed!
total: 1
failed: 0
succeeded: 1
skipped: 0
duration: 1s 325ms
Which kind of test is the natural unit for a slice is the subject of post 05; what matters here is that you have the choice. The rule is simple: slices that are nothing but one query, like GetOrderDetails, may stay in a lambda; every slice with a branch gets a handler class.
Letting each slice configure DbContext its own way. Applying "each slice chooses its own approach" to DbContext can look tempting: one slice builds its own connection string with new BookstoreDbContext(...), another changes db.ChangeTracker.QueryTrackingBehavior at the top of its handler. The first bypasses the configuration, logging, and interceptors in DI; the second changes the behaviour of everything that shares the context in the same scope. DbContext is configured in one place, with AddDbContext; slices only inject it. If a read slice doesn't want tracking, it puts AsNoTracking() on its own query, not on the context.
Returning IResult from the handler. Having the handler return TypedResults.Created(...) looks like it saves a line, but it locks the handler to HTTP, and instead of the test above you end up writing a test that parses an HTTP response. Keep the result type in the slice's own language (Placed, UnknownBooks) and the HTTP translation in the endpoint.
A Small Experiment
★☆☆ Rename GetOrderDetailsHandler and start the application first with the last loop in MapSlices, then without it. Compare when, and on which endpoint, the error shows up.
★★☆ Add a rule to PlaceOrderLine that prevents the same book from appearing on two lines. Would you write it as a Data Annotation, with IValidatableObject, or in the handler? Does the error response from the place you chose have the same shape as the other validation errors?
★★★ Write an architecture test that verifies every IEndpoint implementation under Features is sealed and every handler's name ends in Handler. Rename a handler to break the test and read the failure message.
In this post we built a slice without any library. In the next post we write the same PlaceOrder with three mediators: MediatR, the source-generated Mediator, and Wolverine. We compare what a pipeline behavior gains, where "Go to Definition" breaks, and the licensing of the three tools as of November 2026.