Mert Özen Her Satırda Daha İleri
Backend gRPC ve GraphQL — #10

Angular'dan GraphQL: Apollo Angular, Code Generation, Subscription'lar ve @defer

Mert Özen 14 Kas 2026 13 dk 4 görüntülenme
Angular'dan GraphQL: Apollo Angular, Code Generation, Subscription'lar ve @defer

Angular'dan GraphQL'e bağlanmanın sağlam yolu Apollo Angular ile normalized cache, GraphQL Code Generator ile typed operation'lar ve aynı codegen'in ürettiği persisted operation manifest'idir. Angular 22 ve Apollo Client 4 ile signal'ler ve dataState'i, mutation sonrası cache tutarlılığını, WebSocket subscription'larını, @defer için gereken fetch tabanlı link'i ve persisted dokümanlarda unutulan __typename'in boş ekranını gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Hot Chocolate 16.7, Angular 22.2, Apollo Angular 14.2, Apollo Client 4.3, graphql-ws 6.4 ve GraphQL Code Generator 8 (client preset 7.0) ile derlenip denenmiştir.

Önce Tahmin Et

Bir önceki yazıda production'da yalnız persisted operation kabul eden bir server kurduk. Bu yazıda Angular client'ının operation'larını GraphQL Code Generator ile çıkarıp server'ın deposuna yazıyorsun. Kitap detay sayfası açılıyor; Network sekmesinde istek 200, yanıtın içinde iki yorum da var:

{"incremental":[{"id":"2","data":{"reviews":[{"id":10,"rating":5,"text":"Great chapter on retries."},{"id":7,"rating":5,"text":"A bit long."}]}}],"completed":[{"id":"2"}],"hasNext":false}

Ama ekranda yorum listesi şöyle: /5 · | /5 ·. İki satır var, içleri boş. Üstelik "Advance" butonuyla sipariş durumunu değiştirdiğinde mutation başarılı dönüyor ama ekrandaki durum değişmiyor. Server doğru veriyi gönderiyor; sorun nerede?

Cevap yanıtta olmayan tek bir field'da. Persisted operation bölümünde göreceğiz; önce client'ı kuralım.

Kurulum: Angular 22, Apollo Angular 14 ve Apollo Client 4

Yazım gününde Apollo Angular'ın son sürümü 14.2.0 ve peer dependency'leri Angular 20, 21 ve 22'yi, Apollo Client'ın da 4.x serisini kapsıyor. Paketler:

npm install @apollo/client@4.3.8 apollo-angular@14.2.0 graphql@17.1.0 graphql-ws@6.4.2
npm install -D @graphql-codegen/cli@8.0.0 @graphql-codegen/client-preset@7.0.0

Geliştirme sırasında Angular dev server'ı /graphql isteklerini Catalog service'ine yönlendiriyor; ws: true WebSocket bağlantılarını da taşıyor:

{
  "/graphql": {
    "target": "http://localhost:5200",
    "ws": true
  }
}

Server tarafında bu yazı için üç şey ekledim: yorumlar (Review entity'si ve bir group DataLoader), sipariş durumu (OrderStatus enum'u, durumu ilerleten bir mutation ve bir subscription) ve @defer desteği. Bunları ilgili bölümlerde gösteriyorum.

GraphQL Code Generator ile Typed Operation'lar

Elle yazılmış TypeScript tipleri schema ile bir gün mutlaka ayrışır. Code generator, schema'yı ve kodundaki operation'ları okuyup her operation için sonucun ve variable'ların tipini üretiyor. Schema'yı server'dan /graphql?sdl ile schema.graphql dosyasına aldım; client repo'sunda dursun ki build server'a bağlı olmasın:

import type { CodegenConfig } from '@graphql-codegen/cli';
import { addTypenameSelectionDocumentTransform } from '@graphql-codegen/client-preset';

