Hot Chocolate 16 ile Bookstore GraphQL API'si: Schema, EF Core Entegrasyonu ve Mutation'lar
Hot Chocolate 16'da EF Core'u güvenle bağlamanın yolu, her resolver'a pooled factory'den kendi DbContext'ini vermek ve schema'ya açılan her alanı, filtreyi ve hatayı bilinçli seçmektir. Paylaşılan DbContext'in rastgele hatalarını, projection ve sorting'in ürettiği SQL'i, entity'den sızan alış fiyatını, mutation convention'larıyla typed error'ları ve onError'ı gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Hot Chocolate 16.7, Entity Framework Core (EF Core) 10.0 ve SQL Server 2025 ile derlenip denenmiştir.
Önce Tahmin Et
Catalog service'ine EF Core'u bağladın. BookstoreContext'i her ASP.NET Core projesinde yaptığın gibi AddDbContext ile scoped olarak kaydettin; yani her HTTP isteğine tek bir DbContext. Client şu sorguyu gönderiyor:
{
a: book(id: 1) { title }
b: book(id: 2) { title }
c: books(where: { id: { eq: 3 } }) { title }
}
Üç root field, üç ayrı veritabanı sorgusu, tek istek. Ne olur? Üçü sırayla çalışır ve sorunsuz döner mi, yoksa bir şey mi patlar? Patlarsa her seferinde aynı yerden mi?
Cevabı yazının ortasında, aynı sorguyu üç kez art arda gönderdiğim çıktıda göreceksin. İpucu: GraphQL'de query field'ları paralel çalışabilir, EF Core'un DbContext'i ise paralel kullanımı kaldırmaz.
Schema Yazmanın Üç Yolu ve Seçimimiz
Bir önceki yazıda GraphQL'in kavramlarını küçük bir in-memory veriyle görmüştük. Bu yazıda Bookstore'un Catalog service'ini gerçek bir SQL Server veritabanına, EF Core üzerinden bağlıyoruz. İlk karar schema'yı nasıl tanımlayacağımız. Hot Chocolate üç yol sunuyor:
| Yaklaşım | Schema nereden gelir | Güçlü yanı | Zayıf yanı |
|---|---|---|---|
| Implementation-first | Attribute'lu C# class'ları + source generator | En az kod, derleme anında analiz, resolver'lar düz method | Schema'nın son hâlini görmek için SDL'i (GraphQL şemasını yazmak için kullanılan dil) dışa aktarmak gerekir |
| Code-first | ObjectType<T> türetip Configure içinde fluent API | Her ayrıntı açıkça kontrol edilir | Uzun ve tekrarlı; resolver'lar delegate içinde kaybolur |
| Schema-first | Elle yazılmış SDL + resolver binding | Schema tasarımı koddan önce, ekipler SDL üzerinde anlaşır | SDL ile C# arasındaki uyumsuzluk çalışma anında ortaya çıkar |
Implementation-first ile devam ediyoruz; serinin önceki yazısında da bunu kullanmıştık. Hot Chocolate 16'nın source generator'ı [QueryType], [MutationType] ve [ObjectType<T>] ile işaretlenmiş static class'ları derleme sırasında bulur ve kayıt kodunu üretir. Generator HotChocolate.Types.Analyzers paketinde; bu paketi eklemeyi unutursan AddCatalogTypes() diye bir method hiç oluşmaz. Code-first'ü tamamen bırakmıyoruz: bir tipin ince ayarı gerektiğinde implementation-first class'ın içinde Configure method'u ile aynı fluent API'ye iniyoruz. Bunu aşağıda Book tipinde göreceksin.
Projeyi Kurmak: AddGraphQLServer ve DbContext Factory
Paketler: HotChocolate.AspNetCore, HotChocolate.Data.EntityFramework, HotChocolate.Types.Analyzers (üçü de 16.7.5; Hot Chocolate paketlerinin sürümleri birbirini tutmalı) ve Microsoft.EntityFrameworkCore.SqlServer 10.0.15. Entity'ler sıradan EF Core class'ları:
public sealed class Book
{
public int Id { get; set; }
public required string Title { get; set; }
public required string Isbn { get; set; }
public decimal Price { get; set; }
public decimal CostPrice { get; set; }
public string? SupplierNote { get; set; }
public int Stock { get; set; }
public int AuthorId { get; set; }
public Author? Author { get; set; }
}
CostPrice (alış fiyatı) ve SupplierNote (tedarikçi notu) iç bilgiler; bu ikisine birazdan döneceğiz. Author, Order ve OrderItem da aynı düzende. Veritabanında 10 yazar ve 50 kitap var. Program.cs:
using Bookstore.Catalog.Data;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPooledDbContextFactory<BookstoreContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Bookstore")));
builder.Services
.AddGraphQLServer()
.RegisterDbContextFactory<BookstoreContext>()
.AddCatalogTypes()
.AddProjections()
.AddFiltering()
.AddSorting()
.AddMutationConventions(applyToAllMutations: true);
var app = builder.Build();
await SeedData.EnsureSeededAsync(app.Services.GetRequiredService<IDbContextFactory<BookstoreContext>>());
app.MapGraphQL();
app.Run();
AddGraphQLServer() bir request executor builder döner; schema'ya giren her şey bu zincire eklenir. MapGraphQL() varsayılan olarak /graphql'e üç şey bağlar: POST ve GET ile GraphQL istekleri, ?sdl ile schema'nın SDL hâli ve tarayıcıdan açıldığında Nitro IDE. Accept: text/html ile /graphql/'e gittiğimde 200 text/html ve başlığında "Nitro IDE" olan sayfa geldi. Nitro, sorgu yazarken schema'dan otomatik tamamlama yapan, Hot Chocolate'ın kendi tarayıcı arayüzü.
DbContext kaydı için iki parça var. AddPooledDbContextFactory EF Core'un kendi API'si: DbContext instance'larını bir havuzda tutar ve yeniden kullanır. RegisterDbContextFactory ise Hot Chocolate'a "resolver parametresinde BookstoreContext görürsen bu factory'den al" der. EF Core serisinin AddDbContext, scoped lifetime, factory ve pooling yazısında bu üç kaydın farkını ayrıntılı anlatmıştık. GraphQL tarafında factory'nin neden önemli olduğunu ise baştaki soru gösteriyor.
DbContext ve Paralel Çalışan Resolver'lar
Baştaki sorunun kurgusu şuydu: factory yerine klasik scoped kayıt ve Hot Chocolate'a "bütün query resolver'ları request'in scope'unu paylaşsın" demek:
// yanlış: request başına tek DbContext
builder.Services.AddDbContext<BookstoreContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Bookstore")));
builder.Services
.AddGraphQLServer()
.AddCatalogTypes()
// ...
.ModifyOptions(o => o.DefaultQueryDependencyInjectionScope = DependencyInjectionScope.Request);
Aynı sorguyu üç kez art arda gönderdim:
200 application/graphql-response+json; charset=utf-8
{"errors":[{"message":"Unexpected Execution Error","path":["b"]},{"message":"Unexpected Execution Error","path":["c"]}],"data":null}
200 application/graphql-response+json; charset=utf-8
{"errors":[{"message":"Unexpected Execution Error","path":["b"]}],"data":{"a":{"title":"Distributed Systems Vol. 1"},"b":null,"c":[{"title":"Streaming Vol. 1"}]}}
200 application/graphql-response+json; charset=utf-8
{"errors":[{"message":"Unexpected Execution Error","path":["b"]},{"message":"Unexpected Execution Error","path":["c"]}],"data":null}
fail: Microsoft.EntityFrameworkCore.Query[10100]
System.InvalidOperationException: A second operation was started on this context instance before a previous operation completed. This is usually caused by different threads concurrently using the same instance of DbContext. ...
Her seferinde farklı sonuç. Üç resolver aynı DbContext'e aynı anda gitti; hangisinin önce bağlantıyı aldığına göre biri ya da ikisi düştü. İkinci denemede b null ama c geldi, birinci ve üçüncüde c de düştü ve [Book!]! non-null olduğu için null bütün data'ya yayıldı. Bu tür bir hata geliştirme ortamında tek tıklamayla nadiren görülür; yük altında ise rastgele çıkar.
Hot Chocolate 16'nın varsayılanı bu yüzden farklı: query field'larının DI (Dependency Injection; bağımlılıkların class'a dışarıdan verilmesi) scope'u DependencyInjectionScope.Resolver; her resolver kendi service scope'unu, dolayısıyla kendi DbContext'ini alıyor. ModifyOptions satırını sildiğimde aynı sorgu (factory olmadan bile) üç denemede de üç kitabı döndü. Mutation'larda varsayılan request scope: top-level mutation field'ları zaten sırayla çalıştığı için aynı istekteki mutation'lar tek DbContext'i paylaşır.
Resolver başına DbContext'in bedeli, istek başına birden çok DbContext oluşması. Factory ve pooling burada değer kazanıyor: RegisterDbContextFactory ile her resolver havuzdan bir instance alıp işi bitince geri bırakıyor. Kural kısa: GraphQL'de DbContext'i asla resolver'lar arasında paylaşma; her resolver ya da her DataLoader kendi DbContext'ini kullansın.
Projection, Filtering ve Sorting'in Ürettiği SQL
Kitap listesini IQueryable olarak döndürüp üç middleware'i ekledim:
[QueryType]
public static partial class BookQueries
{
[UseProjection]
[UseFiltering]
[UseSorting]
public static IQueryable<Book> GetBooks(BookstoreContext db) =>
db.Books.AsNoTracking().OrderBy(b => b.Id);
public static async Task<Book?> GetBookAsync(int id, BookstoreContext db, CancellationToken cancellationToken) =>
await db.Books.AsNoTracking().FirstOrDefaultAsync(b => b.Id == id, cancellationToken);
}
Attribute sırası önemli ve yukarıdan aşağı okunur: projection en dışta, sonra filtering, sonra sorting. Hot Chocolate 16'nın analyzer'ı yanlış sırayı derleme hatası olarak işaretliyor. Middleware'ler resolver'ın döndürdüğü IQueryable'a Where, OrderBy ve Select ekler; sorgu ancak en sonda, tek seferde veritabanına gider. Schema'da books(where: BookFilterInput, order: [BookSortInput!]) argümanları belirdi. EF Core'un SQL logunu (Microsoft.EntityFrameworkCore.Database.Command kategorisi, Information seviyesi) açıp bir sorgu gönderdim:
{ books(where: { price: { lt: 170 } }, order: [{ price: DESC }]) { title price author { name } } }
{"data":{"books":[{"title":"Distributed Systems Vol. 1","price":155.00,"author":{"name":"Ada Byron"}},{"title":"Protobuf Vol. 1","price":160.00,"author":{"name":"Alan Turing"}},{"title":"Streaming Vol. 1","price":165.00,"author":{"name":"Grace Hopper"}}]}}
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (24ms) [Parameters=[@p='?' (Precision = 10) (Scale = 2) (DbType = Decimal)], CommandType='Text', CommandTimeout='30']
SELECT [b].[Title], [b].[Price], CAST(1 AS bit), [a].[Name]
FROM [Books] AS [b]
INNER JOIN [Authors] AS [a] ON [b].[AuthorId] = [a].[Id]
WHERE [b].[Price] < @p
ORDER BY [b].[Id], [b].[Price] DESC
İşin iyi tarafı: projection çalışıyor. SELECT'te yalnız sorgunun istediği üç kolon var, author için de ayrı sorgu yerine tek bir JOIN. Filtre parametreli bir WHERE oldu. Sorun ise yanıtın sırasında: fiyatları azalan istedim, artan geldi. SQL'in son satırı sebebi gösteriyor: ORDER BY [b].[Id], [b].[Price] DESC. Resolver'daki OrderBy(b => b.Id) birincil sıralama olarak kaldı, client'ın sıralaması ikincil anahtar oldu. Id'ler benzersiz olduğu için ikinci anahtar hiçbir şeyi değiştirmedi. Sorting middleware'i kullanıyorsan resolver'da sıralama yapma.
İkinci sürpriz book(id)'de. Sorgu { book(id: 1) { title author { name } } }, yanıt:
{"data":{"book":{"title":"Distributed Systems Vol. 1","author":null}}}
Hata yok, yazar null. FirstOrDefaultAsync ile yüklenen entity'nin Author navigation'ı Include olmadan boş kalıyor; schema'da author nullable olduğu için GraphQL bunu meşru bir null olarak döndü. Üstelik logdaki SQL, sorgu yalnız title istese bile sekiz kolonun hepsini seçiyordu (CostPrice ve SupplierNote dahil). İki method'u da düzelttim:
[QueryType]
public static partial class BookQueries
{
[UseProjection]
[UseFiltering<BookFilterType>]
[UseSorting]
public static IQueryable<Book> GetBooks(BookstoreContext db) =>
db.Books.AsNoTracking();
[UseFirstOrDefault]
[UseProjection]
public static IQueryable<Book> GetBook(int id, BookstoreContext db) =>
db.Books.AsNoTracking().Where(b => b.Id == id);
}
{"data":{"book":{"title":"Distributed Systems Vol. 1","author":{"name":"Ada Byron"}}}}
SELECT [b0].[Title], CAST(1 AS bit), [a].[Name]
FROM (
SELECT TOP(@p) [b].[AuthorId], [b].[Title]
FROM [Books] AS [b]
WHERE [b].[Id] = @id
) AS [b0]
INNER JOIN [Authors] AS [a] ON [b0].[AuthorId] = [a].[Id]
{ books(where: { price: { lt: 170 } }, order: [{ price: DESC }]) { title price } }
SELECT [b].[Title], [b].[Price]
FROM [Books] AS [b]
WHERE [b].[Price] < @p
ORDER BY [b].[Price] DESC
[UseFirstOrDefault] tek kayıt dönen bir field'ı da IQueryable üzerinden çalıştırıyor, böylece projection orada da devreye giriyor. EF Core bu sorgu için "OrderBy olmadan Take" uyarısı logladı; filtre birincil anahtar üzerinde olduğu için zararsız. Sıralama da artık doğru.
Filtering'in bir bedeli var. { books(where: { title: { contains: "Cach" }, author: { name: { startsWith: "D" } } }) { title } } gibi bir sorgu WHERE [b].[Title] LIKE @p_contains ESCAPE N'\' AND [a].[Name] LIKE @p1_startswith ... üretti. Baştaki joker karakterli LIKE index kullanamaz; her istek tablonun tamamını tarar. Varsayılan filter tipi entity'nin her property'sini ve her operatörü açar, yani client'a veritabanında istediği taramayı yaptırma yetkisi verir. Hot Chocolate 16 bir filter argümanında en fazla 64 operasyona izin veriyor (aşınca HC0117), ama bu bir üst sınır, tasarım değil. Public bir API'de filtrelenebilir alanları açıkça seç:
public sealed class BookFilterType : FilterInputType<Book>
{
protected override void Configure(IFilterInputTypeDescriptor<Book> descriptor)
{
// yalnız index'li ya da ucuz kolonlar filtrelenebilir
descriptor.BindFieldsExplicitly();
descriptor.Field(b => b.Isbn);
descriptor.Field(b => b.Price);
descriptor.Field(b => b.AuthorId);
}
}
input BookFilterInput {
and: [BookFilterInput!]
or: [BookFilterInput!]
isbn: StringOperationFilterInput
price: DecimalOperationFilterInput
authorId: IntOperationFilterInput
}
{ books(where: { title: { contains: "Cach" } }) { title } }
400 application/graphql-response+json; charset=utf-8
{"errors":[{"message":"The specified input object field `title` does not exist.", ...}]}
Başlıkta arama gerçekten gerekiyorsa onu bir filter operatörü olarak değil, full-text index'e giden ayrı bir search(term:) field'ı olarak tasarlamak daha sağlıklı. Liste uzunluğunun sınırı ise ayrı bir konu: books şu an 50 kitabın hepsini dönebiliyor. Bir sonraki yazıda bunu cursor pagination ile kapatıyoruz.
Schema'ya Ne Açıldığını Kontrol Etmek
İlk SDL'e geri dönelim. Implementation-first, entity'nin bütün public property'lerini schema'ya taşıdı:
type Book {
id: Int!
title: String!
isbn: String!
price: Decimal!
costPrice: Decimal!
supplierNote: String
stock: Int!
authorId: Int!
author: Author
}
Alış fiyatı ve tedarikçi notu herkese açık bir API'de. Daha zor fark edileni filter input'u: costPrice orada da vardı. Field'ı sorgudan gizlesen bile { books(where: { costPrice: { gt: 200 } }) { title price } } sorgusu 14 kitap döndü; filtreyle ikili arama yaparak her kitabın alış fiyatı bulunabilir. API Güvenliği serisinin API3 BOPLA (OWASP API Top 10 listesinin üçüncü maddesi, Broken Object Property Level Authorization) yazısında REST (Representational State Transfer) için anlattığımız problem, GraphQL'de filter ve sort input'larıyla bir kanal daha kazanıyor.
Çözüm, tipin hangi property'leri yayınlayacağına açıkça karar vermek:
[ObjectType<Book>]
public static partial class BookNode
{
static partial void Configure(IObjectTypeDescriptor<Book> descriptor)
{
// maliyet ve tedarikçi notu iç bilgi; schema'ya hiç girmesin
descriptor.Ignore(b => b.CostPrice);
descriptor.Ignore(b => b.SupplierNote);
}
}
Bu değişiklikten sonra iki alan hem Book tipinden hem de sorting input'undan çıktı; filter input'unu zaten BookFilterType ile daralttık. costPrice ile filtrelemeye çalışan sorgu artık doğrulamada The specified input object field `costPrice` does not exist. hatasıyla 400 alıyor. Daha büyük bir modelde Ignore listesi yerine ayrı bir DTO (katmanlar arasında veri taşıyan, davranışsız class) ve EF Core Select projection'ı ile çalışmak daha güvenli bir varsayılan: yeni eklenen bir entity property'si kendiliğinden schema'ya sızmaz.
Mutation Convention'ları ve Typed Error'lar
Sipariş verme mutation'ı. AddMutationConventions(applyToAllMutations: true) açıkken düz parametreli bir method yazıyorsun; input ve payload tiplerini Hot Chocolate üretiyor. Domain hataları exception olarak fırlatılıyor, [Error<T>] attribute'ları onları schema'da tipli hatalara çeviriyor:
public sealed record OrderLineInput(int BookId, int Quantity);
public sealed class OutOfStockException(int bookId, int requested, int available)
: Exception($"Book {bookId} has only {available} item(s) in stock.")
{
public int BookId { get; } = bookId;
public int Requested { get; } = requested;
public int Available { get; } = available;
}
[MutationType]
public static partial class OrderMutations
{
[Error<OutOfStockException>]
[Error<BookNotFoundException>]
[Error<OrderValidationException>]
public static async Task<Order> PlaceOrderAsync(
string customerEmail,
IReadOnlyList<OrderLineInput> lines,
BookstoreContext db,
CancellationToken cancellationToken)
{
if (customerEmail.Length > 320 || !customerEmail.Contains('@'))
{
throw new OrderValidationException("customerEmail", "Customer email is not valid.");
}
if (lines.Count == 0 || lines.Any(l => l.Quantity is < 1 or > 20))
{
throw new OrderValidationException("lines", "Each line needs a quantity between 1 and 20.");
}
int[] bookIds = [.. lines.Select(l => l.BookId).Distinct()];
Dictionary<int, Book> books = await db.Books
.Where(b => bookIds.Contains(b.Id))
.ToDictionaryAsync(b => b.Id, cancellationToken);
Order order = new() { CustomerEmail = customerEmail, CreatedAt = DateTimeOffset.UtcNow };
foreach (OrderLineInput line in lines)
{
if (!books.TryGetValue(line.BookId, out Book? book))
{
throw new BookNotFoundException(line.BookId);
}
if (book.Stock < line.Quantity)
{
throw new OutOfStockException(book.Id, line.Quantity, book.Stock);
}
book.Stock -= line.Quantity;
order.Items.Add(new OrderItem { BookId = book.Id, Quantity = line.Quantity, UnitPrice = book.Price });
}
order.Total = order.Items.Sum(i => i.UnitPrice * i.Quantity);
db.Orders.Add(order);
await db.SaveChangesAsync(cancellationToken);
return order;
}
}
BookNotFoundException ve OrderValidationException aynı düzende (BookId; Field). Üretilen schema:
type Mutation {
placeOrder(input: PlaceOrderInput!): PlaceOrderPayload!
}
input PlaceOrderInput {
customerEmail: String!
lines: [OrderLineInput!]!
}
type PlaceOrderPayload {
order: Order
errors: [PlaceOrderError!]
}
union PlaceOrderError =
| OutOfStockError
| BookNotFoundError
| OrderValidationError
interface Error {
message: String!
}
type OutOfStockError implements Error {
message: String!
bookId: Int!
requested: Int!
available: Int!
}
Hot Chocolate exception adındaki Exception son ekini Error ile değiştirdi, exception'ın public property'lerini field yaptı ve hepsini Error interface'ini implement eden tipler olarak bir union'da topladı. Payload'daki order nullable: hata varsa sipariş yok. Client tarafı:
mutation PlaceOrder($input: PlaceOrderInput!) {
placeOrder(input: $input) {
order { id total items { book { title } quantity unitPrice } }
errors {
__typename
... on Error { message }
... on OutOfStockError { bookId requested available }
}
}
}
{"input":{"customerEmail":"deniz@example.com","lines":[{"bookId":1,"quantity":2},{"bookId":6,"quantity":1}]}}
{"data":{"placeOrder":{"order":{"id":1,"total":490.00,"items":[{"book":{"title":"Distributed Systems Vol. 1"},"quantity":2,"unitPrice":155.00},{"book":{"title":"Caching Vol. 1"},"quantity":1,"unitPrice":180.00}]},"errors":null}}}
{"input":{"customerEmail":"deniz@example.com","lines":[{"bookId":12,"quantity":3}]}}
{"data":{"placeOrder":{"order":null,"errors":[{"__typename":"OutOfStockError","message":"Book 12 has only 1 item(s) in stock.","bookId":12,"requested":3,"available":1}]}}}
{"input":{"customerEmail":"deniz@example.com","lines":[{"bookId":999,"quantity":1}]}}
{"data":{"placeOrder":{"order":null,"errors":[{"__typename":"BookNotFoundError","message":"Book 999 was not found."}]}}}
Başarılı siparişte EF Core iki stok UPDATE'i, bir INSERT INTO [Orders] ve kalemler için bir MERGE [OrderItem] çalıştırdı; hepsi SaveChangesAsync'in transaction'ı içinde. Stok yetmediğinde ise yanıt bir runtime hatası değil, veri: errors top-level'da değil payload'ın içinde, __typename ile ayırt edilebilir ve available gibi client'ın "en fazla 1 adet alabilirsin" diyebileceği alanları taşıyor. Angular tarafında bu union'ı bir switch ile ele alabilirsin.
Doğrulamanın iki katmanı var. Tip doğrulamasını GraphQL yapıyor: quantity için string gönderen istek resolver'a hiç ulaşmadı, 400 ve "inputPath":["input","lines",0,"quantity"] ile döndü. İş kuralı doğrulaması (adet 1 ile 20 arasında, e-posta geçerli) senin işin ve typed error olarak dönmeli:
{"data":{"placeOrder":{"order":null,"errors":[{"__typename":"OrderValidationError","message":"Each line needs a quantity between 1 and 20.","field":"lines"}]}}}
Beklenmeyen Hatalar: Null Propagation ve onError
[Error<T>] ile listelemediğin bir exception typed error olmaz. Mutation'ın ilk hâlinde e-posta uzunluğu kontrolü yoktu; 342 karakterlik bir e-posta gönderdim. SQL Server String or binary data would be truncated in table 'grpcseries_07.dbo.Orders', column 'CustomerEmail' dedi ve yanıt şu oldu:
{"errors":[{"message":"Unexpected Execution Error","path":["placeOrder"]}],"data":null}
Exception ayrıntısı client'a sızmadı, bu iyi. Ama data null, çünkü placeOrder: PlaceOrderPayload! non-null; serinin önceki yazısında gördüğümüz null propagation burada da işledi. Aynı istekte başka root field'lar olsaydı onlar da kaybolurdu.
Hot Chocolate 16, GraphQL çalışma grubundaki onError önerisini uyguluyor: client istek gövdesine "onError": "NULL" ekleyerek "hatalı field'ı null yap, yukarı taşıma" diyebiliyor. Varsayılan olarak kapalı; ilk denememde değer yok sayıldı ve aynı yanıt geldi. Server'da açtım:
builder.Services
.AddGraphQLServer()
// ...
.ModifyRequestOptions(o => o.AllowErrorHandlingModeOverride = true);
{"query":"...","variables":{...},"onError":"NULL"}
{"errors":[{"message":"Unexpected Execution Error","path":["placeOrder"]}],"data":{"placeOrder":null}}
{"query":"...","variables":{...},"onError":"PROPAGATE"}
{"errors":[{"message":"Unexpected Execution Error","path":["placeOrder"]}],"data":null}
{"query":"{ book(id: 1) { title } }","onError":"HALT"}
400 {"errors":[{"message":"Unknown 'onError' value 'HALT'. Allowed values are 'PROPAGATE' or 'NULL'.","extensions":{"code":"HC0012"}}]}
NULL modunda hata aynı yerde raporlandı ama budama durdu: data artık {"placeOrder":null}. Bu, normalized cache tutan client'lar için önemli; tek bir field'ın hatası yüzünden ekrandaki bütün veriyi atmak zorunda kalmıyorlar. Öneri üçüncü bir HALT modu da tartışıyor, Hot Chocolate 16.7 ise yalnız PROPAGATE ve NULL'u kabul ediyor. Herkes için varsayılanı değiştirmek istersen DefaultErrorHandlingMode = ErrorHandlingMode.Null seçeneği var; ama bu, schema'daki !'lerin anlamını client'ın gözünde değiştirir ve "non-null alan asla null gelmez" varsayımıyla yazılmış client'ları şaşırtır.
Doğru düzeltme yine de typed error: e-posta kontrolünü ekledikten sonra aynı istek OrderValidationError ile, data'yı kaybetmeden döndü. onError beklenmeyen hatalar için bir emniyet, domain hatalarının yerine geçmez.
Üretimde Dikkat
Stok düşümü yarışa açık. PlaceOrderAsync stoğu okuyup bellekte azaltıyor; aynı anda gelen iki sipariş aynı son kitabı satabilir. EF Core serisinin concurrency yazısındaki concurrency token ya da ExecuteUpdateAsync ile WHERE Stock >= @quantity koşullu güncelleme burada şart. Hot Chocolate 16 eski transaction scope handler'larını da kaldırdı; birden çok root mutation field'ını tek transaction'a bağlamak yerine, birlikte değişmesi gereken şeyleri tek bir kaba taneli mutation'da topla.
SQL logunu geliştirme sırasında hep açık tut, production'da kapat. Projection'ın gerçekten çalıştığını, filtrenin hangi index'e düştüğünü görmenin başka yolu yok. Production'da aynı bilgiyi OpenTelemetry trace'inden almayı bir sonraki yazıda kuruyoruz.
Schema'yı CI'da dışa aktar. Implementation-first'te schema koddan doğuyor; bir entity'ye property eklemek fark ettirmeden public API'yi değiştirebilir. /graphql?sdl çıktısını repo'da tutup her PR'da diff'ine bakmak bu sızıntıları ve kırıcı değişiklikleri yakalar.
Filter ve sort argümanlarını bir maliyet olarak düşün. Açtığın her alan, client'ın veritabanında çalıştırabileceği bir sorgu biçimi. Index'i olmayan bir kolonda sıralama da tablo taraması demek; varsayılan sort tipi de bütün alanları açıyor ve onu da filter gibi daraltabilirsin.
Sık Düşülen Tuzaklar
DbContext'i resolver'lar arasında paylaşmak. Request scope'a çekilmiş bir DbContext ya da singleton bir service'in içine gömülmüş bir DbContext, paralel query field'larında rastgele hatalar üretir.
Sorting middleware'iyle birlikte resolver'da OrderBy yazmak. Client'ın sıralaması ikinci anahtara düşer ve sessizce etkisiz kalır.
IQueryable yerine materialize edilmiş entity döndürüp navigation beklemek. FirstOrDefaultAsync ile dönen kitabın yazarı Include yoksa null; GraphQL bunu hata saymaz.
Entity'yi olduğu gibi schema'ya açmak. Field'ı gizlemek yetmez, filter ve sort input'larını da kontrol et.
Domain hatalarını exception olarak bırakmak. [Error<T>] ile işaretlenmeyen her exception "Unexpected Execution Error" olur ve non-null payload yüzünden bütün yanıtı götürür.
Küçük Bir Deneme
★☆☆ BookSortInput'u da BookFilterType gibi daralt: yalnız title ve price ile sıralamaya izin ver. Schema'nın değiştiğini ?sdl ile doğrula.
★★☆ PlaceOrderAsync'teki stok düşümünü ExecuteUpdateAsync ile koşullu bir UPDATE'e çevir. İki terminalden aynı anda son kalan kitabı sipariş etmeyi dene ve yalnız birinin başarılı olduğunu SQL loguyla göster.
★★★ Book entity'sini schema'dan tamamen ayır: BookDto record'u tanımla, GetBooks'u db.Books.Select(b => new BookDto(...)) döndürecek şekilde değiştir ve projection, filtering ve sorting'in üretilen SQL'de hâlâ birlikte çalıştığını, yani WHERE ve ORDER BY'ın bellekte değil veritabanında çalıştığını SQL logundan kontrol et.
Schema, EF Core ve mutation'lar yerinde. Şimdilik her şey tek bir JOIN'e sığıyor, ama yazarı başka bir veri kaynağından gelen bir field eklediğimiz anda N+1 geri döner. Bir sonraki yazıda bunu SQL loguyla yakalayıp DataLoader ve batch resolver'larla 51 sorgudan 2'ye indiriyoruz; listeyi de cursor pagination ile sınırlıyoruz.