Mert Özen Her Satırda Daha İleri
Yazılım Tasarımı Refactoring — #08

Angular Kodunu Refactor Etmek: NgModule'den Standalone'a, Constructor Injection'dan inject()'e, RxJS'ten Signal'lere

Mert Özen 14 Kas 2026 13 dk 1 görüntülenme
Angular Kodunu Refactor Etmek: NgModule'den Standalone'a, Constructor Injection'dan inject()'e, RxJS'ten Signal'lere

Eski bir Angular component'ini modernleştirmenin güvenli yolu, davranışı testle sabitlemek, resmi migration schematic'lerini --path ile feature feature, her birini ayrı commit'te çalıştırmak, schematic'lerin yapamadığı veri akışını ise testlerin gözetiminde elle dönüştürmektir. Yazıda bir CheckoutComponent sekiz schematic ve elle yapılan switchMap ve signal geçişiyle taşınıyor: prune-ng-modules'ın build'i kırması, hatadan sonra biten akış ve iki katına çıkan istek gerçek çıktılarla görülüyor.

Sürüm notu: Bu yazıdaki kodlar Angular 22.2 (CLI 22.2.7), TypeScript 6.0 (Angular 22.2 projeleri TypeScript 6.0 istiyor) ve RxJS 7.8 ile derlenip denenmiştir; testler Vitest 5.1 ile çalıştırılmıştır.

Önce Tahmin Et: Sekiz Schematic, Kaç Kırmızı Test?

Bookstore'un Angular uygulaması Angular 12 döneminde yazıldı ve ng update ile sürüm sürüm 22'ye kadar yükseltildi. Kod derleniyor ama hâlâ eski usulde. Ödeme sayfasındaki CheckoutComponent tipik bir örnek: bir NgModule'de declare edilmiş, constructor injection kullanıyor, template'te *ngIf ve *ngFor var, sepet gelince kargo ücretini iç içe bir subscribe ile istiyor ve abonelikleri elle topluyor:

export class CheckoutComponent implements OnInit, OnDestroy {
  @ViewChild('couponInput') couponInput?: ElementRef<HTMLInputElement>;

  cart?: Cart;
  shipping?: number;
  loading = true;
  error?: string;
  private subscriptions = new Subscription();

  constructor(
    private cartService: CartService,
    private shippingService: ShippingService,
  ) {}

  ngOnInit(): void {
    this.subscriptions.add(
      this.cartService.getCart().subscribe((cart) => {
        this.cart = cart;
        this.subscriptions.add(
          this.shippingService.quote(cart.postalCode, cart.weightKg).subscribe({
            next: (amount) => {
              this.shipping = amount;
              this.loading = false;
            },
            error: () => {
              this.error = 'Shipping quote failed.';
              this.loading = false;
            },
          }),
        );
      }),
    );
  }

  ngOnDestroy(): void {
    this.subscriptions.unsubscribe();
  }
}

Component'in decorator'ında bir de changeDetection: ChangeDetectionStrategy.Eager satırı var. Bunu biz yazmadık: Angular 22'de component'lerin varsayılanı OnPush oldu ve ng update'in change-detection-eager migration'ı eski davranışı korumak için bu satırı her component'e ekledi. Hedef, Angular'ın resmi migration schematic'leriyle bu component'i standalone, inject(), built-in control flow ve signal'lere taşımak. Sekiz schematic çalıştıracağız. Sence kaç tanesinden sonra build ya da testler kırmızıya döner?

Önce Test: Bugünkü Davranışı Sabitlemek

Schematic'ler kodu otomatik değiştirir, ama otomatik olması doğru olduğu anlamına gelmez. Bu yüzden ilk commit testler. CartService ve ShippingService'i, cevapların ne zaman geleceğini testin belirlediği Subject'lerle değiştirip ekranda görünen metni sabitliyoruz:

it('shows the lines and the total once the cart and the quote arrive', () => {
  expect(text()).toBe('Loading...');

  carts.next(cart('34000'));
  quotes.get('34000')!.next(59.9);
  fixture.detectChanges();

  expect(text()).toBe('2 x Dune | Shipping: 59.9 | Total: 959.9 | Place order');
});