const config: CodegenConfig = {
  schema: 'schema.graphql',
  documents: ['src/app/**/*.ts'],
  ignoreNoDocuments: false,
  generates: {
    'src/gql/': {
      preset: 'client',
      // Apollo'nun cache'i __typename'e ihtiyaç duyar; persisted dokümanda da olmalı
      documentTransforms: [addTypenameSelectionDocumentTransform],
      presetConfig: {
        fragmentMasking: false,
        persistedDocuments: { hashAlgorithm: 'sha256' },
      },
      config: {
        useTypeImports: true,
        enumsAsTypes: true,
        scalars: { Decimal: 'number', DateTime: 'string' },
      },
    },
  },
};

export default config;

Operation'lar component'in yanında, üretilen graphql() fonksiyonu ile yazılıyor. Her component kendi ihtiyacını bir fragment olarak tanımlıyor; serinin GraphQL temelleri yazısında söz ettiğimiz düzen bu:

export const ReviewItem = graphql(`
  fragment ReviewItem on Review {
    id
    rating
    text
  }
`);

@Component({
  selector: 'app-review-list',
  template: `
    <ul>
      @for (review of reviews(); track review.id) {
        <li>{{ review.rating }}/5 · {{ review.text }}</li>
      }
    </ul>
  `,
})
export class ReviewList {
  readonly reviews = input.required<ReviewItemFragment[]>();
}
const BookDetailQuery = graphql(`
  query BookDetail($id: Int!) {
    book(id: $id) {
      id
      title
      price
      author {
        name
      }
      ... @defer(label: "reviews") {
        reviews(first: 2) {
          ...ReviewItem
        }
      }
    }
  }
`);

npx graphql-codegen çalıştırınca src/gql/ altında graphql.ts, gql.ts ve persisted-documents.json oluştu. BookDetailQuery için üretilen tip @defer'ı da anlıyor: yorumlar ya var ya da hiç yok.

export type BookDetailQuery = { book: { id: number, title: string, price: number, author: { name: string } | null } & ({ reviews: Array<{ id: number, rating: number, text: string }> } | { reviews?: never }) | null };

Schema'da book.title'ı yeniden adlandırdığında Angular build'i derleme hatasıyla durur; çalışma anında boş bir başlık görmezsin. fragmentMasking: false bilinçli bir sadelik tercihi; client preset'in fragment masking özelliği, bir component'in yalnız kendi fragment'ındaki field'ları görmesini zorunlu kılıyor ve büyük ekiplerde değerli, ama bu yazının konusunu dağıtırdı.

Signals ile Bağlamak ve dataState

Apollo Angular sonuçları RxJS (Reactive Extensions for JavaScript) observable'ı olarak veriyor; Angular tarafında toSignal ile signal'e çeviriyorum:

export class BookDetail {
  private readonly apollo = inject(Apollo);
  readonly bookId = input.required<number>();

  readonly result = toSignal(
    toObservable(this.bookId).pipe(
      switchMap((id) => this.apollo.watchQuery({ query: BookDetailQuery, variables: { id } }).valueChanges),
    ),
  );

  // streaming: ilk parça geldi, @defer'lı kısım yolda; complete: hepsi geldi
  readonly reviews = computed(() => {
    const result = this.result();
    return result?.dataState === 'complete' ? result.data.book?.reviews : undefined;
  });
}
@let book = result()?.data?.book;
@if (result()?.error; as error) {
  <p class="error">{{ error.message }}</p>
}
@if (book) {
  <h2>{{ book.title }}</h2>
  <p>{{ book.author?.name }} · {{ book.price }} TL</p>
  @if (reviews(); as reviews) {
    <app-review-list [reviews]="reviews" />
  } @else {
    <p>Loading reviews…</p>
  }
}

reviews computed'ı neden var? İlk denememde template'te doğrudan book.reviews'u kullandım ve derleme durdu: Type '... | DeepPartialObject<{ id: number; rating: number; text: string; }>[]' is not assignable to type 'ReviewItemFragment[]'. Apollo Client 4 her sonuca bir dataState ekliyor: empty, partial, streaming ya da complete. Tip sistemi de buna göre daralıyor; yalnız complete durumunda data eksiksiz tip. @defer'lı bir sorgu ilk parçadan sonra streaming durumunda, son parçadan sonra complete oluyor.

