MediatR Olmadan İlk Slice: Minimal API, Handler Class ve Endpoint Kaydı
MediatR olmadan bir slice; request ve response record'ları, DbContext'i doğrudan kullanan bir handler class'ı ve endpoint'i kendi class'ında map eden bir IEndpoint ile kurulur, kayıtları da assembly scan yapar. Bookstore'da PlaceOrder ve GetOrderDetails slice'larını .NET 10 AddValidation, TypedResults ve EF Core projection ile yazıyor, adlandırma convention'ının bütün endpoint'leri nasıl düşürebildiğini ve bunun açılışta nasıl yakalanacağını gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Önceki yazıda slice'lı Bookstore'da tek bir conflict kalmıştı: her slice handler'ını Program.cs'e elle kaydediyordu. Bu yazıda o satırları bir convention'la kaldıracağız: Features namespace'i altında adı Handler ile biten her class otomatik olarak DI (dependency injection) container'a kaydedilecek.
foreach (Type type in types.Where(t => t.Name.EndsWith("Handler", StringComparison.Ordinal)))
services.TryAddScoped(type);
Bir developer, sipariş detayını okuyan GetOrderDetailsHandler class'ını "aslında bir query" diyerek GetOrderDetailsQuery diye yeniden adlandırıyor. Kod derleniyor, uyarı yok. Tahmin et: uygulama açılınca ne olur? Açılmaz mı, yalnızca sipariş detayı endpoint'i mi bozulur, yoksa başka bir şey mi?
Slice'ın Parçaları: Request, Response ve Sonuç
Bookstore'un ilk gerçek slice'ı PlaceOrder. Bu sefer klasörde üç dosya var: tipler, handler ve endpoint. Önce tipler:
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;
}
Request ve response bu slice'a ait; başka hiçbir slice onları kullanmıyor. PlaceOrderResult ise handler'ın dönebileceği iki sonucu kapalı bir record hiyerarşisiyle anlatıyor: sipariş verildi ya da bilinmeyen kitaplar var. Beklenen bir iş sonucunu exception'la değil tiple ifade etmek, Design Patterns serisinin "GoF Dışında Bilmen Gereken Kod Seviyesi Pattern'ler: Null Object, Specification, Result ve Pipeline" yazısındaki Result pattern'inin slice'a özel, küçük bir hâli. Generic bir Result<T> library'si de olurdu; burada iki durum var ve isimleri kendini anlatıyor.
Handler: DbContext'i Doğrudan Kullanmak
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));
}
}
Handler bir interface implement etmiyor ve BookstoreDbContext'i doğrudan alıyor. Bu bilinçli bir karar. DbContext zaten bir unit of work, DbSet<T> zaten bir repository; Entity Framework Core (EF Core) serisinin, CQRS'in (Command Query Responsibility Segregation) okuma tarafını da ele alan "EF Core ve Mimari: Repository, CQRS'in Okuma Tarafı ve Dapper'a Geçiş" yazısında bunun üstüne generic bir repository koymanın neyi kazandırıp neyi kaybettirdiğini konuşmuştuk. Slice'ta repository'nin klasik gerekçesi de zayıflıyor: sorguyu paylaşan başka bir slice yok, sorgu tek bir yerde ve tam olarak bu use case'in ihtiyacı kadar.
Handler'ın tek ihtiyacı olan dış bağımlılık saat; onu da .NET 8'den beri BCL (Base Class Library) içinde olan TimeProvider ile alıyoruz, test'te sabit bir saat vermek kolay olsun diye. Handler bir HTTP (Hypertext Transfer Protocol) tipi de bilmiyor: IResult dönmüyor, HttpContext almıyor. Bu ayrım, aynı handler'ı yarın bir background job'dan ya da bir message consumer'dan çağırabilmenin ön koşulu.
Endpoint: TypedResults ve 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()
};
}
Endpoint'in tek işi HTTP ile handler arasında çeviri yapmak: route, request'in bağlanması ve handler sonucunun HTTP cevabına dönüşmesi. Dönüş tipi Results<Created<PlaceOrderResponse>, ValidationProblem>; ASP.NET Core Web API (Application Programming Interface) serisinin ".NET 7 ile ASP.NET Core Yenilikleri: MapGroup, TypedResults, Rate Limiting ve Output Caching" yazısında gördüğümüz gibi bu tip, endpoint'in hangi cevapları dönebileceğini hem derleyiciye hem OpenAPI dokümanına söylüyor. Handler sonucunu bir switch expression ile positional pattern'lere ayırıyoruz; Derleyici abstract bir record'un başka alt tipleri olmayacağını bilemiyor; _ kolunu silince CS8509 ("The switch expression does not handle all possible values of its input type") uyarısı veriyor, bu yüzden o kolda UnreachableException var. C# 15 ile gelmesi beklenen union tipleri bu kolu gereksiz kılacak.
Okuma Slice'ı: Projection ile Sadece Gerekeni Çekmek
Sipariş detayı bir query. Okuma slice'larında entity yükleyip DTO'ya (Data Transfer Object) map etmek yerine doğrudan response tipine projection yapıyoruz:
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());
}
Kısa bir slice'ta handler ve endpoint aynı dosyada durabiliyor; klasör mü dosya mı, slice'ın büyüklüğüne bakarak karar ver. EF Core'un ürettiği SQL (Structured Query Language), toplamı veritabanında hesaplıyor ve Include yazmadan yalnızca kitap başlığını join'liyor:
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"
Projection'ın değeri EF Core serisinin "EF Core'da Performans Temelleri: N+1, Projection, AsNoTracking ve Compiled Query" yazısında ayrıntılı; VSA (Vertical Slice Architecture) açısından önemli olan, her okuma slice'ının kendi ekranının ihtiyacı kadar sütun çekebilmesi. Paylaşılan bir OrderDto olsaydı, en çok alan isteyen ekran herkesin sorgusunu şişirirdi.
Otomatik Kayıt: IEndpoint, Handler Taraması ve MapGroup
Slice'ları uygulamaya bağlayan kod tek bir dosya:
using System.Reflection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Bookstore.Api.Infrastructure;
public interface IEndpoint
{
void Map(IEndpointRouteBuilder app);
}
public static class SliceExtensions
{
// Features altındaki IEndpoint'leri ve *Handler class'larını tek seferde kaydeder
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);
// Endpoint'leri şimdi kur: hatalı bir slice ilk request'te değil açılışta patlasın
foreach (EndpointDataSource source in app.DataSources)
_ = source.Endpoints;
return app;
}
}
Endpoint'ler singleton, handler'lar scoped kaydediliyor; handler DbContext'e bağlı olduğu için onunla aynı lifetime'da olmalı. Bütün slice'lar tek bir MapGroup("/api") altına bağlanıyor, böylece prefix, authorization ya da rate limiting gibi ortak ayarlar tek yerde. Son foreach'in neden orada olduğunu birazdan göreceğiz. Program.cs artık hiçbir slice'ı tanımıyor:
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;
Örnekleri kısa tutmak için SQLite kullanıyoruz; 05. yazıda test'leri gerçek SQL Server'a karşı koşacağız. Önceki yazının Ada ve Alan senaryosunu bu projede tekrarladım: CancelOrder ve GetCustomerOrders iki ayrı branch'te, ikisi de yalnızca kendi klasörünü ekliyor.
$ 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
Conflict yok, değişen ortak dosya yok. İki feature'ı eklemek, iki klasör eklemek oldu.
Validation: .NET 10'un AddValidation'ı
Program.cs'teki AddValidation(), ASP.NET Core Web API serisinin ".NET 10 Yenilikleri: ASP.NET Core'da AddValidation, OpenAPI 3.1 ve Server-Sent Events" yazısında tanıştığımız yerleşik validation. Request record'undaki Data Annotations attribute'ları handler çalışmadan önce kontrol ediliyor:
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
İlk cevap input validation: request'in kendi içinde tutarlı olup olmadığı, veritabanına gitmeden. İkincisi iş kontrolü: kitap gerçekten var mı, bunu yalnızca handler bilebilir. İkisi farklı yerlerde yaşıyor ama istemciye aynı ValidationProblem biçiminde gidiyor. AddValidation() reflection'la değil, build sırasında Map* çağrılarını tarayan bir source generator'la çalışıyor; endpoint'lerin Program.cs'te değil kendi class'larında map edilmesi bunu etkilemedi, generator PlaceOrderEndpoint'teki MapPost'u da buldu.
Convention'ın Bedeli
Şimdi açılıştaki soruya dönelim. GetOrderDetailsHandler'ı GetOrderDetailsQuery diye yeniden adlandırdım ve MapSlices'taki son foreach olmadan uygulamayı açtım. Uygulama sorunsuz açıldı. Önce sipariş vermeyi, sonra sipariş detayını denedim:
POST /api/orders -> 500
GET /api/orders/1 -> 500
Sipariş verme slice'ına kimse dokunmamıştı, o da 500 dönüyor. Hatanın detayı:
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?
Ne olduğunu sırayla açalım. Adı artık Handler ile bitmediği için class DI'a kaydedilmedi. Minimal API, parametrenin bir service olup olmadığını container'a soruyor; cevap "hayır" olunca onu request body'si sanıyor. GET endpoint'inde body olamayacağı için endpoint kurulamıyor. ASP.NET Core endpoint'leri ilk request geldiğinde toplu olarak kurduğu için de tek bir slice'ın hatası bütün route tablosunu düşürüyor: her endpoint 500 dönüyor. Yani "slice'lar birbirinden bağımsız" iddiası, paylaşılan bir convention'da sessizce delinmiş oldu.
Çözüm iki adımlı. Birincisi, hatayı açılışa çekmek: MapSlices'ın sonundaki döngü bütün data source'ların Endpoints property'sini okuyarak endpoint'leri hemen kurduruyor. Aynı bozuk kodla uygulama artık hiç açılmıyor:
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
Deploy edilmiş bir uygulamanın ilk request'te çökmesi yerine CI'da (continuous integration) ya da developer'ın makinesinde açılışta çökmesi çok daha ucuz. İkincisi, convention'ı açık yazmak: "slice handler'ı Handler ile biter" kuralı ekip için bir README satırı ya da bir architecture test'i olmalı; Clean, Onion ve Hexagonal Architecture serisinin "Clean Architecture'ı Test Etmek ve Korumak: Core Test'leri, Adapter Test'leri ve Architecture Test'leri" yazısındaki ArchUnitNET ile bu bir test'lik iş. Name convention yerine bir marker interface de seçebilirdin; o zaman da kural "interface'i implement etmeyi unutma" olur. Her convention bir kural, her kural da bir yerde korunmalı.
Sık Düşülen Tuzaklar
Handler'ı endpoint lambda'sına gömmek. En kısa yol bu ve ilk gün sorunsuz çalışıyor:
using Bookstore.Api.Domain;
using Bookstore.Api.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Api.Features.Orders.PlaceOrder;
// Her şey lambda'nın içinde: çalışır ama yalnızca HTTP üzerinden çağrılabilir
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)));
});
}
Sorun bugün değil, slice büyüdüğünde başlıyor. İş akışı HTTP cevaplarıyla iç içe; "bilinmeyen kitap" dalını denemek için bir web sunucusu ayağa kaldırman gerekiyor; DateTime.UtcNow sabitlenemiyor; aynı işi bir background job'dan çağırmak için kodu kopyalaman gerekiyor. Handler'ı ayırdığında aynı dal tek bir test'le, HTTP'siz deneniyor:
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
Hangi test türünün slice için doğal birim olduğu 05. yazının konusu; burada önemli olan, seçeneğin elinde olması. Kural basit: GetOrderDetails gibi tek bir sorgudan ibaret slice'lar lambda'da kalabilir, dalı olan her slice'ın bir handler class'ı olur.
Her slice'ın DbContext'i kendince configure etmesi. "Her slice kendi yolunu seçer" ilkesini DbContext'e uygulamak cazip görünebilir: bir slice new BookstoreDbContext(...) ile kendi connection string'ini kurar, bir başkası handler'ın başında db.ChangeTracker.QueryTrackingBehavior'ı değiştirir. Birincisi DI'daki configuration'ı, logging'i ve interceptor'ları atlar; ikincisi aynı scope'taki context'i paylaşan her şeyin davranışını değiştirir. DbContext tek yerde, AddDbContext ile configure edilir; slice'lar onu yalnızca inject eder. Bir okuma slice'ı tracking istemiyorsa AsNoTracking()'i context'e değil kendi sorgusuna yazar.
Handler'dan IResult dönmek. Handler'ın TypedResults.Created(...) dönmesi bir satır kazandırıyor gibi görünür ama handler'ı HTTP'ye kilitler ve yukarıdaki test'in yerine bir HTTP cevabını parse eden test yazmak zorunda kalırsın. Sonuç tipi slice'ın dilinde (Placed, UnknownBooks), HTTP çevirisi endpoint'te kalsın.
Küçük Bir Deneme
★☆☆ GetOrderDetailsHandler'ı yeniden adlandırıp uygulamayı önce MapSlices'taki son döngüyle, sonra döngüsüz aç. Hatanın ne zaman ve hangi endpoint'te göründüğünü karşılaştır.
★★☆ PlaceOrderLine'a aynı kitabın iki satırda gelmesini engelleyen bir kural ekle. Bunu bir Data Annotation ile mi, IValidatableObject ile mi, handler'da mı yazardın? Seçtiğin yerin hata cevabı diğer validation hatalarıyla aynı biçimde mi?
★★★ Features altındaki her IEndpoint implementasyonunun sealed olduğunu ve her handler'ın adının Handler ile bittiğini doğrulayan bir architecture test'i yaz. Test'i bozmak için bir handler'ı yeniden adlandır ve hata mesajını oku.
Bu yazıda slice'ı hiçbir library kullanmadan kurduk. Bir sonraki yazıda aynı PlaceOrder'ı üç mediator ile yazacağız: MediatR, source generator tabanlı Mediator ve Wolverine. Pipeline behavior'ın ne kazandırdığını, "Go to Definition"ın nerede koptuğunu ve üç aracın Kasım 2026'daki lisans durumunu orada karşılaştırıyoruz.