// Tuhaf ama bugünkü davranış: hata mesajının yanında sepet de görünüyor ve toplamda kargo yok.
it('shows an error next to the cart when the quote fails', () => {
  carts.next(cart('34000'));
  quotes.get('34000')!.error(new Error('503'));
  fixture.detectChanges();

  expect(text()).toBe('Shipping quote failed. | 2 x Dune | Shipping: | Total: 900 | Place order');
});

İkinci testin beklentisini ben yazmadım, ilk koşudaki çıktıdan aldım. Hata durumunda sepetin de görünmesini beklemiyordum; characterization testi kodun bugün ne yaptığını kaydeder, ne yapması gerektiğini değil. Aynı dosyada iki test daha var: component yok edilince kargo isteğinin aboneliğinin kapandığını kontrol eden bir test ve bilinen bir hatayı bilerek sabitleyen bir test. Sepet arka arkaya iki kez gelirse (kullanıcı adresi değiştirdiğinde olduğu gibi) ve eski sepetin kargo cevabı yenisinden sonra gelirse, ekranda eski sepetin ücreti kalıyor. Test bu hatalı davranışı bekliyor; düzeltmesi ayrı bir commit olacak.

Standalone: İki Adım ve Bir Sürpriz

Bütün schematic'ler --path ile tek bir klasöre sınırlanabiliyor; işi feature feature yürütmenin yolu bu. Standalone migration'ı üç modda çalışıyor ve ilki component'leri standalone yapıyor:

$ ng generate @angular/core:standalone --path=src/app/checkout --mode=convert-to-standalone
    🎉 Automated migration step has finished! 🎉
    IMPORTANT! Please verify manually that your application builds and behaves as expected.
UPDATE src/app/checkout/checkout.component.ts (1883 bytes)
UPDATE src/app/checkout/order-summary.component.ts (447 bytes)
UPDATE src/app/checkout/checkout.module.ts (367 bytes)

Diff beklendiği gibi: standalone: false satırı kalkmış, template'in kullandığı directive'ler component'in kendi imports listesine yazılmış (NgIf, NgFor, NgClass, OrderSummaryComponent), modül de artık component'leri declare etmek yerine import ediyor. Build yeşil, dört test yeşil, commit. İkinci mod artık işe yaramayan modülleri siliyor:

$ ng generate @angular/core:standalone --path=src/app/checkout --mode=prune-ng-modules
DELETE src/app/checkout/checkout.module.ts
UPDATE src/app/app-module.ts (470 bytes)

Schematic, CheckoutModule'ü silip onu AppModule'ün imports listesinden de çıkardı; yerine CheckoutComponent'i eklemedi. Dosya verdiğimiz --path'in dışında olduğu hâlde değişti. Build hemen söyledi:

✘ [ERROR] NG8001: 'app-checkout' is not a known element:
1. If 'app-checkout' is an Angular component, then verify that it is part of this module.
...
✘ [ERROR] TS2307: Cannot find module './checkout.module' or its corresponding type declarations. [plugin angular-compiler]

İkinci hata test dosyasından geliyor; o da modülü import ediyordu. Düzeltme iki satır: AppModule'e ve teste CheckoutComponent'i import etmek. Sekiz schematic'in ilk kırmızısı bu oldu, ve testler kadar build'in de güvenlik ağının parçası olduğunu gösterdi. Üçüncü mod (standalone-bootstrap) uygulamayı bootstrapApplication'a geçirir; uygulamanın geri kalanı hâlâ NgModule tabanlı olduğu için onu bütün feature'lar bittiğinde çalıştıracağız. Standalone API'lerin kendisi Angular serisinin "Angular 15 ile Standalone API'ler: bootstrapApplication, provideRouter ve NgModule'süz Uygulama" yazısında anlatılıyor.

Control Flow, inject() ve Signal API'leri

Sonraki schematic'ler daha az sürpriz çıkardı, ama her birinin diff'inde bakılacak bir şey vardı. Control flow migration'ı *ngIf ve *ngFor'u @if ve @for'a çevirdi, kullanılmayan NgIf ve NgFor'u imports'tan kaldırdı:

