Mert Özen Her Satırda Daha İleri
Backend .NET Core Web API — #15

ASP.NET Core Web API'de DTO ve Mapping: Elle Mapping, AutoMapper Lisansı ve Mapperly

Mert Özen 4 Kas 2026 10 dk 2 görüntülenme
ASP.NET Core Web API'de DTO ve Mapping: Elle Mapping, AutoMapper Lisansı ve Mapperly

Entity'yi doğrudan dönmenin bedelini gerçek hatalarla görüyoruz: overposting ve döngüsel referans. Request/response DTO'ları, elle mapping ve projection, AutoMapper'ın 2025 lisans değişikliği ve CVE'si, Mapperly'ye kısa bakış.

Sürüm notu: Bu yazıdaki kodlar .NET 6 ile yazılmıştır ve .NET 10'da da değişmeden çalışır.

Önce Tahmin Et

Bir önceki yazıda ProductsController'ı doğrudan Product entity'si alıp dönecek şekilde yazdık. Veritabanında iki kategori var: Keyboards (1) ve Mice (2). Bir istemci şu isteği atıyor:

curl -i -X POST http://localhost:5080/api/products -H "Content-Type: application/json" -d '{"name":"USB-C Hub","price":890,"categoryId":1,"category":{"name":"Free Stuff"}}'

İki soru. İstemci hangi status code'u alır? Ve istek bittiğinde veritabanında yeni ürünün CategoryId'si kaç olur?

Entity'yi Doğrudan Dönmenin Bedeli

Cevap şu iki satırda:

HTTP/1.1 500 Internal Server Error

curl http://localhost:5080/api/products/3
{"id":3,"name":"USB-C Hub","price":890.0,"categoryId":3,"category":null}

İstemci 500 aldı ama ürün kaydedildi. Üstelik 1 numaralı kategoriye değil, 3 numaraya. Çünkü body'deki category object'i model binding ile entity'nin navigation property'sine bağlandı, EF Core da onu yeni bir kategori olarak gördü ve "Free Stuff" adında bir kategori ekledi. Ürün endpoint'i, istemciye fark ettirmeden kategori oluşturan bir endpoint'e dönüştü. Bu açığın adı overposting: istemci, API'nin ona açmayı hiç düşünmediği alanları doldurabiliyor. Body'ye "id":1 koysaydı EF Core o Id ile INSERT denerdi ve UNIQUE constraint failed: Products.Id ile yine 500 alırdı.

Peki 500 nereden geldi? Kayıt bittikten sonra, response yazılırken. Ürünün Category'si dolu, kategorinin Products listesinde de aynı ürün var. System.Text.Json bu halkada döner durur ve 32 seviyede pes eder:

System.Text.Json.JsonException: A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 32. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path: $.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Id.

Önceki yazının son denemesinde Include(p => p.Category) ile gördüğün hata da buydu. Bir de sözleşme meselesi var. Entity'yi dönersen API'nin JSON şekli veritabanı şemana kilitlenir: bir kolonun adını değiştirdiğin gün mobil uygulama bozulur, entity'ye maliyet fiyatı gibi iç bir alan eklediğin gün o alan herkese açılır. Önceki yazıda Category'yi nullable yapmamızın sebebi de buydu; Category Category { get; set; } = null! yazsaydık her POST şununla dönerdi:

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"One or more validation errors occurred.","status":400,"traceId":"00-49c51f3540309834324d987d1ae26980-e045677d908859f7-00","errors":{"Category":["The Category field is required."]}}

Veritabanı modeli ile API sözleşmesi farklı ihtiyaçlara hizmet eder. Bir restoranın mutfağındaki tezgâh düzeni ile müşteriye verilen menü aynı şey değildir. DTO (Data Transfer Object), menüdür.

Response ve Request DTO'ları

Dışarıya dönen şekil için bir record yeterli:

namespace ProductCatalog.Dtos;

public record ProductDto(int Id, string Name, decimal Price, string CategoryName);

