Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Microservices — #12

Monolith'ten Microservice'e: Strangler Fig, Branch by Abstraction ve Distributed Monolith Tuzağı

Mert Özen 8 Kas 2026 11 dk 2 görüntülenme
Monolith'ten Microservice'e: Strangler Fig, Branch by Abstraction ve Distributed Monolith Tuzağı

Monolith'ten microservice'e sıfırdan yeniden yazarak değil, monolith'in önüne bir proxy koyup yetenekleri tek tek dışarı alarak (strangler fig) geçilir; proxy'den geçmeyen iç çağrılar branch by abstraction ile ayrılır, yeni yol parallel run'da gölgede doğrulanır ve hedef bağımsız deploy edilebilen service'ler olur, distributed monolith değil. YARP ile gerçek route çıktısı, dry run'lı parallel run'ın yakaladığı bir fark, önce veri mi önce kod mu kararı ve service konsolidasyonu.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir; reverse proxy örneği Yarp.ReverseProxy 2.3.0 ile koşturuldu.

Önce Tahmin Et

Bookstore'un ekibi büyüdü, dört stream-aligned ekip kuruldu ve karar verildi: Bookstore.Web monolith'inden microservice'lere geçilecek. Masada iki plan var. Birincisi: "Yeni mimariyi sıfırdan yazalım. Dokuz ay sonra yeni sistem hazır olunca bir hafta sonu geçiş yaparız; o zamana kadar monolith'e yalnız kritik bug fix." İkincisi: "Monolith'in önüne bir proxy koyalım ve her ay bir yeteneği dışarı alalım; monolith her ay biraz küçülsün."

İkinci planı seçtiğini varsayalım. İlk taşınacak yetenek ne olmalı: işin kalbi olan ve en çok değişen Ordering mi, yoksa sipariş ve kargo e-postalarını gönderen Notification mı? Ve Notification'ın monolith'teki asıl tetikleyicisi bir HTTP (Hypertext Transfer Protocol) isteği değil, checkout kodunun içindeki bir method çağrısıysa, önüne koyduğun proxy bu çağrıyı nasıl route edecek?

Big Bang Rewrite Neden Kaybeder

Sıfırdan yeniden yazma planının çekiciliği anlaşılır: eski kodun bütün kusurlarından kurtulmak, temiz bir başlangıç. Pratikte üç sebeple kaybeder. Birincisi, hedef hareket eder: dokuz ay boyunca iş durmaz, monolith'e "yalnız kritik" değişiklikler girmeye devam eder ve yeni sistem hiç durmayan bir hedefi kovalar. İkincisi, değer en sona kalır: dokuz ay boyunca kullanıcıya hiçbir şey ulaşmaz ve projenin doğru gidip gitmediğini ancak geçiş haftasonunda öğrenirsin. Üçüncüsü, eski sistemin bilgisi kodun içindedir: kimsenin hatırlamadığı bir kampanya kuralı, 2019'da bir müşteri şikâyeti üzerine eklenmiş bir istisna, ödeme sağlayıcısının belgelenmemiş bir davranışı. Sıfırdan yazarken bunlar kaybolur ve geçişten sonra production'da tek tek yeniden keşfedilir.

Martin Fowler'ın strangler fig yazısı tam bu riski azaltmak için. Fowler, ilk hâlini 2004'te "Strangler Application" adıyla yazdığı ve 2024'te genişleterek yeniden yayımladığı yazıda, Ian Cartwright, Rob Horn ve James Lewis'e atıfla dört faaliyet sayıyor: ulaşmak istediğin sonucu anla, problemi küçük parçalara böl, parçaları teslim et, organizasyonu bunu sürekli yapabilecek şekilde değiştir. Sam Newman'ın Monolith to Microservices (2019) kitabı da bu geçişin pattern'lerini (strangler fig, branch by abstraction, parallel run, change data capture) ayrıntılı katalogluyor; bu yazı onların Bookstore'a uygulanışı.

Strangler Fig: Monolith'in Etrafında Büyümek