-<div *ngIf="loading" class="loading">Loading...</div>
+@if (loading) {
+  <div class="loading">Loading...</div>
+}
...
-    <li *ngFor="let line of cart.lines">{{ line.quantity }} x {{ line.title }}</li>
+      @for (line of cart.lines; track line) {
+        <li>{{ line.quantity }} x {{ line.title }}</li>
+      }

@for bir track ifadesi zorunlu tutuyor ve schematic, eski koddaki davranışı korumak için object referansını seçmiş: track line. Sepet her yenilendiğinde yeni object'ler geldiği için bu, bütün satırların DOM'da (tarayıcının sayfayı tuttuğu nesne ağacı) yeniden oluşturulması demek. Doğru anahtar kitabın ISBN'i (kitaplara verilen uluslararası standart numara); bu küçük değişikliği schematic commit'inden ayrı bir commit'e koyduk ki reviewer neyin makineden, neyin insandan geldiğini görsün.

inject migration'ı constructor parametrelerini field'lara çevirdi: private cartService = inject(CartService);. Alanlar readonly değil; bunu da daha sonra elle ekledik. Ardından signal API'leri için üç schematic geldi:

$ ng generate @angular/core:signal-input-migration --path=src/app/checkout
    Successfully migrated to signal inputs 🎉
      -> Migrated 1/1 inputs.
$ ng generate @angular/core:output-migration --path=src/app/checkout
    Successfully migrated to outputs as functions 🎉
      -> Migrated 1 out of 1 detected outputs (100.00 %).
$ ng generate @angular/core:signal-queries-migration --path=src/app/checkout
    Successfully migrated to signal queries 🎉
      -> Migrated 1/1 queries.

OrderSummaryComponent'te @Input() total = 0 artık readonly total = input(0), @Output() confirm = new EventEmitter<void>() artık readonly confirm = output<void>(); template'teki {{ total }} da {{ total() }} oldu. @ViewChild ise viewChild()'a dönüştü ve onu kullanan focusCoupon method'u this.couponInput()?.nativeElement.focus() olarak güncellendi. Schematic'lerin asıl değeri bu: yalnız tanımı değil, referansları da değiştiriyorlar. Signal input'lar salt okunur olduğu için, uygulama kodunun bir yerde değer atadığı bir input'u schematic güvenle taşıyamaz ve atlar; çıktıdaki "Migrated 1/1" oranı bu yüzden review'da bakılacak ilk yer. --insert-todos seçeneği atlanan her input'un yanına nedenini yazan bir yorum ekliyor.

Son ikisi template temizliği: ngclass-to-class, [ngClass]="{ 'free-shipping': shipping === 0 }" ifadesini [class.free-shipping]="shipping === 0"'a çevirdi ve NgClass'ı imports'tan kaldırdı. Arkasından çalışan cleanup-unused-imports bu yüzden iş bulamadı:

$ ng generate @angular/core:cleanup-unused-imports
    Scanning for unused imports using tsconfig.app.json
    Schematic could not find unused imports in the project
Nothing to be done.

Bu schematic --path kabul etmiyor (Error: Unknown argument: path) ve bütün projeye bakıyor; büyük bir projede en sona bırakmak ve diff'ini ayrı review etmek iyi olur. Bütün bu API'lerin kendisi Angular serisinin "Angular 17 Yenilikleri: @if, @for, @defer ve Yeni Application Builder" ve "Angular 17.x Yenilikleri: input(), model(), output() ve Signal Query'ler" yazılarında.

Cevap: Bir Kırmızı Build, Ama İş Bitmedi

Sekiz schematic'ten yalnız biri, prune-ng-modules, build'i kırdı; testler hiç kırmızıya dönmedi. Ama component'e bakınca iş bitmiş görünmüyor: iç içe subscribe, elle toplanan abonelikler, ngOnInit ve ngOnDestroy ve Eager change detection yerinde duruyor. Schematic'ler syntax'ı ve API'yi değiştirir; veri akışının tasarımını değiştirmez. Kalan kısım elle yapılacak ve testlerin asıl işe yarayacağı yer orası.