Kategori object'inin tamamı yerine sadece adı var. Döngü ihtimali kalmadı, istemci de ihtiyacı olanı tek alanda alıyor. Gelen veri için ise ayrı class'lar yazıyoruz:

using System.ComponentModel.DataAnnotations;

namespace ProductCatalog.Dtos;

public class CreateProductRequest
{
    [Required]
    [StringLength(100)]
    public string Name { get; set; } = string.Empty;

    [Range(0.01, 1000000)]
    public decimal Price { get; set; }

    [Range(1, int.MaxValue)]
    public int CategoryId { get; set; }
}
using System.ComponentModel.DataAnnotations;

namespace ProductCatalog.Dtos;

public class UpdateProductRequest
{
    [Required]
    [StringLength(100)]
    public string Name { get; set; } = string.Empty;

    [Range(0.01, 1000000)]
    public decimal Price { get; set; }
}

Request DTO'larında Id yok, Category object'i yok. İstemcinin gönderebileceği alanları artık sen seçiyorsun; body'de fazladan bir alan gelirse System.Text.Json onu sessizce yok sayar. Create ile Update'in farklı class'lar olması da bilinçli: güncellemede kategoriyi değiştirmeye izin vermiyoruz. Bugün aynı görünen iki request yarın ayrışır; baştan ayırmak, sonra ayırmaktan ucuzdur. Validation attribute'ları da entity yerine burada duruyor, çünkü kural veritabanının değil, API'nin kuralı.

Elle Mapping

Entity ile DTO arasındaki dönüşümü yazmanın en sade yolu birkaç extension method:

using ProductCatalog.Dtos;
using ProductCatalog.Models;

namespace ProductCatalog.Mapping;

public static class ProductMappings
{
    public static ProductDto ToDto(this Product product)
    {
        return new ProductDto(product.Id, product.Name, product.Price, product.Category?.Name ?? string.Empty);
    }

    public static Product ToEntity(this CreateProductRequest request)
    {
        return new Product
        {
            Name = request.Name,
            Price = request.Price,
            CategoryId = request.CategoryId
        };
    }

    public static void ApplyTo(this UpdateProductRequest request, Product product)
    {
        product.Name = request.Name;
        product.Price = request.Price;
    }
}

Okuma tarafında ise mapping'i sorgunun içine koyuyoruz:

List<ProductDto> products = await _db.Products
    .Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Category!.Name))
    .ToListAsync();

EF Core bu Select'i SQL'e çevirir ve konsola şunu yazar:

SELECT "p"."Id", "p"."Name", "p"."Price", "c"."Name"
FROM "Products" AS "p"
INNER JOIN "Categories" AS "c" ON "p"."CategoryId" = "c"."Id"

Include yazmadık ama JOIN geldi, çünkü projection'da p.Category!.Name'e dokunduk. Veritabanından yalnızca dört kolon okundu. Buna projection denir ve okuma endpoint'lerinde entity yükleyip sonra map'lemekten hem daha hızlı hem daha güvenlidir. ! operatörü sadece derleyiciyi susturuyor; ifade SQL'e çevrildiği için çalışma anında null kontrolü yapılmıyor.

Controller'ı DTO'larla Yeniden Yazmak

[HttpGet("{id:int}")]
public async Task<ActionResult<ProductDto>> GetById(int id)
{
    ProductDto? product = await _db.Products
        .Where(p => p.Id == id)
        .Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Category!.Name))
        .FirstOrDefaultAsync();
    if (product is null)
    {
        return NotFound();
    }

    return product;
}

[HttpPost]
public async Task<ActionResult<ProductDto>> Create(CreateProductRequest request)
{
    Category? category = await _db.Categories.FindAsync(request.CategoryId);
    if (category is null)
    {
        ModelState.AddModelError(nameof(request.CategoryId), "Category does not exist.");
        return ValidationProblem(ModelState);
    }

    Product product = request.ToEntity();
    product.Category = category;
    _db.Products.Add(product);
    await _db.SaveChangesAsync();
    return CreatedAtAction(nameof(GetById), new { id = product.Id }, product.ToDto());
}