İsim, Fowler'ın Queensland yağmur ormanlarında gördüğü bir sarmaşıktan geliyor: tohum bir ağacın dalında filizlenir, köklerini aşağı salar, yıllar içinde ağacı sarar ve sonunda ağaç ölse bile sarmaşık onun şeklinde ayakta kalır. Yazılımdaki karşılığı: yeni sistem eski sistemin etrafında büyür, trafiği parça parça devralır ve eski sistem zamanla küçülür ya da tamamen emekliye ayrılır. Her aşamada sistem çalışır durumdadır ve her aşama geri alınabilir.

12-strangler-fig-tr.png

İlk adım hiçbir davranışı değiştirmez: monolith'in önüne bir reverse proxy konur ve bütün trafik oradan monolith'e gider. Bu adımın tek amacı, sonraki adımları birer route değişikliğine indirmek. Bookstore'da proxy olarak YARP (Yet Another Reverse Proxy) kullandım; route'lar configuration'da duruyor:

{
  "ReverseProxy": {
    "Routes": {
      "notifications": {
        "ClusterId": "notification",
        "Order": 1,
        "Match": { "Path": "/account/notification-preferences/{**rest}" }
      },
      "unsubscribe": {
        "ClusterId": "notification",
        "Order": 1,
        "Match": { "Path": "/unsubscribe/{**rest}" }
      },
      "everything-else": {
        "ClusterId": "legacy",
        "Order": 100,
        "Match": { "Path": "{**catch-all}" }
      }
    },
    "Clusters": {
      "notification": { "Destinations": { "d1": { "Address": "http://localhost:5702/" } } },
      "legacy": { "Destinations": { "d1": { "Address": "http://localhost:5701/" } } }
    }
  }
}
builder.Configuration.AddJsonFile("proxy.json");
builder.Services.AddReverseProxy().LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
WebApplication proxy = builder.Build();
proxy.MapReverseProxy();

Monolith (5701) ve yeni Notification service'i (5702) birer stub; proxy'ye dört istek:

/                                    -> legacy monolith handled /
/books/dune                          -> legacy monolith handled /books/dune
/account/notification-preferences    -> notification service: preferences for ada
/unsubscribe/7f3a                    -> notification service: unsubscribed 7f3a

Bildirim tercihleri sayfası ve e-postalardaki "abonelikten çık" linki artık yeni service'e gidiyor, geri kalan her şey monolith'te. Order değeri küçük olan route önce değerlendiriliyor; catch-all route'u en sona koymak, "taşınmamış her şey monolith'e" kuralının configuration'daki karşılığı. Yeni service'te bir sorun çıkarsa geri dönüş, iki route'u silip configuration'ı yeniden yüklemek. YARP'ın ayrıntıları ilerideki Backend kategorisinde, API (Application Programming Interface) gateway'lere ayrılmış "API Gateway ve YARP" serisinde; burada önemli olan, proxy'nin geçiş boyunca bir transitional architecture parçası olması. Fowler bu tür geçici kodu açıkça tanımlıyor: eski ve yeni sistemin bir arada yaşamasını sağlayan, geçiş bitince kaldırılan kod. Bookstore'da proxy, geçiş bittiğinde bir önceki yazılarda konuştuğumuz edge gateway'e dönüşebilir.

İçeriden Gelen Çağrılar: Branch by Abstraction

Proxy yalnız dışarıdan gelen istekleri yakalar. Notification'ın asıl işi ise sipariş onay e-postası ve bu e-posta, monolith'te checkout kodunun içinden, bir method çağrısıyla gönderiliyor. Bu çağrı hiçbir zaman proxy'den geçmez. Newman bu durumu strangler fig'in sınırı olarak anlatıyor ve çözüm olarak branch by abstraction'ı öneriyor.

Branch by abstraction, Fowler'ın 2014'te bliki'sinde anlattığı ve adını Paul Hammant'ın trunk-based development tartışmalarında koyduğu teknik. Adımlar şöyle: önce monolith içinde, değiştirilecek kodla onu çağıranlar arasına bir abstraction koyarsın ve bütün çağıranları ona geçirirsin; sonra aynı abstraction'ın arkasına yeni implementation'ı yazarsın; çağıranları bir flag'le birer birer yeniye geçirirsin; eski implementation kullanılmaz hâle gelince silersin. Bütün bu süre boyunca kod ana branch'te kalır ve her gün deploy edilebilir; uzun ömürlü bir feature branch yoktur, adın "branch"i bu yüzden ironik.