Schematic'lerin Yapamadığı Kısım: İç İçe subscribe

İç içe subscribe'ın bilinen karşılığı switchMap: yeni bir sepet geldiğinde önceki sepetin kargo isteğini iptal eder. Bu, bilerek sabitlediğimiz eski sepet hatasını da düzeltir. Ama bu bir davranış değişikliği; o yüzden önce düz bir dönüşüm yaptım ve testleri çalıştırdım:

this.cartService
  .getCart()
  .pipe(
    tap((cart) => (this.cart = cart)),
    switchMap((cart) => this.shippingService.quote(cart.postalCode, cart.weightKg)),
  )
  .subscribe({ next: /* ... */, error: /* ... */ });
 FAIL  |bookstore-web| src/app/checkout/checkout.component.spec.ts > CheckoutComponent > quotes the next cart after a failed quote
TypeError: Cannot read properties of undefined (reading 'next')
 FAIL  |bookstore-web| src/app/checkout/checkout.component.spec.ts > CheckoutComponent > keeps the quote of an older cart when it arrives last
AssertionError: expected '2 x Dune | Shipping: 69.9 | Total: 96…' to contain 'Shipping: 59.9'

İkinci kırmızı beklenen sonuç: eski hata düzeldi, test eski hatayı beklediği için kırıldı. Birincisi ise bir regression. Bu testi schematic'lerden sonra, elle dönüşüme başlamadan önce ekledim: eski kodda kargo isteği hata verse bile sonraki sepet için yeni istek yapılıyordu, çünkü hata yalnız iç aboneliği bitiriyordu. Düz switchMap'te iç akıştaki hata dış akışa taşınıyor ve sepet akışı tamamen bitiyor; kullanıcı adresini düzeltse bile bir daha kargo ücreti hesaplanmıyor. Async kodda "aynı şeyi daha kısa yazmak", hata ve iptal davranışını sessizce değiştirebilir.

Düzeltme hatayı iç akışta yakalamak. Bu commit iki davranış değişikliğini açıkça söylüyor: eski sepetin geç gelen cevabı artık yok sayılıyor ve hatadan sonra başarılı bir cevap gelince hata mesajı kalkıyor. Eski hatayı sabitleyen testin beklentisi de aynı commit'te değişti:

switchMap((cart) =>
  this.shippingService.quote(cart.postalCode, cart.weightKg).pipe(
    map((amount) => ({ amount, error: undefined })),
    catchError(() => of({ amount: undefined, error: 'Shipping quote failed.' })),
  ),
),

Signal'lere Geçmek: toSignal ve computed

Akış doğru olunca state'i signal'lere taşımak mekanik bir iş. Component'in alanları toSignal ve computed oluyor, ngOnInit, ngOnDestroy ve Subscription kalkıyor; toSignal aboneliği component yok edilince kendisi kapatıyor. Eager satırı da siliniyor, component Angular 22'nin varsayılanı olan OnPush ile çalışıyor:

export class CheckoutComponent {
  private readonly cartService = inject(CartService);
  private readonly shippingService = inject(ShippingService);

  readonly couponInput = viewChild<ElementRef<HTMLInputElement>>('couponInput');

  // Sepet için tek istek; aşağıdaki iki signal onu paylaşır.
  private readonly cart$ = this.cartService.getCart().pipe(shareReplay({ bufferSize: 1, refCount: true }));

  readonly cart = toSignal(this.cart$);

  private readonly quote = toSignal(
    this.cart$.pipe(
      switchMap((cart) =>
        this.shippingService.quote(cart.postalCode, cart.weightKg).pipe(
          map((amount) => ({ amount, error: undefined })),
          catchError(() => of({ amount: undefined, error: 'Shipping quote failed.' })),
        ),
      ),
    ),
  );

  readonly loading = computed(() => this.quote() === undefined);
  readonly shipping = computed(() => this.quote()?.amount);
  readonly error = computed(() => this.quote()?.error);
  readonly itemsTotal = computed(
    () => this.cart()?.lines.reduce((sum, l) => sum + l.quantity * l.unitPrice, 0) ?? 0,
  );
}