[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, UpdateProductRequest request)
{
    Product? product = await _db.Products.FindAsync(id);
    if (product is null)
    {
        return NotFound();
    }

    request.ApplyTo(product);
    await _db.SaveChangesAsync();
    return NoContent();
}

GetAll ve Delete yukarıdaki projection'la ve önceki yazıdaki hâliyle aynı. Tahmin sorusundaki isteği tekrar atalım, ardından var olmayan bir kategoriyle deneyelim:

HTTP/1.1 201 Created
Location: http://localhost:5080/api/Products/3

{"id":3,"name":"USB-C Hub","price":890,"categoryName":"Keyboards"}

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"One or more validation errors occurred.","status":400,"traceId":"00-89556156a96fa39d471e9173f02ff161-b5b98d30dac9e766-00","errors":{"CategoryId":["Category does not exist."]}}

Body'deki id ve category yok sayıldı, ürün istenen kategoriye bağlandı ve response'ta döngü yok. Var olmayan kategori artık 500 değil, [ApiController]'ın validation hatalarıyla aynı biçimde bir 400. ValidationProblem(ModelState) bu yüzden işe yarıyor; istemci iki tür hatayı ayrı ayrı ele almak zorunda kalmıyor.

AutoMapper ile Aynı İş

Elle mapping'in itirazı belli: onlarca DTO'su olan bir projede aynı atamaları tekrar tekrar yazarsın. AutoMapper bu atamaları isim eşleştirme convention'larıyla kendisi yapar. Önce sürüm; bir sonraki bölümde nedenini anlatacağım:

dotnet add package AutoMapper --version 15.1.3
using AutoMapper;
using ProductCatalog.Dtos;
using ProductCatalog.Models;

namespace ProductCatalog.Mapping;

public class ProductProfile : Profile
{
    public ProductProfile()
    {
        CreateMap<Product, ProductDto>();
        CreateMap<CreateProductRequest, Product>()
            .ForMember(d => d.Id, o => o.Ignore())
            .ForMember(d => d.Category, o => o.Ignore());
    }
}
builder.Services.AddAutoMapper(cfg =>
{
    cfg.LicenseKey = builder.Configuration["AutoMapper:LicenseKey"];
}, typeof(Program));

typeof(Program), profile'ların aranacağı assembly'yi gösterir. ProductDto'daki CategoryName için hiçbir şey yazmadık: AutoMapper bu adı Category.Name olarak parçalar ve kendisi bulur. Buna flattening deniyor. Controller'da IMapper'ı enjekte edip iki yerde kullanıyoruz:

List<ProductDto> products = await _db.Products
    .ProjectTo<ProductDto>(_mapper.ConfigurationProvider)
    .ToListAsync();
Product product = _mapper.Map<Product>(request);
product.Category = category;
_db.Products.Add(product);
await _db.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = product.Id }, _mapper.Map<ProductDto>(product));

ProjectTo, elle yazdığımız Select ile birebir aynı SQL'i üretti; yine dört kolon ve bir JOIN. İki Ignore satırı neden orada? AutoMapper'ın en değerli özelliği, eksik mapping'leri uygulama açılırken yakalayabilmesi. Program.cs'te app.Services.GetRequiredService<IMapper>().ConfigurationProvider.AssertConfigurationIsValid() çağırdığımda, Ignore'lar yokken uygulama şu exception ile açılmadı:

Unhandled exception. AutoMapper.AutoMapperConfigurationException:
Unmapped members were found. Review the types and members below.
Add a custom mapping expression, ignore, add a custom resolver, or modify the source/destination type
For no matching constructor, add a no-arg ctor, add optional arguments, or map all of the constructor parameters
==========================================================================
CreateProductRequest -> Product (Destination member list)
ProductCatalog.Dtos.CreateProductRequest -> ProductCatalog.Models.Product (Destination member list)

Unmapped properties:
Id
Category