// Monolith'teki çağıranlar artık yalnız bu abstraction'ı biliyor
public interface INotificationSender
{
    Task<RenderedEmail> SendOrderConfirmationAsync(OrderConfirmation order, bool dryRun = false);
}

LegacyEmailSender monolith'teki mevcut kodu bu interface'in arkasına alıyor; NotificationServiceSender ise aynı interface'in yeni service'i çağıran implementation'ı. Checkout kodu hangisinin çalıştığını bilmiyor. Bu adım tek başına bile değerli: monolith'in içinde Notification'ın sınırı ilk kez açıkça çizilmiş oluyor. Modular Monolith serisinde module sınırlarını derleyiciyle koruduğumuz yaklaşım, bu sınırı çizmenin daha kalıcı hâli.

Parallel Run: Yeni Yolu Gölgede Çalıştırmak

Yeni implementation'a geçmeden önce aynı girdiyle aynı çıktıyı ürettiğinden nasıl emin olursun? Parallel run, iki implementation'ı aynı anda çalıştırıp sonuçlarını karşılaştırmak: eski yolun sonucu kullanılır, yeni yolun sonucu yalnız karşılaştırılır ve farklar raporlanır. GitHub'ın 2016'da açık kaynak yaptığı Scientist library'si (ve .NET portu Scientist.NET) bu fikrin kod seviyesindeki bilinen örneği.

E-posta göndermenin bir sorunu var: iki implementation da gerçekten gönderirse Ada iki e-posta alır. Newman bu yüzden yan etkisi olan işlemlerde yeni yolun yalnız sonucu üretmesini, yan etkiyi gerçekleştirmemesini öneriyor. Örnekteki dryRun parametresi bunun için: yeni yol e-postayı render ediyor ama göndermiyor.

// Parallel run: eski yol gerçekten gönderir, yeni yol yalnız render eder, sonuçlar karşılaştırılır
public sealed class ParallelRunSender(INotificationSender current, INotificationSender candidate) : INotificationSender
{
    public async Task<RenderedEmail> SendOrderConfirmationAsync(OrderConfirmation order, bool dryRun = false)
    {
        RenderedEmail sent = await current.SendOrderConfirmationAsync(order, dryRun);
        RenderedEmail shadow = await candidate.SendOrderConfirmationAsync(order, dryRun: true);
        if (sent != shadow)
            Console.WriteLine($"parallel run mismatch for {order.OrderNumber}: body '{sent.Body}' vs '{shadow.Body}'");
        return sent;
    }
}
legacy smtp: sent 'Order A-1001 confirmed' to ada@example.com
parallel run mismatch for A-1001: body 'Thank you! Dune, total 14.90 EUR' vs 'Thank you! Dune, total 14.90 €'

Yalnız bir e-posta gitti ve karşılaştırma gerçek bir farkı yakaladı: yeni service tutarı "EUR" yerine "€" ile yazıyor. Bu belki bilinçli bir iyileştirme, belki bir hata; ama her iki durumda da kararı geçişten önce ve production'ın gerçek verisiyle veriyorsun, müşteri şikâyetinden sonra değil. RenderedEmail bir record olduğu için != alan alan karşılaştırıyor; gerçek bir parallel run'da farklar log'a ve bir metric'e yazılır, eşleşme oranı birkaç gün boyunca izlenir ve hedef orana ulaşınca flag yeni implementation'a çevrilir.

Yeni Notification service'i monolith'in verisine ya da modeline ihtiyaç duyuyorsa, o modelin yeni service'e sızmasını önlemek için arada bir anti-corruption layer durur: monolith'in Order tablosunun yapısı değil, yeni service'in kendi OrderConfirmation modeli. DDD (Domain-Driven Design) serisinin "Context Mapping: Anti-Corruption Layer, Open Host Service ve Bounded Context'ler Arası İlişkiler" yazısında eski kargo sistemi için kurduğumuz translator'ın aynısı.

Önce Veri mi, Önce Kod mu?

Kodu ayırmak yetmez; yeni service'in verisi de kendi verisi olmalı. Newman, Monolith to Microservices'in veritabanını ayırmaya ayırdığı bölümünde iki sırayı karşılaştırıyor. Önce veriyi ayırmak (şemayı bölüp monolith'i iki veritabanıyla çalıştırmak) veri sahipliği ve transaction sınırı sorunlarını erkenden ortaya çıkarır ama kullanıcıya görünür bir fayda getirmeden uzun sürebilir. Önce kodu ayırmak (yeni service geçici olarak monolith'in veritabanını kullanır) hızlı değer üretir ama bir shared database'le başlarsın ve "geçici" kelimesi kolayca kalıcıya döner.