shareReplay satırı kolay gözden kaçan bir ayrıntı. Aynı Observable'a iki toSignal abone olunca, paylaşım yoksa HttpClient iki ayrı istek atar. Bu refactoring'den önce, sepetin bir kez istendiğini kontrol eden bir test ekledim; satırı silip denediğimde test bunu yakaladı:

 FAIL  |bookstore-web| src/app/checkout/checkout.component.spec.ts > CheckoutComponent > requests the cart once
AssertionError: expected 2 to be 1 // Object.is equality

Aynı akışı Angular 22 ile stable olan rxResource ile de kurabilirdik; sepeti bir signal olarak verip kargo isteğini onun stream'ine yazmak, önceki isteği iptal etme işini de üstlenir. Burada toSignal'ı seçtik, çünkü akış zaten RxJS'te ve tek değişiklik onu signal'e bağlamaktı.

Signal'e Ne Zaman Geçmemeli?

Her Observable'ı signal'e çevirmek gerekmiyor. CartService ve ShippingService hâlâ Observable dönüyor ve böyle kalmaları doğru: HTTP isteği tek değer üreten, iptal edilebilen bir işlem ve RxJS bunu iyi ifade ediyor. Signal'ler template'in okuduğu state için iyi; zamana bağlı olay akışları için değil. Arama kutusunda debounceTime, distinctUntilChanged ve switchMap ile kurulmuş bir typeahead'i signal'lere çevirmeye çalışmak kodu kısaltmaz, uzatır. Sınır genelde şu: akış RxJS'te kurulur, en sonda template'e verilmek üzere signal'e çevrilir. Bu sınırın ayrıntıları ilerideki RxJS ve Angular'da State Yönetimi serilerinde.

OrderSummaryComponent da iyi bir örnek. Schematic'ler onu input() ve output()'a taşıdı ve orada durduk; içinde state yok, yalnız bir değer gösterip bir event yayıyor. Daha fazla dönüşüm ona bir şey kazandırmaz.

Kademeli Geçiş: Feature Feature

Bütün geçiş on dört commit, her biri yeşil:

$ git log --oneline
2b31234 refactor: CheckoutComponent state as signals (toSignal, computed), no manual subscriptions, OnPush
a182e67 test: pin that the cart is requested once
a6f3ff6 fix: quote only the latest cart (switchMap); a failed quote no longer ends the stream and a later quote clears the error
761454d test: pin that a new cart is quoted after a failed quote
a213b05 refactor: class binding instead of NgClass (ng g @angular/core:ngclass-to-class)
c1c4983 refactor: viewChild() signal query in CheckoutComponent (signal-queries-migration)
6af7f48 refactor: input() and output() in OrderSummaryComponent (signal-input-migration, output-migration)
ccd67b8 refactor: inject() instead of constructor injection (ng g @angular/core:inject)
466c891 refactor: track cart lines by isbn instead of object identity
aa5383e refactor: built-in control flow in checkout (ng g @angular/core:control-flow)
d9d55b3 refactor: remove CheckoutModule (prune-ng-modules) and import CheckoutComponent where the module was used
ba8f363 refactor: CheckoutComponent and OrderSummaryComponent standalone (ng g @angular/core:standalone convert-to-standalone)
ccb6ebb test: characterization tests for CheckoutComponent
6f27d13 legacy: CheckoutComponent in CheckoutModule (zone.js, Eager)

Her schematic kendi commit'inde, commit mesajında da çalıştırılan komut var. Reviewer makinenin ürettiği diff'e başka, insanın yazdığına başka bir gözle bakabiliyor. Bu ayrım üçüncü yazıdaki "toplu otomatik düzeltme ayrı commit'te" kuralının Angular'daki karşılığı.