Hedefte karşılığı olmayan her alan için ya bir kural yazarsın ya da bilerek yok sayarsın. Bu kontrolü bir unit test'e koymak, mapping hatalarını canlıya çıkmadan yakalamanın en ucuz yolu.

AutoMapper'ın 2025 Lisans Değişikliği

AutoMapper'ı yıllarca "ücretsiz, MIT lisanslı" diye eklerdik. Bu 2025'te değişti; aşağıdaki bilgiler Jimmy Bogard'ın kendi blogundaki iki duyurudan ve automapper.io'dan. Bogard 2 Nisan 2025'te AutoMapper ve MediatR'ı ticarileştireceğini duyurdu. 2 Temmuz 2025'te ticari sürümler yayımlandı ve iki proje Lucky Penny Software adlı şirkete taşındı. AutoMapper 15.0 ve sonrası çift lisanslı: Reciprocal Public License 1.5 (RPL-1.5) ya da Lucky Penny'nin ticari lisansı. Ticari lisans geliştirici sayısına göre üç ücretli kademeye ayrılıyor. Yıllık geliri 5 milyon doları geçmeyen şirket ve bireyler, bütçesi aynı sınırın altındaki kâr amacı gütmeyen kuruluşlar, eğitim kullanımı ve production dışı ortamlar için ücretsiz bir Community lisansı var. Kamu kurumları bu lisanstan yararlanamıyor, dışarıdan büyük yatırım almış şirketler için de ek bir sınır var. Community lisansında bile bir lisans key'i alman isteniyor.

Key vermezsen AutoMapper çalışmayı reddetmiyor, açılışta bir uyarı logluyor:

warn: LuckyPennySoftware.AutoMapper.License[0]
      You do not have a valid license key for the Lucky Penny software AutoMapper. This is allowed for development and testing scenarios. If you are running in production you are required to have a licensed version. Please visit https://luckypennysoftware.com to obtain a valid license.

Key'i yukarıdaki gibi configuration'dan okumak, configuration yazısında gördüğün user secrets ve environment variable düzenine oturuyor. 15'ten önceki sürümler yayımlandıkları andaki MIT lisansıyla kalıyor. Ama "14'te kalırım" demeden önce bir şeyi bil: Mart 2026'da yayımlanan CVE-2026-32933 (yüksek önem, derin iç içe object'lerde kontrolsüz recursion ile process'i düşüren bir DoS açığı) 15.1.1'den önceki bütün sürümleri ve 16.0.0 ile 16.1.0'ı etkiliyor; düzeltme 15.1.1 ve 16.1.1'de. MIT lisanslı son sürümlerden biri olan 13.0.1'i eklediğimde restore açıkça uyardı:

warning NU1903: Package 'AutoMapper' 13.0.1 has a known high severity vulnerability, https://github.com/advisories/GHSA-rvv3-g6hj-g44x

Bu yüzden örnekte 15.1.3 var: düzeltmeyi içeriyor ve netstandard2.0 hedefiyle net6.0'da da net10.0'da da uyarısız derleniyor. 16.x'i net6.0 projesine eklediğimde ise Microsoft.Extensions paketlerinin 10.0 sürümlerini getirdi ve her biri için "doesn't support net6.0" uyarısı verdi; yalnızca .NET 10 hedefleyen bir projede 16.2.0 sorunsuz. Bir de API değişikliği var: 15'ten itibaren eski AddAutoMapper(typeof(Program)) çağrısı derlenmiyor ve CS1503 veriyor; yukarıdaki gibi bir configuration action'ı vermen gerekiyor.

Mapperly ve Hangisini Seçmeli

Üçüncü bir yol daha var: Mapperly. Apache-2.0 lisanslı bir source generator; mapping kodunu derleme anında üretir, yani çalışma anında reflection yok:

using ProductCatalog.Dtos;
using ProductCatalog.Models;
using Riok.Mapperly.Abstractions;

namespace ProductCatalog.Mapping;

[Mapper]
public static partial class ProductMapper
{
    public static partial ProductDto ToProductDto(Product product);

    public static partial IQueryable<ProductDto> ProjectToDto(this IQueryable<Product> query);
}