Pratik bir orta yol: kodu önce ayır ama veri sahipliğini ilk günden netleştir. Yeni Notification service'i, monolith'in tablolarını doğrudan okumak yerine bildirim tercihlerini kendi veritabanına taşır; monolith'ten ihtiyacı olan sipariş bilgisini event'le alır. Monolith'e dokunmadan event üretmek gerekiyorsa change data capture (CDC) bir köprü olabilir: veritabanının değişiklik log'unu okuyup event'e çeviren bir araç, monolith'in koduna hiç dokunmadan. Event'lerin güvenilir yayınlanması için outbox, Messaging ve Event-Driven Architecture serisinin "Transactional Outbox ve Inbox: Dual Write Problemini Anlamak ve Doğru Tasarlamak" yazısında. Shared database'den neden kaçınıldığını ve verinin nasıl replike edildiğini de bu serinin "Data Ownership ve Database per Service" yazısında konuştuk.

Hangi Yetenekle Başlamalı?

Önce Tahmin Et'teki ikinci soru. Ordering'le başlamak cazip, çünkü en çok değişen ve en değerli yer orası. Ama ilk taşıma aynı zamanda ekibin öğrenme projesi: proxy, pipeline, observability, contract test'ler, veri taşıma; hepsi ilk kez yapılıyor. Bunu işin kalbinde, en çok bağımlılığı olan ve hatası doğrudan gelir kaybı olan yerde öğrenmek gereksiz bir risk. Newman ilk aday için iki ekseni öneriyor: ayırmanın kolaylığı ve ayırmanın getirisi. İlk adım kolay tarafta olmalı.