Gerçek bir projede bu iş tek component'le değil, onlarca feature klasörüyle yapılır. --path ile bir feature'ı bitirip merge etmek, sonra sıradakine geçmek, review'u küçük tutar ve bir schematic beklenmedik bir şey yaptığında etkisini o feature'la sınırlar. Bu sürede kod tabanı karışık olacak: bazı component'ler standalone ve signal tabanlı, bazıları NgModule'de. Ekip için yazılı bir kural bu dönemi kolaylaştırır: yeni kod yalnız standalone, inject() ve signal API'leriyle yazılır; eski bir component'e bir özellik için dokunulacaksa, önce o feature'ın migration'ı ayrı bir PR (pull request; değişikliğin incelenip ana branch'e alınması için açılan istek) olarak yapılır. Böyle kuralları otomatik denetlemek ilerideki Coding Conventions ve Ekip Standartları serisinin konusu. Bütün feature'lar bittiğinde standalone-bootstrap ile AppModule kalkar, OnPush ve signal'lerle çalışan component'ler çoğaldıkça zone.js'i kaldırmak da gündeme gelir. Angular 22'nin varsayılanlarının eski bir projeyle karşılaştırması Angular serisinin "Angular 22: Varsayılan OnPush, Stable Signal Forms ve Angular 12 ile Proje Farkı" yazısında.

Sık Düşülen Tuzaklar

Bütün projeye tek komutla migration uygulayıp tek dev PR açmak. --path vermeden çalışan bir schematic yüzlerce dosyaya dokunur ve kimse o diff'i gerçekten okumaz. Bu yazıdaki prune-ng-modules sürprizi, yüz modüllük bir projede yüz farklı yerde ortaya çıkardı.

Schematic çıktısını review etmeden merge etmek. Schematic'ler davranışı korumaya çalışır, ama bu her zaman en iyi kodu üretmek anlamına gelmez: track line çalışır ama her yenilemede DOM'u baştan kurar; inject() alanları readonly olmadan gelir. Bir schematic'in çıktısındaki oran 1/1 değilse, atlananın neden atlandığını öğrenmeden merge etme.

Async davranışı değişen yerleri test etmeden geçmek. Bu yazıda iki kez gördük: düz switchMap hatadan sonra akışı bitirdi, shareReplay'siz toSignal isteği ikiye katladı. İkisi de derlenir ve mutlu yol testlerinden geçer; yakalayan, hata, iptal ve istek sayısı gibi durumları sabitleyen testlerdi.

Davranış düzeltmesini refactoring'e karıştırmak. Eski sepet hatasının düzelmesi iyi bir şey, ama refactor: başlıklı bir commit'in içinde gelseydi, reviewer bir testin beklentisinin neden değiştiğini sorgulamazdı. Düzeltme fix: başlığıyla kendi commit'inde.

Her şeyi signal'e çevirmek. Observable'lar HTTP ve zaman tabanlı akışlar için hâlâ doğru araç; signal'ler template'in okuduğu state için.

Küçük Bir Deneme

★☆☆ Kendi Angular projende bir feature klasörü seç ve ng generate @angular/core:control-flow --path=... çalıştır. Üretilen track ifadelerine bak: kaç tanesi object referansı, kaç tanesi gerçek bir id?

★★☆ İç içe subscribe kullanan bir component bul. Önce iç isteğin hata verdiği ve dış akışın ikinci bir değer ürettiği durumu sabitleyen bir test yaz, sonra kodu switchMap'e çevir. Test ne diyor?

★★★ Bu yazıdaki CheckoutComponent'in kargo kısmını rxResource ile yeniden yaz: sepeti params, kargo isteğini stream olarak ver. Aynı altı test değişmeden geçiyor mu? Geçmeyen varsa davranış nerede ayrılıyor?

Bu seride şimdiye kadar Bookstore'un altı legacy parçasıyla uğraştık. Hangisine önce yatırım yapılacağını ise hep biz seçtik. Son yazıda bu seçimi veriye dayandırıyoruz: git log ve küçük bir C# script'iyle Bookstore repo'sunun churn ve Cognitive Complexity değerlerini çıkarıp hotspot'ları bulacak, change coupling'e bakacak ve refactoring'i Product Owner'a değişim maliyeti, lead time ve hata oranı üzerinden nasıl anlatacağımızı konuşacağız.