Hata tarafında Apollo Client'ın varsayılan errorPolicy'si none: yanıtta GraphQL hatası varsa data atılır ve error dolar. Serinin önceki yazılarında gördüğümüz partial data'yı göstermek istediğin ekranlarda errorPolicy: 'all' ile hem data'yı hem error'u alırsın; stok rozeti eksik diye bütün kitap sayfasını hata ekranına çevirmezsin.

Normalized Cache ve Mutation Sonrası Tutarlılık

Apollo'nun InMemoryCache'i yanıtları olduğu gibi saklamaz; her object'i __typename ve id'sinden oluşan bir anahtarla (Order:1 gibi) tek bir yere yazar. Aynı sipariş iki farklı sorguda görünse bile cache'te bir kopyası vardır. Sipariş durumu component'i:

const OrderQuery = graphql(`
  query Order($id: Int!) {
    order(id: $id) {
      id
      status
      total
    }
  }
`);

const AdvanceOrderStatus = graphql(`
  mutation AdvanceOrderStatus($orderId: Int!) {
    advanceOrderStatus(input: { orderId: $orderId }) {
      order {
        id
        status
      }
    }
  }
`);

advance(): void {
  this.apollo.mutate({ mutation: AdvanceOrderStatus, variables: { orderId: this.orderId() } }).subscribe();
}

Sayfayı Playwright ile açıp butona tıkladım. Ağ trafiği ve ekran:

[   76 ms] POST Order
[ 1595 ms] ORDER Order #1: PLACED | Advance
[ 1621 ms] POST AdvanceOrderStatus
[ 2426 ms] ORDER Order #1: PACKED | Advance | PACKED

Mutation'dan sonra Order sorgusu yeniden gönderilmedi ama ekrandaki durum değişti. Mutation'ın yanıtı id ve status taşıdığı için Apollo Order:1 kaydını güncelledi, o kaydı izleyen bütün sorgular da yeniden render oldu. Kural: mutation'ın payload'ında değiştirdiğin object'in id'sini ve değişen field'larını iste. Listeye yeni bir eleman ekleyen mutation'larda ise Apollo hangi listeye ekleneceğini bilemez; orada update callback'i ile cache.modify ya da refetchQueries gerekir.

watchQuery'nin varsayılan fetchPolicy'si cache-first: veri cache'te tam varsa ağa gitmez. Stok gibi sık değişen veriler için cache-and-network önce cache'i gösterip arkadan tazeler; network-only ise cache'i okumaz ama yine yazar.

Subscription'lar: graphql-transport-ws, SSE ve SignalR

Server tarafı, serinin GraphQL temelleri yazısındaki topic düzeninin aynısı:

public sealed record OrderStatusChange(int OrderId, OrderStatus Status, DateTimeOffset ChangedAt);

[MutationType]
public static partial class OrderStatusMutations
{
    public static async Task<Order?> AdvanceOrderStatusAsync(
        int orderId,
        BookstoreContext db,
        ITopicEventSender sender,
        CancellationToken cancellationToken)
    {
        Order? order = await db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, cancellationToken);
        if (order is null || order.Status == OrderStatus.Delivered)
        {
            return order;
        }

        order.Status += 1;
        await db.SaveChangesAsync(cancellationToken);
        await sender.SendAsync(
            $"order-status/{orderId}",
            new OrderStatusChange(orderId, order.Status, DateTimeOffset.UtcNow),
            cancellationToken);
        return order;
    }
}

[SubscriptionType]
public static partial class OrderSubscriptions
{
    [Subscribe]
    [Topic("order-status/{orderId}")]
    public static OrderStatusChange OnOrderStatusChanged(int orderId, [EventMessage] OrderStatusChange change) =>
        change;
}

Program.cs'te .AddInMemorySubscriptions() ve app.UseWebSockets() gerekiyor. In-memory provider yalnız tek instance'ta çalışır: mutation A sunucusuna, subscription B sunucusuna düşerse event kaybolur. Birden çok instance'ta HotChocolate.Subscriptions.Redis paketi ve AddRedisSubscriptions ile event'leri Redis üzerinden dağıtmak gerekiyor.

İlk denemede uygulama açılmadı: `SHİPPED` is not a valid GraphQL name. Bir önceki yazıda HotChocolate.Caching'de karşılaştığımız Türkçe büyük İ sorunu bu kez kendi OrderStatus.Shipped değerimizde çıktı; aynı çözümle, uygulamanın kültürünü CultureInfo.InvariantCulture'a sabitleyerek geçtim.