Notification bu açıdan iyi bir ilk aday: neredeyse yalnız tüketici (sipariş ve kargo event'lerini dinler, kimse onun verisine bağımlı değil), hatası bir e-postanın gecikmesi (siparişin kaybolması değil), sınırı net ve ayrı bir ekibe (Customer Experience) ait. Ordering ise ekip proxy'yi, pipeline'ı ve contract'ları Notification'da oturttuktan sonra, getirisi en yüksek adım olarak gelir. Seçimi bir de Modular Monolith serisinin perspektifinden yapmak mümkün: monolith önce module'lere ayrılmışsa, hangi module'ün çıkarılacağı bir route ve bir deployment kararına indirgenir.

Distributed Monolith: Teşhis ve Çözüm

Strangler fig'in en büyük riski yolun yarısında kalmak değil, yanlış yere varmak: service'lere bölünmüş ama birlikte deploy edilmek zorunda olan bir sistem. Mimariye Giriş serisinin "Monolith'i Doğru Anlamak: Single-Process, Modular ve Distributed Monolith" yazısında tanımını yaptık; burada teşhis ve tedavi:

BelirtiNasıl fark edilirÇözüm yönü
Lockstep deployRelease notunda "önce X, sonra Y, aynı gece" adımlarıExpand-contract, contract test, can-i-deploy
Shared databaseİki service'in aynı tabloya yazması; şema değişikliği iki ekibi bekletiyorTek sahip, diğerine API ya da event'le replike edilmiş kopya
Shared business library"Bookstore.Common" güncellenince bütün service'ler yeniden derleniyorİş kuralını sahibi olan service'e taşı; ortak kodu yalnız teknik altyapıya indir
Uzun sync zincirBir istek beş service'i sırayla çağırıyor; biri düşünce hepsi düşüyorSınırı yeniden çiz ya da akışı event'le asenkron yap
Chatty service'lerTek bir ekran için aynı iki service arasında onlarca çağrıSınır yanlış: iki service muhtemelen tek bir service

Tablonun son satırı önemli bir şeyi söylüyor: bazen doğru tedavi service'leri birleştirmek. Sync zincir sorunu serinin "Microservice İletişim Stilleri" yazısında, sınır çizme soruları "Service Boundary'lerini Çizmek" yazısında. Tedavinin ortak paydası şu: her belirti bir coupling'in network'e taşınmış hâli ve coupling network'te monolith'tekinden çok daha pahalı.

Geri Dönüş: Service Konsolidasyonu

Bir service'i monolith'e ya da başka bir service'e geri katmak başarısızlık değil, bir mimari karar. Serinin "Microservice'lerin Bedeli: Ne Zaman Gereksiz, Ne Zaman Kaçınılmaz?" yazısında Prime Video vakasında gördüğümüz şey de buydu: bir alt sistemin parçalarını birleştirmek, o alt sistemin ihtiyacına daha uygun çıktı. Bookstore'da benzer bir an gelebilir: Inventory ve Shipping'i ayırdıktan sonra her değişikliğin ikisini birlikte gerektirdiğini görürsen, aynı ekibe (Fulfillment) ait bu iki service'i tek bir service'te iki module olarak birleştirmek, bağımsız deploy'un kazanamadığı bir bedeli ortadan kaldırır.

Konsolidasyonu kolaylaştıran şey, geçiş boyunca kurduğun disiplinin kendisi: her service'in contract'ı belli, verisi ayrı, sınırı net. Birleştirmek, iki service'in kodunu tek bir deployment'a almak ve aralarındaki HTTP çağrısını bir in-process çağrıya çevirmek demek; Modular Monolith serisinin anlattığı yapı tam olarak bu. Strangler fig'deki proxy de burada işe yarıyor: iki route aynı cluster'a çevrilir ve client'lar hiçbir şey fark etmez.

Sık Düşülen Tuzaklar

Proxy'yi koyup durmak

İlk adım davranışı değiştirmediği için güvenli ve kolay; ama tek başına hiçbir değer üretmez. Proxy konduğu sprint'te ilk route'un ne zaman taşınacağı da planlanmalı.

İlk taşımayı işin kalbinde yapmak

İlk taşıma bir öğrenme projesi. Ordering ile başlamak, ekibin bütün altyapı hatalarını en pahalı yerde yapması demek.

Kodu taşıyıp veriyi bırakmak

Yeni service monolith'in tablolarına bağlı kalırsa, bağımsız deploy edilemez ve monolith'in şeması dondurulmuş olur. Veri sahipliğinin bir tarihi olmalı.

Parallel run'da yan etkiyi iki kez yapmak

İki implementation da e-posta gönderirse, ödeme alırsa ya da stok düşerse, karşılaştırma bir production incident'ına döner. Yeni yol dry run çalışır.

Bitişi tanımlamamak

Strangler fig'in sonunda monolith'in tamamen yok olması gerekmez. Neyin taşınacağını ve neyin monolith'te (artık küçük ve kararlı bir parça olarak) kalacağını baştan konuşmak, hiç bitmeyen bir geçişin önüne geçer.

Küçük Bir Deneme

★☆☆ Örnekteki proxy.json'da unsubscribe route'unun Order değerini 200 yap ve dört isteği tekrar gönder. /unsubscribe/7f3a nereye gidiyor ve neden?

★★☆ ParallelRunSender'ı, eşleşmeyen sonuçları bir sayaçta toplayıp 100 siparişten sonra eşleşme oranını yazacak şekilde değiştir. Yeni implementation'a geçiş için hangi oranı ve kaç günlük gözlemi yeterli sayarsın?

★★★ Bookstore için bir strangler fig yol haritası yaz: Notification'dan sonra hangi yetenek, hangi sırayla taşınır? Her adım için proxy'de hangi route değişir, hangi veri kimin olur, hangi contract test eklenir ve geri dönüş adımı nedir? Haritanın sonunda monolith'te ne kalıyor?

Bu yazıyla Microservices serisi bitiyor. On iki yazı boyunca microservice'in ne olduğundan, bedelinden ve sınırlarından başlayıp iletişime, contract'lara, edge'e, discovery'ye, güvenliğe, işletmeye ve geçişe kadar her kararı trade-off'larıyla konuştuk. Ortak bir ders varsa o da şu: microservices bir hedef değil, belirli bir ölçekte ve belirli bir ekip yapısında bağımsız deploy edebilmenin bedeli. O bedeli ödemeye değer olduğunda, buradaki kararları uçtan uca kodla görmek için ilerideki Backend kategorisindeki ".NET ile Mikroservis Uygulaması" serisi Bookstore'u gerçekten parçalara ayıracak.