Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Clean, Onion ve Hexagonal Architecture — #06

Application Layer ve Use Case'ler: MediatR Olmadan Handler, Validation ve Transaction Sınırı

Mert Özen 7 Kas 2026 16 dk 5 görüntülenme
Application Layer ve Use Case'ler: MediatR Olmadan Handler, Validation ve Transaction Sınırı

Application layer'da her use case tek amaçlı bir handler class'ıdır: input validation Web'de, iş kuralı Domain'de kalır, beklenen hatalar Result ile döner ve tek bir SaveChanges transaction sınırını çizer. Scrutor ile decorator'larla cross-cutting işleri MediatR'sız eklemek ve MediatR'ın 13.0.0 sonrası lisans durumu.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un Clean Architecture'a geçmeden önceki, katmanlı versiyonunda sipariş akışı aşağıdaki gibi yazılmıştı. Repository method'u işini bitirince kendi SaveChangesAsync'ini çağırıyor; service önce stoğu ayırıp kaydediyor, sonra kargo firmasının API'sinden (Application Programming Interface) ücreti alıp siparişi yazıyor. Kod derleniyor, testlerin çoğu geçiyor, production'da aylarca çalışmış:

public sealed class LegacyBookRepository(LegacyDbContext db)
{
    public Task<Book?> FindAsync(int id, CancellationToken ct) => db.Books.SingleOrDefaultAsync(b => b.Id == id, ct);

    public async Task UpdateAsync(Book book, CancellationToken ct)
    {
        db.Books.Update(book);
        await db.SaveChangesAsync(ct); // her repository çağrısı kendi SaveChanges'ını yapıyor
    }
}

public sealed class LegacyShippingClient(HttpClient http)
{
    public async Task<decimal> QuoteAsync(string postalCode, int weightGrams, CancellationToken ct)
    {
        using HttpResponseMessage response = await http.PostAsJsonAsync("v2/rates", new { postalCode, weightGrams }, ct);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<decimal>(ct);
    }
}

public sealed class LegacyOrderService(LegacyBookRepository books, LegacyShippingClient shipping, LegacyDbContext db)
{
    public async Task<int> PlaceAsync(string email, string postalCode, int bookId, int quantity, CancellationToken ct)
    {
        Book book = await books.FindAsync(bookId, ct) ?? throw new KeyNotFoundException($"Book {bookId} not found.");
        Order order = new(email, postalCode);
        if (!order.TryAddItem(book, quantity))
            throw new InvalidOperationException($"Only {book.Stock} copies of '{book.Title}' left.");
        await books.UpdateAsync(book, ct); // stok burada commit ediliyor

        order.ApplyShipping(await shipping.QuoteAsync(postalCode, quantity * 400, ct));
        db.Orders.Add(order);
        await db.SaveChangesAsync(ct);
        return order.Id;
    }
}

Müşteri, stokta 3 adet bulunan "Dune"dan bir tane sipariş ediyor. Tam o sırada kargo firmasının API'si cevap vermiyor. PlaceAsync exception fırlatıyor, endpoint 500 dönüyor, müşteri "sipariş alınamadı" mesajı görüyor ve sayfayı kapatıyor. Soru şu: bu istekten sonra veritabanında Dune'un stoğu kaç, kaç sipariş kaydı var? İkinci soru: aynı akışı yarın bir console komutundan ya da gece çalışan bir background job'dan tetiklemen gerekse, bu kodun hangi parçalarını yanında taşıman gerekir?

Yarım Kalan Sipariş

Akışı SQLite üzerinde, kargo adresi olarak kapalı bir port vererek çalıştırdım ve ardından tabloları yeni bir DbContext ile okudum:

HttpRequestException: Connection refused (localhost:5307)
Stock of 'Dune': 2
Orders in database: 0

Sipariş yok ama stok düşmüş. UpdateAsync kendi SaveChangesAsync'iyle stoğu commit etti; kargo çağrısı patladığında geri alınacak bir şey kalmamıştı. Her başarısız denemede bir kopya "kayboluyor". Bu bir Entity Framework Core (EF Core) hatası değil, bir tasarım sonucu: "işlem nerede başlar, nerede biter?" sorusunun cevabı iki ayrı yere dağılmış. Repository method'u bir iş akışının parçası olduğunu bilmiyor, service de commit'in ne zaman olduğunu bilmiyor.