Client'ta subscription'lar WebSocket'ten, geri kalan her şey HTTP'den gidiyor. Apollo'nun GraphQLWsLink'i graphql-ws kütüphanesini, yani graphql-transport-ws alt protokolünü kullanıyor:

// subscription'lar WebSocket'ten, geri kalanı HTTP'den
const link = ApolloLink.split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return definition.kind === 'OperationDefinition' && definition.operation === 'subscription';
  },
  persisted.concat(ws),
  persisted.concat(http),
);
[  178 ms] ws> {"type":"connection_init"}
[  206 ms] ws< {"type":"connection_ack"}
[  206 ms] ws> {"id":"c4c3eb63-...","type":"subscribe","payload":{"variables":{"orderId":1},"operationName":"OrderStatus", ...}}
[ 1746 ms] POST AdvanceOrderStatus
[ 1772 ms] ws< {"id":"c4c3eb63-...","type":"next","payload":{"data":{"onOrderStatusChanged":{"orderId":1,"status":"DELIVERED", ...}}}}

Hot Chocolate aynı subscription'ı SSE (sunucudan istemciye tek yönlü event akışı) ile de sunuyor; serinin GraphQL temelleri yazısında curl ile denemiştik. Tarayıcıda graphql-sse kütüphanesiyle kullanılabilir ve kurumsal proxy'lerin WebSocket'i engellediği ortamlarda işe yarar. Ne zaman GraphQL subscription yerine SignalR? Real-time Uygulamalar serisinin ilk yazısındaki harita burada da geçerli: client zaten GraphQL konuşuyorsa ve olay schema'daki bir tipe karşılık geliyorsa subscription doğal. Chat, ortak düzenleme, oyun gibi yüksek frekanslı ve çift yönlü trafikte ya da GraphQL kullanmayan client'lar varsa SignalR daha doğru.

@defer: Incremental Delivery v0.2

Yorumları yavaş bir dış servisten geliyormuş gibi 800 ms geciktirdim. @defer olmadan bütün sayfa en yavaş field'ı bekler. Hot Chocolate'ta @defer varsayılan olarak kapalı; ilk denemede The specified directive `defer` is not supported by the current schema. aldım ve .ModifyOptions(o => o.EnableDefer = true) ile açtım. curl ile:

[   198 ms] {"data":{"book":{"title":"Distributed Systems Vol. 1","author":{"name":"Ada Byron"}}},"pending":[{"id":"2","path":["book"],"label":"reviews"}],"hasNext":true}
[  1004 ms] {"incremental":[{"id":"2","data":{"reviews":[{"rating":5,"text":"Great chapter on retries."},{"rating":5,"text":"A bit long."}]}}],"completed":[{"id":"2"}],"hasNext":false}

Yanıt multipart/mixed, iki parça. İlk parça 198 ms'de kitabı ve yazarı getiriyor, pending ile "id'si 2 olan bir parça yolda" diyor; ikincisi 800 ms sonra yorumları incremental ile ekliyor ve completed ile kapatıyor. Bu, Hot Chocolate 16'nın varsayılanı olan incremental delivery v0.2 biçimi; v0.1'de parçalar path ile eşleşiyordu. @defer ve @stream hâlâ GraphQL spesifikasyonunda değil; September 2025 sürümüne girmediler ve wire formatı çalışma grubunda değişmeye devam ediyor. Bu yüzden client ile server'ın aynı formatı konuşması gerekiyor.

Apollo Client 4'te bu iş incrementalHandler'ın. Defer20220824Handler eski formatı, GraphQL17Alpha9Handler ise pending/completed'lı yeni formatı okuyor; ikincisini seçince Apollo isteği Accept: multipart/mixed;incrementalSpec=v0.2,application/graphql-response+json,application/json;q=0.9 ile gönderdi.

Yine de ilk denemede ekranda yalnız Http failure during parsing for http://localhost:4210/graphql yazdı. Apollo Angular'ın kendi HttpLink'i istekleri Angular'ın HttpClient'ı ile gönderiyor ve HttpClient multipart/mixed yanıtını parse edemiyor. Apollo Client'ın fetch tabanlı HttpLink'ine geçince çalıştı:

[  389 ms] TEXT Distributed Systems Vol. 1 |  | Ada Byron · 155 TL |  | Loading reviews…
[ 1594 ms] TEXT Distributed Systems Vol. 1 |  | Ada Byron · 155 TL |  | 5/5 · Great chapter on retries. | 5/5 · A bit long.

Bunun bir bedeli var: fetch tabanlı link Angular'ın HTTP interceptor'larından geçmiyor. Token eklemeyi ya da hata loglamayı interceptor'da yapıyorsan aynı işi bir ApolloLink olarak yazman gerekir. @defer kullanmayan bir uygulamada Apollo Angular'ın HttpLink'i ile kalmak daha az uğraştırır.

Persisted Operation Manifest'i

Bir önceki yazıda server'ı production'da yalnız persisted operation kabul edecek şekilde ayarlamıştık. Codegen'in persistedDocuments ayarı her operation'ın SHA-256 (Secure Hash Algorithm 256) hash'ini hesaplayıp persisted-documents.json'a yazıyor ve üretilen her dokümana __meta__.hash ekliyor. Server tarafında hash algoritmasını eşledim: .AddSha256DocumentHashProvider(HashFormat.Hex). Deploy hattında manifest'i server'ın deposuna yazan küçük bir script:

import json, sys, pathlib
# kullanım: write-persisted.py persisted-documents.json hedef-klasör
docs = json.load(open(sys.argv[1]))
out = pathlib.Path(sys.argv[2]); out.mkdir(exist_ok=True)
for key, text in docs.items():
    (out / f"{key.split(':', 1)[1]}.graphql").write_text(text, encoding="utf-8")
print(len(docs), "documents")

Client'ta Apollo'nun PersistedQueryLink'i sorgu metni yerine hash'i gönderiyor. Hash'i tarayıcıda hesaplamıyoruz; codegen'in build anında ürettiği değeri kullanıyoruz:

// sorgu metni yerine codegen'in build anında hesapladığı hash gider
const persisted = new PersistedQueryLink({
  generateHash: (document) => (document as { __meta__?: { hash: string } }).__meta__!.hash.replace('sha256:', ''),
});
const ws = new GraphQLWsLink(
  createClient({
    url: `ws://${location.host}/graphql`,
    // WebSocket'ten de yalnız hash gitsin; sorgu metni server'da zaten var
    jsonMessageReplacer: (key, value) =>
      key === 'payload' && value?.extensions?.persistedQuery ? { ...value, query: undefined } : value,
  }),
);

Hot Chocolate bu yazının denemelerinde üç biçimi de kabul etti: {"id": "<hash>"}, {"documentId": "<hash>"} ve Apollo'nun gönderdiği {"extensions": {"persistedQuery": {"version": 1, "sha256Hash": "<hash>"}}}. documentId'yi sha256: önekiyle gönderdiğimde ise "The operation ID has an invalid format." (HC0012) aldım; önek atılmalı. HTTP tarafı ilk denemede çalıştı. WebSocket tarafı çalışmadı: GraphQLWsLink, persisted link'ten geçse bile payload'a sorgu metnini de koyuyor ve server Only persisted operations are allowed. diyerek subscription'ı reddetti. graphql-ws'in jsonMessageReplacer seçeneğiyle payload'dan query'yi çıkarınca subscription da hash ile açıldı.

Baştaki sorunun cevabı da burada. Persisted dokümanların ilk hâlinde __typename yoktu. Apollo Client, kendi gönderdiği sorgulara cache'in ihtiyacı olan __typename'i otomatik ekler; ama artık sorgu metnini Apollo değil server'daki dosya belirliyordu ve o dosyada __typename yoktu. Yanıtta __typename olmayınca Apollo ...ReviewItem fragment'ının hangi tipe ait olduğunu bilemedi ve yorumların field'larını boş bıraktı; mutation'ın döndürdüğü Order da normalize edilemediği için cache'teki Order:1 güncellenmedi. Codegen config'ine addTypenameSelectionDocumentTransform'u ekleyip manifest'i yeniden üretince her şey düzeldi:

"sha256:938671321c...": "fragment ReviewItem on Review { __typename id rating text } query BookDetail($id: Int!) { __typename book(id: $id) { __typename author { __typename name } id price title ... @defer(label: \"reviews\") { __typename reviews(first: 2) { __typename ...ReviewItem } } } }"

[ 1618 ms] TEXT Distributed Systems Vol. 1 |  | Ada Byron · 155 TL |  | 5/5 · Great chapter on retries. | 5/5 · A bit long.
[ 1688 ms] ws< {"id":"7ad470c4-...","type":"next","payload":{"data":{"onOrderStatusChanged":{"__typename":"OrderStatusChange", ...,"status":"PACKED"}}}}
[ 2470 ms] ORDER Order #1: PACKED | Advance | PACKED

Bu son çıktı Production ortamında, yalnız persisted operation kabul eden server'a karşı alındı: sorgu, mutation, @defer ve subscription aynı manifest'le çalışıyor.

Üretimde Dikkat

Manifest'i server'a client'tan önce deploy et. Yeni client sürümü, server'ın henüz tanımadığı bir hash gönderirse istek HC0020 ile reddedilir. Manifest'i birikimli tut: eski client sürümleri tarayıcılarda bir süre daha yaşar ve onların hash'leri de geçerli kalmalı.

Production'da WebSocket adresi wss:// olmalı ve subscription'lar da authorization'dan geçmeli. Bağlantı açılırken token'ı graphql-ws'in connectionParams'ı ile gönderip server'da doğrulamak gerekir; tarayıcı WebSocket'e özel header ekleyemez.

Schema dosyasını client repo'sunda tut ve CI'da codegen'i çalıştır. Server'ın schema'sı değişip client'ınki eski kalırsa üretilen tipler yalan söyler; bir önceki yazıdaki schema diff'ini client'ın build'ine de bağlamak bu yüzden faydalı.

@defer'ı ölçerek kullan. Her ertelenen parça ayrı bir iş planı ve ayrı bir ağ parçası demek. Yavaş ve ekranın ikincil kısmı olan field'larda kazandırır; hızlı field'ları ertelemek yalnız karmaşıklık ekler.

Sık Düşülen Tuzaklar

Persisted dokümanlarda __typename'i unutmak. Apollo'nun normalized cache'i ve fragment eşleştirmesi ona dayanıyor; eksikse veri sessizce boş görünür.

@defer'ı Angular'ın HttpClient'ı üzerinden denemek. multipart/mixed parse edilemez; fetch tabanlı link gerekir.

Mutation payload'ında id'yi istememek. Cache değişen object'i bulamaz ve ekran eski veriyi göstermeye devam eder.

In-memory subscription provider ile birden çok instance'a çıkmak. Event'ler yalnız yayınlandıkları instance'taki abonelere ulaşır.

Schema'daki her değişiklikte codegen'i çalıştırmayı unutmak. Üretilen dosyaları repo'ya koyuyorsan CI'da "codegen çalıştır, diff boş mu" kontrolü ekle.

Küçük Bir Deneme

★☆☆ BookDetail sorgusundan @defer'ı kaldır ve Playwright ya da tarayıcının Network sekmesiyle ilk içeriğin ekrana geliş süresini karşılaştır.

★★☆ Bir addReview mutation'ı ekle ve yeni yorumu kitap detayındaki listeye refetchQueries kullanmadan, update callback'inde cache.modify ile ekle. Mutation'dan sonra kaç istek gittiğini say.

★★★ Subscription bağlantısına authorization ekle: token'ı connectionParams ile gönder, Hot Chocolate'ta bir socket session interceptor'ı ile doğrula ve kullanıcının yalnız kendi siparişinin durumunu dinleyebildiğini test et.

gRPC ve GraphQL'i ayrı ayrı uçtan uca kurduk. Bir sonraki yazıda hepsini bir araya getiriyoruz: aynı Bookstore senaryolarını REST (Representational State Transfer), gRPC ve GraphQL ile k6 kullanarak ölçüp hangi durumda hangisini seçeceğimize sayılarla karar veriyoruz.