Büyük Refactoring'i Planlamak: Mikado Method, Branch by Abstraction ve Parallel Run
Tek PR'a sığmayan bir refactoring, Mikado Method ile deneyip geri alarak çıkarılan bir ön koşul graph'ıyla planlanır ve branch by abstraction ile ana branch'te küçük, her biri yeşil commit'lerle yürütülür. Yazıda static LegacyShippingClient typed HttpClient tabanlı bir client'la 13 commit'te değiştiriliyor; parallel run testlerde olmayan iki farkı yakalıyor, static state'in paralel testleri nasıl bozduğu da gerçek çıktılarla gösteriliyor.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 (SDK 10.0.402) ile, testler xUnit.net v3 4.0.3 ve Microsoft.Extensions.Diagnostics.Testing 10.11.0 ile derlenip denenmiştir; typed client Microsoft.Extensions.Http 10.0.15 ile kurulmuştur.
Önce Tahmin Et: Bu Değişiklik Bir Öğleden Sonra Sürer mi?
Bookstore'un kargo firması eski XML servisini kapatacağını duyurdu; yerine JSON konuşan bir v2 API var. Bizim tarafta kargo firmasıyla konuşan tek yer, 2014'ten kalma static bir class:
// 2014'ten beri kargo firmasının XML servisine senkron çağrı yapıyor.
public static class LegacyShippingClient
{
public static string Endpoint { get; set; } = "https://carrier.example/xml";
public static decimal GetRate(string postalCode, decimal weightKg)
{
XElement response = Post("GetRate",
new XElement("RateRequest",
new XElement("PostalCode", postalCode),
new XElement("WeightKg", weightKg)));
return decimal.Parse(response.Element("Amount")!.Value, CultureInfo.InvariantCulture);
}
public static string CreateShipment(int orderId, string postalCode, decimal weightKg) { /* ... */ }
private static XElement Post(string action, XElement body)
{
WebRequest request = WebRequest.Create(Endpoint);
// ... senkron GetRequestStream / GetResponse
}
}
Derleyici bu class'ı yıllardır aynı uyarıyla derliyor:
LegacyShippingClient.cs(34,30): warning SYSLIB0014: 'WebRequest.Create(string)' is obsolete: 'WebRequest, HttpWebRequest, ServicePoint, and WebClient are obsolete. Use HttpClient instead.'
Üç yer onu çağırıyor: sepette kargo ücretini hesaplayan CheckoutService, kargo kaydı açan FulfillmentService ve iade ücretini hesaplayan ReturnService. Üçü de minimal API endpoint'lerinde new CheckoutService() diye oluşturuluyor. Hedef net: kargo çağrıları typed HttpClient ile yeni API'ye gitsin, LegacyShippingClient silinsin. Üç servis, üç endpoint ve bir static class. Sence bu iş tek bir PR'da (pull request; değişikliğin incelenip ana branch'e alınması için açılan istek), bir öğleden sonrada biter mi?
Mikado Method: Dene, Not Al, Geri Al
Ola Ellnestam ve Daniel Brolund'un The Mikado Method (Manning, 2014) kitabında anlattığı yöntem, büyük bir değişikliği planlamayı tahmin yerine denemeye dayandırır. Döngü kısa: hedefi doğrudan uygulamayı dene; kırılan her şeyi hedefin ön koşulu olarak bir graph'a yaz; sonra yaptığın değişikliği geri al. Her ön koşul için aynı döngüyü tekrarla. Ön koşulu olmayan bir düğüme, yani bir yaprağa ulaştığında onu gerçekten uygula, commit'le ve graph'ta yukarı doğru ilerle. Adı, çubukları diğerlerini kımıldatmadan tek tek çektiğin Mikado oyunundan geliyor.
İlk denemeyi en saf hâliyle yaptım: yeni bir CarrierApiClient yazdım ve CheckoutService'i doğrudan ona bağladım.
public class CheckoutService(CarrierApiClient carrier)
{
public Task<decimal> QuoteShippingAsync(Cart cart, CancellationToken cancellationToken = default) =>
carrier.GetRateAsync(cart.PostalCode, cart.WeightKg, cancellationToken);
}
src/Bookstore.Web/Program.cs(10,35): error CS7036: There is no argument given that corresponds to the required parameter 'carrier' of 'CheckoutService.CheckoutService(CarrierApiClient)'
tests/Bookstore.Shipping.Tests/LegacyBehaviorTests.cs(14,36): error CS7036: There is no argument given that corresponds to the required parameter 'carrier' of 'CheckoutService.CheckoutService(CarrierApiClient)'
İki hata, iki not: endpoint'ler servisi new ile oluşturuyor, testler de öyle. Mikado'nun sınandığı an burası. İnsanın içinden hatayı hemen düzeltmek geliyor; ben de bir adım ileri gidip Program.cs'e AddHttpClient<CarrierApiClient> ve AddScoped<CheckoutService>() ekledim, endpoint'i async yaptım. Web projesi derlendi, ama yeni sorular çıktı. QuoteShippingAsync artık Task döndürüyor, onu çağıran her yer async olmalı. Testler yeni client'ı neye karşı kuracak? Characterization testleri yalnız eski XML servisini tanıyor; yeni API'nin aynı fiyatı verdiğini gösteren hiçbir şey yok. FulfillmentService için yeni client'ta CreateShipment karşılığı bile yok.
Bu noktada notları graph'a yazıp her şeyi geri aldım:
$ git restore . && git clean -fd src
$ git status --short
$
Geri almak israf gibi görünür, ama yöntemin özü bu. Kırık kodun üstüne düzeltme yığarsan bir saat sonra neyin neden kırıldığını bilemezsin ve geri dönebileceğin yeşil bir nokta da kalmaz. On dakikalık deneme, kodda kalmadı ama graph'ta kaldı.
Cevap: Graph'ın Yaprakları ve Uygulama Sırası