İkinci sorunun cevabı da aynı yerden çıkıyor. Bu service bir controller'ın ya da endpoint'in arkasında yaşadığı için akışın gerçek sahibi belli değil: hatalar farklı exception tipleri olarak fırlıyor ve HTTP (Hypertext Transfer Protocol) katmanında status code'a çevriliyor, validation'ın bir kısmı da controller'da kalmış. Console komutundan çağırmak istediğinde exception'ları yeniden yorumlaman, validation'ı yeniden yazman gerekiyor.

05. yazıda dört project'li solution'ı kurup PlaceOrderHandler'ı bu hatayı yapmayacak şekilde, tek bir IUnitOfWork ile yazmıştık. Bu yazı o handler'ın oturduğu katmana, Application'a odaklanıyor. Robert C. Martin 2012'deki Clean Architecture yazısında bu çembere "Use Cases" diyor ve içine uygulamaya özgü iş kurallarını koyuyor: entity'lere giren ve çıkan veri akışını yöneten, ama ne UI (user interface) ne de veritabanı değiştiğinde değişmesi gerekmeyen kod. 05'teki ilk hali bu tanımla birkaç adım daha ileri götüreceğiz: her use case için ortak bir handler sözleşmesi, iki ayrı yerde iki ayrı validation, hata türünü taşıyan bir Result, tek bir transaction sınırı ve cross-cutting işler için MediatR'sız bir decorator.

Use Case = Tek Amaçlı Handler

Önce sözleşme. Application project'i, bütün use case'lerin uyduğu küçük bir interface tanımlıyor:

namespace Bookstore.Application;

// Bütün use case'lerin uyduğu sözleşme
public interface IUseCaseHandler<in TRequest, TResponse>
{
    Task<Result<TResponse>> HandleAsync(TRequest request, CancellationToken ct);
}

Sonra sipariş verme use case'i. Girdi, çıktı ve handler aynı dosyada duruyor; birini değiştiren diğerine de bakmak zorunda olduğu için yan yana olmaları işini kolaylaştırıyor. 05'tekinden iki farkı var: handler artık IUseCaseHandler'ı implement ediyor ve girdi tipinin adı PlaceOrderRequest değil PlaceOrderCommand:

using Bookstore.Domain.Catalog;
using Bookstore.Domain.Ordering;

namespace Bookstore.Application.Ordering;

public sealed record PlaceOrderCommand(string CustomerEmail, string PostalCode, int BookId, int Quantity);
public sealed record PlaceOrderResponse(int OrderId, decimal Subtotal, decimal ShippingFee, decimal Total);

public sealed class PlaceOrderHandler(
    IBookRepository books, IOrderRepository orders, IShippingRates rates, IUnitOfWork unitOfWork)
    : IUseCaseHandler<PlaceOrderCommand, PlaceOrderResponse>
{
    private const int GramsPerBook = 400;

    public async Task<Result<PlaceOrderResponse>> HandleAsync(PlaceOrderCommand command, CancellationToken ct)
    {
        Book? book = await books.FindAsync(command.BookId, ct);
        if (book is null)
            return new Error("book.not_found", $"Book {command.BookId} not found.", ErrorKind.NotFound);

        Order order = new(command.CustomerEmail, command.PostalCode);
        if (!order.TryAddItem(book, command.Quantity))
            return new Error("book.out_of_stock", $"Only {book.Stock} copies of '{book.Title}' left.", ErrorKind.Conflict);

        order.ApplyShipping(await rates.QuoteAsync(command.PostalCode, command.Quantity * GramsPerBook, ct));
        orders.Add(order);
        await unitOfWork.SaveChangesAsync(ct); // tek SaveChanges: stok ve sipariş aynı transaction'da
        return new PlaceOrderResponse(order.Id, order.Subtotal, order.ShippingFee, order.Total);
    }
}

İsim değişikliği kozmetik değil. PlaceOrderCommand bir HTTP request'i değil, kullanıcının niyetinin uygulama dilindeki karşılığı: hangi müşteri, hangi kitaptan kaç adet, nereye. İçinde JSON (JavaScript Object Notation) attribute'u, route bilgisi ya da validation attribute'u yok. HTTP'nin request'i aşağıda Web'e taşınıyor; adı da onunla birlikte gidiyor. Fiyat da command'da yok: fiyatı client'ın söylediği değerden değil, katalogdaki kitaptan alıyoruz.