Riok.Mapperly 4.3.1 ile iki hedefte de derledim, flattening'i o da kendisi yaptı ve ProjectToDto aynı SQL'i üretti. Eşleşmeyen alanları derleme uyarısı olarak söylüyor:

warning RMG020: The member CategoryId on the mapping source type ProductCatalog.Models.Product is not mapped to any member on the mapping target type ProductCatalog.Dtos.ProductDto

Seçim için iki seçenek ve kısa tradeoff. Elle mapping: bağımlılık yok, lisans derdi yok, ne olduğunu herkes okur, bir alan eklediğinde record'un constructor'ı yüzünden derleyici seni bütün çağrı noktalarına götürür; bedeli tekrar eden atama kodu. Mapper library'si (AutoMapper ya da Mapperly): tekrar eden kod kaybolur ve büyük modellerde hız kazandırır; bedeli yeni bir bağımlılık, AutoMapper'da lisans ve "bu alan nereden geldi" sorusunun koda bakarak cevaplanamaması. Bu serinin büyüklüğündeki bir API için önerim elle mapping; DTO sayısı gerçekten çoğaldığında derleme zamanında çalışan Mapperly'ye bakardım.

Sık Düşülen Tuzaklar

Birincisi, extension method'u sorgunun içinde çağırmak. Şu kod derlenir ve çalışır:

List<ProductDto> products = await _db.Products
    .Select(p => p.ToDto())
    .ToListAsync();
[{"id":1,"name":"Mechanical Keyboard","price":1450.0,"categoryName":""},{"id":2,"name":"Wireless Mouse","price":520.0,"categoryName":""}]

Kategori adları boş geldi. EF Core ToDto'nun içini SQL'e çeviremez; entity'yi okuyup method'u bellekte çağırır. Category yüklenmediği için null'dır ve ?. sessizce boş string verir. Sorgu içinde ya açık bir new ProductDto(...) yaz ya da ProjectTo gibi SQL'e çevrilebilen bir araç kullan.

İkincisi, AutoMapper'da ReverseMap(). Tek satırla DTO'dan entity'ye ters yönlü map de oluşur ve overposting'i geri getirebilir. Request için her zaman ayrı ve açık bir map yaz.

Üçüncüsü, DTO'nun içine entity koymak. ProductDto'ya public Category Category eklersen kapıdan kovduğun bütün sorunlar pencereden geri girer; DTO'nun içindeki her tip de bir DTO olmalı.

Dördüncüsü, önce ToListAsync() sonra map. Bütün entity'leri bütün kolonlarıyla belleğe çekip sonra dönüştürürsün. Okuma endpoint'lerinde projection'ı sorgunun parçası yap.

Küçük Bir Deneme

★☆☆ CategoryDto adında bir record yaz (Id, Name, ProductCount) ve GET /api/categories endpoint'ini projection ile yaz. ProductCount için c.Products.Count kullan ve konsoldaki SQL'e bak.

★★☆ ProductDto'ya CategoryId ekle. Elle mapping'de derleyicinin seni hangi satırlara götürdüğünü say. Aynı değişikliği AutoMapper sürümünde yap; hiçbir satıra dokunmadan çalıştığını gör.

★★★ Mapperly ile CreateProductRequest'ten Product'a giden bir mapping method'u yaz. Derlediğinde Id ve Category için hangi RMG uyarılarını aldığını oku, ardından [MapperIgnoreTarget(nameof(Product.Id))] gibi attribute'larla bu iki alanı bilerek yok say ve uyarıların kaybolduğunu gör.

Controller'ımız artık veritabanını sorguluyor, kategorinin varlığını kontrol ediyor, mapping yapıyor ve HTTP yanıtını hazırlıyor. Bir action için fazla iş. Bir sonraki yazıda bu sorumlulukları bir service katmanına taşıyacak, repository pattern'in EF Core üzerinde gerçekten gerekli olup olmadığını tartışacak ve interface'lerin test edilebilirliğe ne kattığını göreceğiz.