Hayır, bir öğleden sonra sürmüyor; ama sürmemesi sorun değil, çünkü artık işin haritası var. Hedefin üç ön koşulu çıktı: servisler carrier'ı constructor'dan almalı, çağıranlar async olmalı ve yeni client'ın eskisiyle aynı sonucu verdiği gösterilmeli. İlk ikisinin altında iki yaprak var: servisleri DI'dan (dependency injection; bağımlılıkları dışarıdan veren mekanizma) çözen endpoint'ler ve iki implementasyonun da arkasına geçebileceği bir soyutlama. Üçüncünün altında contract testleri ve production trafiğinde parallel run duruyor.
Yapraklar birbirinden bağımsız, küçük ve her biri kendi başına yeşil kalan değişiklikler. Görseldeki numaralar uygulama sırasını gösteriyor. Dikkat edilecek nokta, graph'ın tek bir PR'ı tarif etmemesi: her düğüm bir ya da iki commit ve her commit ana branch'e gidebilecek durumda.
Branch by Abstraction: Soyutlamayı Araya Koymak
İlk yaprak, iki implementasyonun yan yana yaşamasını sağlayacak soyutlama. Martin Fowler'ın 2014 tarihli BranchByAbstraction yazısına göre teknik adını Paul Hammant'tan alıyor, Hammant da fikri Stacy Curl'e atfediyor. "Branch" kelimesi yanıltmasın: kaynak kontrolünde branch açılmıyor, dallanma kodun içinde bir interface'in arkasında oluyor. Soyutlamanın imzasını eski koda göre değil, varmak istediğimiz yere göre seçtik: async ve CancellationToken alan method'lar.
public interface IShippingCarrier
{
Task<decimal> GetRateAsync(string postalCode, decimal weightKg, CancellationToken cancellationToken = default);
Task<string> CreateShipmentAsync(int orderId, string postalCode, decimal weightKg, CancellationToken cancellationToken = default);
}
// Geçici: eski static client yeni soyutlamanın arkasında. Her çağrıda hâlâ bir thread'i bloke ediyor.
public sealed class LegacyShippingCarrier : IShippingCarrier
{
public Task<decimal> GetRateAsync(string postalCode, decimal weightKg, CancellationToken cancellationToken = default) =>
Task.FromResult(LegacyShippingClient.GetRate(postalCode, weightKg));
public Task<string> CreateShipmentAsync(int orderId, string postalCode, decimal weightKg, CancellationToken cancellationToken = default) =>
Task.FromResult(LegacyShippingClient.CreateShipment(orderId, postalCode, weightKg));
}
Task.FromResult içinde senkron bir HTTP çağrısı güzel bir kod değil; async görünüp thread'i bloke ediyor. Bu bilinçli bir ara adım: soyutlamanın imzası doğru, içi geçici. Bu commit'te kimse IShippingCarrier'ı kullanmıyor, davranış değişmiyor. İkinci yaprak, endpoint'lerin servisleri DI container'ından alması. Ardından servisler tek tek soyutlamaya taşındı. CheckoutService'in commit'i, Mikado denemesinde gördüğümüz değişikliğin neredeyse aynısı; tek fark, bu kez constructor'a somut client değil soyutlama geliyor ve DI kaydı eski implementasyonu gösteriyor:
+builder.Services.AddSingleton<IShippingCarrier, LegacyShippingCarrier>();
builder.Services.AddScoped<CheckoutService>();
-app.MapPost("/checkout/shipping-quote", (Cart cart, CheckoutService checkout) =>
- Results.Ok(new { amount = checkout.QuoteShipping(cart) }));
+app.MapPost("/checkout/shipping-quote", async (Cart cart, CheckoutService checkout, CancellationToken ct) =>
+ Results.Ok(new { amount = await checkout.QuoteShippingAsync(cart, ct) }));
Bu commit'ten sonra kargo ücreti hâlâ eski XML servisinden geliyor, testler hâlâ yeşil. Aynı adım ReturnService ve FulfillmentService için tekrarlandı. Artık hiçbir servis LegacyShippingClient'ı doğrudan bilmiyor; onu bilen tek yer adapter.
İki Implementasyon, Aynı Testler
Yeni client'ı yazmadan önce, onun doğru olduğunu neyin söyleyeceğine karar verdik. Characterization testlerini, implementasyonu bir abstract method'dan alan bir base class'a çevirdik. Aynı testler her implementasyon için ayrı bir subclass'la çalışıyor:
public abstract class ShippingCarrierContract(CarrierFixture fixture)
{
protected abstract IShippingCarrier CreateCarrier();
[Theory]
[InlineData("34000", 0.8, 59.90)]
[InlineData("06100", 2.5, 94.90)]
[InlineData("21000", 1.0, 89.90)]
public async Task Checkout_quotes_the_carrier_rate(string postalCode, decimal weightKg, decimal expected)
{
Cart cart = new(postalCode, [new CartLine("9780441013593", 1, weightKg)]);
Assert.Equal(expected, await new CheckoutService(CreateCarrier()).QuoteShippingAsync(cart, TestContext.Current.CancellationToken));
}
// ... iade ücreti ve tek kargo kaydı testleri
}
public class LegacyShippingCarrierTests(CarrierFixture fixture) : ShippingCarrierContract(fixture)
{
protected override IShippingCarrier CreateCarrier() => new LegacyShippingCarrier();
}
Testler gerçek kargo firmasına değil, test sürecinde Kestrel üzerinde ayağa kalkan küçük bir sahte servise gidiyor; bu servis hem eski XML endpoint'ini hem de v2 JSON API'yi aynı fiyat listesiyle sunuyor. Sonra typed client'ı yazdık: HttpShippingCarrier, constructor'dan HttpClient alan, PostAsJsonAsync ile v2/rates ve v2/shipments'a giden sıradan bir class. Ona da bir subclass eklendi. İlk koşu, yeni kodla hiç ilgisi olmayan bir sebepten kırmızıydı ve her seferinde değil, arada bir:
failed Bookstore.Shipping.Tests.LegacyShippingCarrierTests.Ship_creates_exactly_one_shipment (3ms)
Assert.Equal() Failure: Values differ
Expected: 1
Actual: 0
failed Bookstore.Shipping.Tests.HttpShippingCarrierTests.Ship_creates_exactly_one_shipment (3ms)
Assert.Equal() Failure: Values differ
Expected: 1
Actual: 2
Sebep LegacyShippingClient.Endpoint'in static olması. İki test class'ı paralel çalışınca her biri kendi sahte servisini başlatıp static adresi kendi servisine çeviriyor; sonra başlayan kazanıyor ve eski client'ın kargo kaydı öbür servise düşüyor. Legacy kodun static state'i, testleri paralel koşturduğun ilk gün böyle ortaya çıkar. Çözüm, iki class'ı tek bir xUnit collection'ına alıp aynı sahte servisi paylaştırmak; collection içindeki testler sırayla çalışır. Bu kısıt, static client silinene kadar ödenen geçici bir bedel.
Parallel Run: Eskisi Cevap Verir, Yenisi Ölçülür
Testler yeşil; ama testler yalnız bizim aklımıza gelen girdileri sınıyor. Production'daki gerçek sepetleri iki yoldan da geçirmenin yolu parallel run: aynı isteği iki implementasyona da gönderirsin, müşteriye her zaman eski sonucu döndürürsün, farkları log'a yazarsın. GitHub bu fikri 2016'da Ruby için Scientist library'si olarak yayımladı; eski koda control, yeni koda candidate diyor ve .NET için Scientist adlı bir port'u da var. Bizim ihtiyacımız küçük olduğu için kendi decorator'ımızı yazdık:
// Parallel run: müşteriye eski sonuç döner; yeni sonuç yalnız karşılaştırılır ve log'a yazılır.
public sealed class ParallelRunShippingCarrier(
LegacyShippingCarrier legacy,
HttpShippingCarrier candidate,
ILogger<ParallelRunShippingCarrier> logger) : IShippingCarrier
{
public async Task<decimal> GetRateAsync(string postalCode, decimal weightKg, CancellationToken cancellationToken = default)
{
decimal expected = await legacy.GetRateAsync(postalCode, weightKg, cancellationToken);
try
{
decimal actual = await candidate.GetRateAsync(postalCode, weightKg, cancellationToken);
if (actual != expected)
logger.LogWarning("Rate mismatch for {PostalCode} {WeightKg}kg: legacy {Legacy}, new {New}",
postalCode, weightKg, expected, actual);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Yeni yoldaki bir hata müşteriye hiçbir zaman ulaşmaz.
logger.LogWarning("New carrier failed for {PostalCode} {WeightKg}kg: {Error}", postalCode, weightKg, ex.Message);
}
return expected;
}
// Kargo kaydı açmanın yan etkisi var: yalnız eski yol çalışır.
public Task<string> CreateShipmentAsync(int orderId, string postalCode, decimal weightKg, CancellationToken cancellationToken = default) =>
legacy.CreateShipmentAsync(orderId, postalCode, weightKg, cancellationToken);
}
Hangi implementasyonun kullanılacağını Shipping:Carrier configuration değeri seçiyor: Legacy, ParallelRun ya da Http; varsayılan Legacy. Web uygulamasını ParallelRun ile başlatıp beş farklı sepet gönderdim; aralarında baştaki sıfırı eksik bir posta kodu ve yalnız e-kitap içeren bir sepet de vardı. Müşterinin gördüğü beş cevap eskisiyle aynıydı; log'a iki satır düştü:
warn: Bookstore.Shipping.Carrier.ParallelRunShippingCarrier[0]
Rate mismatch for 6100 1.2kg: legacy 82.40, new 102.4
warn: Bookstore.Shipping.Carrier.ParallelRunShippingCarrier[0]
New carrier failed for 35210 0kg: Response status code does not indicate success: 400 (Bad Request).
İki bulgu da hiçbir testte yoktu. Birincisi, Excel'den aktarılırken baştaki sıfırı kaybetmiş eski müşteri adresleri: 6100 aslında 06100. Eski XML servisi dört haneli posta kodlarını sessizce sıfırla tamamlıyormuş; v2 API bunu yapmıyor ve kodu "61" bölgesi sanıp başka bir fiyat veriyor. İkincisi, yalnız e-kitap içeren sepetler: ağırlık 0, eski servis bunu kabul edip baz fiyatı veriyor, yeni API en az 1 gram istiyor.
İkinci bulgu bir iş kuralı sorusu da açıyor: yalnız e-kitap alan müşteriden kargo ücreti almalı mıyız? Cevap büyük ihtimalle hayır, ama bu bir davranış değişikliği ve client değiştirirken verilecek bir karar değil. Bu yüzden yeni client'ı eskisi gibi davranacak şekilde düzelttik (posta kodunu beş haneye tamamlamak, en az 1 gram göndermek), iki durumu contract testine ekledik ve e-kitap sorusunu Product Owner'a ayrı bir iş olarak yazdık. Aynı beş sepetle ikinci parallel run'da log boş kaldı.
Parallel run'ın bedeli de var. Kargo firmasına giden her ücret sorgusu iki katına çıkıyor; firma istek başına ücret alıyorsa ya da rate limit uyguluyorsa bu hesaba girmeli. Cevap süresi de iki çağrının toplamı oluyor; çağrıları Task.WhenAll ile eşzamanlı yapmak ya da yeni yolu yalnız isteklerin bir yüzdesinde çalıştırmak bu süreyi azaltır. Asıl tehlike yan etkili işlemlerde: CreateShipmentAsync'i iki yoldan da çalıştırsaydık her siparişe iki kargo kaydı açılırdı. Bu yüzden parallel run yalnız okuma işlemlerinde kullanılır; yazan işlemler için yeni yol ancak bir test ortamında ya da sahte bir hesapta sınanır. Aynı fikrin servis seviyesindeki hâli Microservices serisinin "Monolith'ten Microservice'e: Strangler Fig, Branch by Abstraction ve Distributed Monolith Tuzağı" yazısında anlatılıyor.
Geçiş ve Temizlik: Switch, Sonra Silmek
Log birkaç gün boyunca boş kalınca geçiş tek satırlık bir configuration değişikliği oldu: "Carrier": "Http". Burada sade bir configuration değeri yetti, çünkü geçişin tek bir anahtarı var ve geri dönmek için yeniden deploy yeterli. Kullanıcı grubuna göre açmak, deploy etmeden geri almak ya da yüzdeyle açmak gerekseydi gerçek bir feature flag gerekirdi; flag türleri ve Microsoft.FeatureManagement Çözüm Desenleri serisinin "Feature Flag'leri Kodda Tasarlamak: Toggle Türleri, Microsoft.FeatureManagement ve Flag Borcu" yazısında.
Branch by abstraction'ın son adımı en çok atlanan adım: eskisini silmek. Static client, adapter, parallel run decorator'ı, switch ve Shipping:Endpoint ayarı tek bir commit'te gitti:
$ git show --shortstat --format=%s HEAD
refactor: remove LegacyShippingClient, its adapter, the parallel run and the switch
9 files changed, 1 insertion(+), 153 deletions(-)
Geriye kalan DI kaydı tek satır: services.AddHttpClient<IShippingCarrier, HttpShippingCarrier>(...). Soyutlamanın kendisi kalmalı mı? Geçiş için açılan bir interface, geçiş bitince gereksiz olabilir; tek implementasyonu olan bir interface'i "belki lazım olur" diye tutmak YAGNI'ye (You Aren't Gonna Need It; ihtiyaç doğmadan soyutlama ekleme ilkesi) ters düşer. Bizim durumumuzda IShippingCarrier kalıyor, çünkü artık geçişten bağımsız bir işi var: üçüncü taraf bir sistemin sınırını çiziyor ve servis testlerinde kargo firmasının yerine geçebiliyor. Bu kararı bilinçli verip commit mesajına yazmak, interface'in neden orada olduğunu bir sonraki okuyucuya anlatır.
Uzun Süren Refactoring'i Trunk'ta Yürütmek
Bütün iş ayrı bir branch'te değil, ana branch'te on üç küçük commit olarak ilerledi. Her commit derleniyor, testler yeşil ve Shipping:Carrier'ın varsayılanı sayesinde geçişe kadar production davranışı değişmiyor:
$ git log --oneline
51c062b refactor: remove LegacyShippingClient, its adapter, the parallel run and the switch
d260de5 chore: switch Shipping:Carrier to Http
02227e2 fix: new carrier pads postal codes and sends at least 1 g, as the parallel run showed
750f6b9 chore: fake carrier host for local runs
c44e79d test: fake carrier pads four-digit postal codes on the XML endpoint like the real one
d1e7815 feat: ParallelRunShippingCarrier and Shipping:Carrier switch (default Legacy)
d21d20c feat: HttpShippingCarrier for the v2 JSON API, same contract tests (not wired)
1313257 test: turn characterization tests into a contract for IShippingCarrier
db9279c refactor: ReturnService and FulfillmentService go through IShippingCarrier
96edb6e refactor: CheckoutService quotes through IShippingCarrier
7bc07d8 refactor: resolve checkout, fulfillment and return services from DI
f50cc8a refactor: introduce IShippingCarrier with a legacy adapter (no callers yet)
fec13be legacy: LegacyShippingClient and its callers, with characterization tests
Aynı işi üç haftalık bir feature/new-shipping-client branch'inde yapsaydık, o sürede ana branch'te kargo koduna dokunan her değişiklik merge sırasında karşımıza çıkardı. Review de on üç küçük diff yerine tek ve büyük bir diff olurdu. Branch by abstraction yarım kalmış işi ana branch'te güvenle tutmayı sağlar: yeni kod oradadır ama bağlı değildir. Kısa ömürlü branch'ler ve küçük merge'ler Git serisinin "Git ile Ekip Çalışması: Branch Stratejileri, Rulesets ve Conventional Commits" yazısında anlatılıyor.
Sık Düşülen Tuzaklar
Mikado'da geri almayı atlamak. İlk denemede çıkan hataları "nasılsa biliyorum" diye yerinde düzeltmeye başlamak, graph yerine kırık bir çalışma kopyası bırakır. Bir saat sonra hangi değişikliğin hangi ön koşul için yapıldığını ayırt edemezsin ve geri dönebileceğin yeşil bir commit de kalmaz. Deneme kodu atılmak için yazılır; değerli olan graph'tır.
Graph'ı yapraklardan değil kökten uygulamak. Hedef düğümü "artık ne yapacağımı biliyorum" diye ilk iş yapmak, Mikado denemesinin kendisini tekrar etmek demektir. Sıra hep yapraktan köke doğru.
Ara soyutlamayı kalıcı hâle getirmek. LegacyShippingCarrier, parallel run decorator'ı ve switch geçici iskeledir. Silinmeyen her biri, iki yıl sonra kimsenin neden orada olduğunu bilmediği bir katman olur. Switch'in ve eski yolun silinmesini geçişin bir parçası olarak planla ve backlog'a yaz.
Yan etkili işlemlerde parallel run. Okuma işlemlerinde iki yolu karşılaştırmak ucuzdur; kargo kaydı, ödeme ya da e-posta gibi işlemlerde iki yolu da çalıştırmak işlemi iki kez yapmak demektir. Yeni yolu bu işlemlerde ancak bir test hesabında ya da kaydı gerçekten yapmayan bir dry-run modunda dene.
Parallel run'da bulunan farkı yeni koda "düzeltme" olarak gizlemek. E-kitap sepetinde olduğu gibi bazı farklar eski kodun tuhaf bir davranışını gösterir. Yeni client'ı eskisi gibi davranacak şekilde düzeltmek refactoring'in işi; tuhaf davranışı değiştirmek ise ayrı bir karar ve ayrı bir commit.
Küçük Bir Deneme
★☆☆ Kod tabanında static bir client ya da helper seç ve hedefi "bu class silinir" diye yaz. Hedefi doğrudan uygulamayı dene, derleyicinin verdiği hataları say, ön koşulları bir kâğıda yaz ve git restore . ile geri al. Kaç ön koşul çıktı?
★★☆ Bu yazıdaki ParallelRunShippingCarrier'ı iki çağrıyı Task.WhenAll ile eşzamanlı yapacak şekilde değiştir ve yeni yolu yalnız isteklerin yüzde 10'unda çalıştıran bir ayar ekle. Cevap süresini iki sürümde ölç; yeni yolun hata vermesi müşteriye yansıyor mu?
★★★ Ekipte uzun ömürlü bir branch'te yürüyen bir geçiş varsa onu branch by abstraction ile ana branch'e taşımanın planını çıkar: soyutlama ne olur, hangi çağıran önce taşınır, switch nerede durur, parallel run hangi işlemlerde güvenli? Planı bir Mikado graph'ı olarak çiz.
Bu yazıda bir client'ın yerini, dış dünyaya giden contract'ı değiştirmeden değiştirdik: kargo firması iki sürümü de destekliyordu. Bir sonraki yazıda contract'ın kendisi değişiyor. Book.Price alanını ListPrice olarak yeniden adlandırıp DiscountRate'i ayrı bir tabloya taşırken API'nin JSON alanını, Entity Framework Core (EF Core; .NET'in veritabanı erişim kütüphanesi) property'sini, veritabanı kolonunu ve Angular modelini expand-contract ile birlikte nasıl değiştireceğimize bakacağız.