Handler'ın constructor'ındaki dört bağımlılık 05'te Application'ın tanımladığı port'lar: IBookRepository, IOrderRepository, IShippingRates ve IUnitOfWork. Implementasyonları Infrastructure'da. Handler akışı yönetiyor ama kuralları bilmiyor: stok kontrolü Book.TryReserve'de, ücretsiz kargo eşiği Order.ApplyShipping'te, ikisi de Domain'de. Handler sadece sırayı kuruyor: kitabı getir, bulunamazsa raporla, domain'e kalemi ekle de, kargo ücretini sor, sonucu kaydet. Martin'in tarifindeki "orchestration" tam olarak bu.

Handler'ı Endpoint'e Bağlamak

Web project'i HTTP'yi konuşan driving adapter. Request'i alıyor, command'a çeviriyor, handler'ı çağırıyor ve sonucu HTTP response'una çeviriyor. 05'te Program.cs'in içinde duran endpoint'i kendi dosyasına taşıdık:

using System.ComponentModel.DataAnnotations;
using Bookstore.Application;
using Bookstore.Application.Ordering;

namespace Bookstore.Web.Ordering;

// HTTP sözleşmesi Web'in; Application yalnız command'ı tanıyor
public sealed record PlaceOrderRequest(
    [Required, EmailAddress] string CustomerEmail,
    [Required, RegularExpression("^[0-9]{5}$")] string PostalCode,
    [Range(1, int.MaxValue)] int BookId,
    [Range(1, 99)] int Quantity)
{
    public PlaceOrderCommand ToCommand() => new(CustomerEmail, PostalCode, BookId, Quantity);
}

public static class OrderEndpoints
{
    public static IEndpointRouteBuilder MapOrderEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapPost("/orders", async (
            PlaceOrderRequest request,
            IUseCaseHandler<PlaceOrderCommand, PlaceOrderResponse> handler,
            CancellationToken ct) =>
        {
            Result<PlaceOrderResponse> result = await handler.HandleAsync(request.ToCommand(), ct);
            return result.IsSuccess
                ? Results.Created($"/orders/{result.Value!.OrderId}", result.Value)
                : result.Error!.ToProblem();
        });
        return app;
    }
}
using Bookstore.Application;
using Bookstore.Infrastructure;
using Bookstore.Web.Ordering;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddProblemDetails();
builder.Services.AddValidation();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);

WebApplication app = builder.Build();
await app.Services.InitialiseDatabaseAsync();

app.MapOrderEndpoints();
app.Run();

Handler'ı endpoint'e vermenin iki yolu var. Birincisi 05'te yaptığımız: handler'ı somut tipiyle kaydetmek ve öyle istemek:

builder.Services.AddScoped<PlaceOrderHandler>();
app.MapPost("/orders", async (PlaceOrderRequest request, PlaceOrderHandler handler, CancellationToken ct) =>
{
    Result<PlaceOrderResponse> result = await handler.HandleAsync(request.ToCommand(), ct);
    return result.IsSuccess ? Results.Created($"/orders/{result.Value!.OrderId}", result.Value) : result.Error!.ToProblem();
});

Bu yol sade: bir interface daha yok, "Go to Definition" doğrudan handler'a götürüyor, kayıt satırı da tek. Bedeli, handler'ın etrafına bir şey saramaman. Logging, ölçüm ya da retry gibi bir işi bütün use case'lere aynı anda eklemek istediğinde her handler'a tek tek dokunman gerekir. İkinci yol, yukarıdaki gibi IUseCaseHandler<TRequest, TResponse> üzerinden istemek. Bir generic interface'in maliyeti var, ama aşağıda göreceğin decorator'ı mümkün kılan da o. Tek bir use case varken birinci yol yeterli; on, yirmi use case'e çıktığında ikincisine geçmek bir refactoring meselesi.

Validation: İki Yer, İki Ayrı Soru

"Validation nerede yapılır?" sorusu aslında iki ayrı soru. Birincisi "bu istek şekil olarak anlamlı mı?": e-posta geçerli mi, posta kodu beş haneli mi, adet pozitif mi? Bu, HTTP sözleşmesinin parçası ve Web'de, request record'unun üzerinde duruyor. ASP.NET Core Web API serisinin ".NET 10 Yenilikleri: ASP.NET Core'da AddValidation, OpenAPI 3.1 ve Server-Sent Events" yazısında anlattığımız AddValidation(), endpoint parametresi olan record'daki DataAnnotations attribute'larını handler çalışmadan önce kontrol ediyor:

POST /orders {"customerEmail":"not-an-email","postalCode":"6100","bookId":1,"quantity":0}
HTTP 400
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"CustomerEmail":["The CustomerEmail field is not a valid e-mail address."],"PostalCode":["The field PostalCode must match the regular expression '^[0-9]{5}$'."],"Quantity":["The field Quantity must be between 1 and 99."]}}

Handler hiç çağrılmadı. Burada bir ayrıntı önemli: validation'ın metadata'sı bir source generator tarafından, AddValidation'ın çağrıldığı assembly için üretiliyor. Request record'unu Web'de tuttuğumuz için generator onu endpoint imzasından buluyor. Aynı attribute'ları Application'daki command'a koysaydın, Microsoft'un dokümanına göre o class library için ayrı bir paket referansı ve ayrı bir AddValidation kaydı gerekirdi. Bu teknik detay doğru mimari kararla örtüşüyor: input'un şekli Web'in sorunu, Application'ın değil.

İkinci soru "bu istek iş kurallarına uyuyor mu?": stok yetiyor mu? Bu, kim çağırırsa çağırsın geçerli olması gereken bir invariant ve 05'ten beri Domain'de duruyor:

namespace Bookstore.Domain.Catalog;

public sealed class Book(int id, string title, decimal price, int stock)
{
    public int Id { get; } = id;
    public string Title { get; } = title;
    public decimal Price { get; } = price;
    public int Stock { get; private set; } = stock;

    public bool TryReserve(int quantity)
    {
        if (quantity <= 0 || quantity > Stock) return false;
        Stock -= quantity;
        return true;
    }
}

Dikkat edersen TryReserve sıfır ya da negatif adedi de reddediyor, Web'deki [Range(1, 99)] de. Bu bir tekrar değil. Web'deki kural "bu API'de sıfır ya da 99'dan fazla adet anlamsız, kullanıcıya alan bazında hata göster" diyor. Domain'deki kural "kim çağırırsa çağırsın stok negatif bir sayıyla değiştirilemez" diyor. Siparişi console komutundan, test'ten ya da başka bir driving adapter'dan oluşturduğunda Web'in validation'ı devrede değil, Domain'inki her zaman devrede. FluentValidation ile Application'da command validator'ları yazmak da yaygın bir tercih; Web API serisinin "ASP.NET Core'da Model Validation: Data Annotations ve FluentValidation" yazısında karşılaştırmıştık. O yolu seçersen bile invariant'ları Domain'den taşıma.

Result mı Exception mı?

Handler 05'ten beri Task<PlaceOrderResponse> değil Task<Result<PlaceOrderResponse>> döndürüyor. Result ve Error Application'ın kökündeki küçük tipler. Bu yazıda Error'a bir alan ekledik: hatanın türü.

namespace Bookstore.Application;

public enum ErrorKind { Validation, NotFound, Conflict }

public sealed record Error(string Code, string Message, ErrorKind Kind);

public sealed class Result<T>
{
    private Result(T? value, Error? error) => (Value, Error) = (value, error);

    public T? Value { get; }
    public Error? Error { get; }
    public bool IsSuccess => Error is null;

    public static implicit operator Result<T>(T value) => new(value, null);
    public static implicit operator Result<T>(Error error) => new(default, error);
}

Ayrım şu: "kitap bulunamadı" ve "stok yetmedi" beklenen iş sonuçları. Her gün olacaklar ve çağıran tarafın onlara bir cevabı olmalı. Bunları exception ile anlatmak akışı görünmez hale getiriyor: açılıştaki LegacyOrderService'in imzası başarısızlık ihtimalinden hiç bahsetmiyor, çağıran taraf hangi exception'ları yakalaması gerektiğini ancak kodu okuyarak öğreniyor. Result ise başarısızlığı dönüş tipine yazıyor. .NET'in kendisi de beklenen başarısızlıklar için aynı yolu seçiyor: int.TryParse, Dictionary.TryGetValue ya da System.Buffers içindeki OperationStatus enum'u exception fırlatmak yerine sonucu döndürüyor. Domain'deki TryReserve ve TryAddItem de aynı Try pattern'ini kullanıyor; Domain hiçbir şeye bağımlı olmadığı için Result'ı tanımıyor, bool döndürüyor, handler bunu bir Error'a çeviriyor. Clean Code serisinin "Clean Code'da Error Handling: Exception, Try Pattern ve Result Pattern" yazısında ve Design Patterns serisinin "GoF Dışında Bilmen Gereken Kod Seviyesi Pattern'ler: Null Object, Specification, Result ve Pipeline" yazısında bu pattern'i ayrıntılı işlemiştik; burada mimari tarafına bakıyoruz.

Exception'lar ortadan kalkmıyor. Açılıştaki HttpRequestException gibi altyapı arızaları, yani kargo firmasına ya da veritabanına ulaşılamaması, bir iş sonucu değil; onlar ASP.NET Core Web API serisinin "ASP.NET Core'da Global Hata Yönetimi: Exception Middleware ve ProblemDetails" yazısındaki global handler'a gider ve 500 olur.

Result'u HTTP'ye çevirmek Web'in işi. Error HTTP'yi bilmiyor, sadece hatanın türünü (ErrorKind) taşıyor; status code seçimi Web'deki tek bir extension method'da:

using Bookstore.Application;

namespace Bookstore.Web;

public static class ErrorMapping
{
    public static IResult ToProblem(this Error error) => Results.Problem(
        statusCode: error.Kind switch
        {
            ErrorKind.NotFound => StatusCodes.Status404NotFound,
            ErrorKind.Conflict => StatusCodes.Status409Conflict,
            _ => StatusCodes.Status422UnprocessableEntity
        },
        title: error.Code,
        detail: error.Message);
}
POST /orders {"customerEmail":"ada@example.com","postalCode":"06100","bookId":42,"quantity":1}
HTTP 404
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"book.not_found","status":404,"detail":"Book 42 not found."}
POST /orders {"customerEmail":"ada@example.com","postalCode":"06100","bookId":1,"quantity":2}
HTTP 409
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"book.out_of_stock","status":409,"detail":"Only 1 copies of 'Dune' left."}

(Çıktılarda traceId alanını kısalık için çıkardım.) 05'teki endpoint her hatayı 409'a çeviriyordu; artık bulunamayan kitap 404, stok çatışması 409. AddProblemDetails() çağrıldığı için Results.Problem, RFC (Request for Comments) 9457'deki ProblemDetails formatını üretiyor ve type alanını status code'a göre kendisi dolduruyor. 400 ile 422'nin farkı da bilinçli: 400 "isteğin şekli bozuk", 422 "istek anlaşıldı ama bir iş kuralına takıldı". 422'yi bir sonraki yazıda, kargo firmasının teslimat yapmadığı posta kodları için kullanacağız.

Transaction Sınırı: Tek SaveChanges = Unit of Work

Açılıştaki yarım siparişin çözümü handler'ın son satırlarında: repository'ler sadece değişiklikleri biriktiriyor, kargo ücreti commit'ten önce soruluyor, commit'i handler bir kez, en sonda yapıyor. IUnitOfWork Application'da tanımlı, implementasyonu Infrastructure'daki DbContext:

public interface IUnitOfWork
{
    Task SaveChangesAsync(CancellationToken ct);
}
internal sealed class BookstoreDbContext(DbContextOptions<BookstoreDbContext> options)
    : DbContext(options), IUnitOfWork
{
    public DbSet<Book> Books => Set<Book>();
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(BookstoreDbContext).Assembly);

    Task IUnitOfWork.SaveChangesAsync(CancellationToken ct) => SaveChangesAsync(ct);
}

Unit of Work, Martin Fowler'ın PoEAA (Patterns of Enterprise Application Architecture, 2002) kitabındaki adıyla, bir iş işlemi boyunca etkilenen object'leri takip edip değişiklikleri tek seferde yazan pattern. EF Core'un DbContext'i bunu zaten yapıyor; Microsoft'un API dokümanı da DbContext'i Unit of Work ve Repository pattern'lerinin birleşimi olarak tarif ediyor. Bizim IUnitOfWork'ümüz bu davranışa Application'ın dilinden bakan tek method'luk bir pencere. EF Core'un o tek SaveChangesAsync çağrısında ne yaptığını görmek için Microsoft.EntityFrameworkCore.Database.Command ve Database.Transaction log kategorilerini açtım:

dbug: Microsoft.EntityFrameworkCore.Database.Transaction[20209]
      Beginning transaction with isolation level 'Unspecified'.
dbug: Microsoft.EntityFrameworkCore.Database.Transaction[20200]
      Began transaction with isolation level 'Serializable'.
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[@p1='?' (DbType = Int32), @p0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      UPDATE "Books" SET "Stock" = @p0
      WHERE "Id" = @p1
      RETURNING 1;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[@p0='?' (Size = 15), @p1='?' (Size = 5), @p2='?' (DbType = Decimal)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "Orders" ("CustomerEmail", "PostalCode", "ShippingFee")
      VALUES (@p0, @p1, @p2)
      RETURNING "Id";
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[@p3='?' (DbType = Int32), @p4='?' (DbType = Int32), @p5='?' (DbType = Int32), @p6='?' (DbType = Decimal)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "OrderItems" ("BookId", "OrderId", "Quantity", "UnitPrice")
      VALUES (@p3, @p4, @p5, @p6)
      RETURNING "Id";
dbug: Microsoft.EntityFrameworkCore.Database.Transaction[20210]
      Committing transaction.
dbug: Microsoft.EntityFrameworkCore.Database.Transaction[20202]
      Committed transaction.
dbug: Microsoft.EntityFrameworkCore.Database.Transaction[20204]
      Disposing transaction.

Stok güncellemesi, sipariş ve sipariş kalemi tek bir transaction içinde gidiyor. Kargo çağrısı patlasaydı handler SaveChangesAsync'e hiç ulaşmayacaktı; bellekte düşürülen stok da scope bitince DbContext ile birlikte çöpe gidecekti. Bunu 07. yazıda gerçek kargo adapter'ını kapatarak ölçeceğiz.

Kural "her use case tek SaveChanges". Bir use case'in içinde bir dış servise yazıp sonra tekrar veritabanına yazman gerekiyorsa, ya da bir event'i güvenilir şekilde yayınlaman gerekiyorsa, tek transaction yetmez; orada explicit transaction ya da outbox gibi bir pattern devreye girer. Onlar ilerideki CQRS (Command Query Responsibility Segregation) ve Event Sourcing serisinin konusu.

Decorator ile Cross-Cutting İşler

Her use case'in ne kadar sürdüğünü ve hangi hata koduyla bittiğini loglamak istiyoruz. Bunu handler'ların içine yazmak her birine aynı beş satırı kopyalamak demek. Bunun yerine handler'ı saran bir decorator yazıyoruz:

using System.Diagnostics;
using Microsoft.Extensions.Logging;

namespace Bookstore.Application.Behaviors;

public sealed class LoggingHandlerDecorator<TRequest, TResponse>(
    IUseCaseHandler<TRequest, TResponse> inner,
    ILogger<LoggingHandlerDecorator<TRequest, TResponse>> logger) : IUseCaseHandler<TRequest, TResponse>
{
    public async Task<Result<TResponse>> HandleAsync(TRequest request, CancellationToken ct)
    {
        long start = Stopwatch.GetTimestamp();
        Result<TResponse> result = await inner.HandleAsync(request, ct);
        TimeSpan elapsed = Stopwatch.GetElapsedTime(start);

        if (result.IsSuccess)
            logger.LogInformation("{UseCase} succeeded in {ElapsedMs:0} ms", typeof(TRequest).Name, elapsed.TotalMilliseconds);
        else
            logger.LogWarning("{UseCase} failed with {ErrorCode} in {ElapsedMs:0} ms", typeof(TRequest).Name, result.Error!.Code, elapsed.TotalMilliseconds);
        return result;
    }
}

Decorator aynı interface'i implement ediyor ve asıl işi içerideki handler'a bırakıyor. .NET'te bu pattern'in en tanıdık örneği Stream ailesi: BufferedStream ve GZipStream başka bir Stream'i alıp onun etrafına buffering ya da sıkıştırma ekliyor, dışarıdan yine bir Stream olarak görünüyor. Design Patterns serisinin "Decorator Pattern: Caching, Logging ve Retry'ı Koda Dokunmadan Eklemek" yazısında anlattığımız fikrin aynısı.

Built-in DI (dependency injection) container'ı decorator kaydını doğrudan desteklemiyor. Scrutor (sürüm 7.0.0) iki iş yapıyor: assembly'yi tarayıp handler'ları buluyor ve Decorate ile hepsini sarıyor. 05'teki tek satırlık AddScoped<PlaceOrderHandler>() şuna dönüşüyor:

using Bookstore.Application.Behaviors;
using Microsoft.Extensions.DependencyInjection;

namespace Bookstore.Application;

public static class DependencyInjection
{
    public static IServiceCollection AddApplication(this IServiceCollection services)
    {
        // IUseCaseHandler<,> implement eden bütün somut handler'ları bul (generic decorator'ın kendisi hariç)
        services.Scan(scan => scan
            .FromAssemblyOf<Error>()
            .AddClasses(c => c.AssignableTo(typeof(IUseCaseHandler<,>)).Where(t => !t.IsGenericTypeDefinition))
            .AsImplementedInterfaces()
            .WithScopedLifetime());

        // her handler'ı logging decorator'ıyla sar
        services.Decorate(typeof(IUseCaseHandler<,>), typeof(LoggingHandlerDecorator<,>));
        return services;
    }
}

Uygulama çalışırken, handler'a ulaşan üç isteğin logları (ilki başarılı bir sipariş, 400 dönen istek handler'a hiç ulaşmadı):

info: Bookstore.Application.Behaviors.LoggingHandlerDecorator[0]
      PlaceOrderCommand succeeded in 97 ms
warn: Bookstore.Application.Behaviors.LoggingHandlerDecorator[0]
      PlaceOrderCommand failed with book.out_of_stock in 14 ms
warn: Bookstore.Application.Behaviors.LoggingHandlerDecorator[0]
      PlaceOrderCommand failed with book.not_found in 0 ms

Yeni bir use case eklediğinde ne loglamayı ne de kaydı düşünüyorsun; class'ı yazman yeterli. Aynı yöntemle bir caching ya da retry decorator'ı da ekleyebilirsin. Sıra önemli: Decorate çağrıları sırayla dıştan içe doğru sarar, son çağrılan en dışta kalır.

MediatR'a Gerçekten Gerek Var mı?

Buraya kadar yaptıklarımız, MediatR kullanan bir projede IRequestHandler ve IPipelineBehavior ile yapılanların karşılığı. MediatR'ın sana eklediği esas şey bir dispatcher: endpoint handler'ı bilmek yerine mediator.Send(command) der, library request'in tipine bakıp doğru handler'ı bulur. Design Patterns serisinin "Mediator Pattern: Object'ler Arası Konuşmayı Merkezileştirmek ve 'MediatR'a Gerçekten İhtiyacım Var mı?'" yazısında gördüğümüz gibi, ayrıştırmanın büyük kısmı handler'ı ayrı bir class yapmaktan geliyor, dispatcher'dan değil. Endpoint'in parametresinde IUseCaseHandler<PlaceOrderCommand, PlaceOrderResponse> görmek, hangi use case'in çağrıldığını derleyiciye de okuyana da açıkça söylüyor.

Bir de lisans konusu var. MediatR 13.0.0'dan itibaren ticari lisanslı: kod RPL (Reciprocal Public License) 1.5 ile, bunu istemeyenler için ayrı bir ticari sözleşmeyle sunuluyor. Bu yazının yazıldığı Kasım 2026'da resmî sitedeki Community edition şartları şöyle: yıllık geliri 5 milyon doların altında olan, 10 milyon dolardan fazla dış yatırım almamış, kamu kurumu olmayan şirketler ücretsiz kullanabiliyor. 12.x ve öncesi eski açık kaynak lisansıyla (Apache 2.0) kalıyor. Yaygın template'ler de ikiye ayrıldı: jasontaylordev/CleanArchitecture hâlâ MediatR 14 kullanırken, ardalis/CleanArchitecture v11'de lisans değişikliğini gerekçe göstererek source generator tabanlı Mediator library'sine geçti. MediatR'ı seçip seçmemenin bir mimari karar olarak tartışması ilerideki Vertical Slice Architecture serisinin 03. yazısında. Clean Architecture açısından cevap basit: Dependency Rule'u korumak için bir dispatcher'a ihtiyacın yok.

Sık Düşülen Tuzaklar

Assembly taramasının generic decorator'ı da handler sanması. İlk denemede tarama satırında yalnızca AssignableTo(typeof(IUseCaseHandler<,>)) vardı ve LoggingHandlerDecorator<,> da bu interface'i implement ettiği için open generic bir handler olarak kaydedildi:

services.Scan(scan => scan
    .FromAssemblyOf<Error>()
    .AddClasses(c => c.AssignableTo(typeof(IUseCaseHandler<,>)), publicOnly: true)
    .AsImplementedInterfaces()
    .WithScopedLifetime());
services.Decorate(typeof(IUseCaseHandler<,>), typeof(LoggingHandlerDecorator<,>));

Mevcut use case çalıştığı için fark etmedim. Handler'ı henüz yazılmamış bir request tipini çözmeye çalışınca container decorator'ı decorator'ın içine koymaya kalktı:

InvalidOperationException: A circular dependency was detected for the service of type 'Bookstore.Application.IUseCaseHandler<string, int>'.
Bookstore.Application.IUseCaseHandler<string, int>(Bookstore.Application.Behaviors.LoggingHandlerDecorator<string, int>) -> Bookstore.Application.IUseCaseHandler<string, int>

Çözüm yukarıdaki Where(t => !t.IsGenericTypeDefinition) filtresi. Kayıtları bir kere listeleyip kontrol etmek, bu tür sürprizleri test'e gerek kalmadan gösteriyor.

Repository'nin içinde SaveChanges çağırmak. Açılıştaki yarım siparişin sebebi. Repository değişikliği biriktirir, commit kararını use case verir.

Handler'dan handler çağırmak. PlaceOrderHandler içinde başka bir use case'in handler'ını inject edip çağırmak iki transaction sınırını iç içe sokar ve decorator'ların iki kez çalışmasına yol açar. Ortak bir adım varsa onu bir domain service'e ya da Application içinde sıradan bir class'a çıkar.

Application'a HTTP sızdırmak. Handler'ın IResult, HttpContext ya da ProblemDetails döndürmesi, use case'i console'dan çağırmayı imkânsız hale getiriyor. Kontrolü kolay: Application'ın .csproj'unda Microsoft.AspNetCore.App framework referansı görüyorsan bir şeyler kaçmış. Bunu 08. yazıda bir architecture test'iyle otomatik hale getireceğiz.

Her kontrolü tekrar sanmak. Input validation ile invariant'ın aynı sınırı kontrol etmesi tekrar değil, iki farklı soruya iki cevap. Gerçek tekrar, bir iş kuralının, örneğin ücretsiz kargo eşiğinin, hem Domain'de hem de bir endpoint'te ya da handler'da hesaplanmasıdır; o zaman bir gün biri sadece birini günceller.

Küçük Bir Deneme

★☆☆ PlaceOrderHandler'ı somut tipiyle kaydettiğin varyantta LoggingHandlerDecorator'ın loglarını bir daha görüyor musun? Neden?

★★☆ PlaceOrderCommand'ı birden çok kitap alacak şekilde genişlet. Kitaplardan biri bulunamazsa ya da stoğu yetmezse hiçbir şey kaydedilmemeli. Bunu 05'teki fake'lerle nasıl test edersin?

★★★ Bir TimingBudgetDecorator ekle: use case 500 ms'yi geçerse warning loglasın. Decorate çağrılarının sırasını değiştirip loglama decorator'ının ölçtüğü süreye bu decorator'ın kendi süresinin dahil olup olmadığını gözlemle.

Application artık port'larını tanımlıyor, ama IShippingRates'in arkasında hâlâ 05'teki sabit ücretli yer tutucu duruyor. Bir sonraki yazıda Infrastructure'a geçiyoruz: EF Core mapping'inin domain'e dokunmadan nasıl kurulduğu, persistence ignorance'ın nerede bittiği ve kargo firmasının API'sini typed HttpClient ile bir driven adapter'a nasıl sardığımız.