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

Legacy Kodda Dependency Kırmak: Sprout, Wrap, Extract Interface ve Static Bağımlılıklar

Mert Özen 14 Kas 2026 11 dk 1 görüntülenme
Legacy Kodda Dependency Kırmak: Sprout, Wrap, Extract Interface ve Static Bağımlılıklar

Testi olmayan legacy bir class'a yeni davranış eklemenin güvenli yolu, önce değişiklik noktası ile test noktasını bulup yalnız aradaki engeller için seam açmaktır: yeni kural sprout ile ayrı yerde test edilir, constructor parametrelenir, static çağrı ince bir wrapper'a, zaman TimeProvider'a alınır. Yazıda SQL Server'a takılan bir OrderImportJob'da bu adımlar uygulanıyor; testler job'un bilinmeyen bir varsayımını ve sprout'u bağlayan satırdaki hatayı gerçek çıktılarla yakalıyor.

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.TimeProvider.Testing 10.11.0 ile derlenip denenmiştir; veritabanı erişimi Microsoft.Data.SqlClient 7.1.2 ile SQL Server içindir.

Önce Tahmin Et: Bu Job'a Test Yazabilir misin?

Bookstore'a anlaşmalı kitapçılardan sipariş dosyaları geliyor. OrderImportJob beş dakikada bir gelen kutusu klasörüne bakıyor, her CSV (virgülle ayrılmış metin; burada ayraç noktalı virgül) dosyasını okuyup satırları SQL Server'daki dbo.ImportedOrders tablosuna yazıyor, dosyayı done klasörüne taşıyor ve operasyon ekibine bir özet e-postası atıyor:

public class OrderImportJob : BackgroundService
{
    private readonly string _inbox;
    private readonly string _connectionString;

    public OrderImportJob(IConfiguration configuration)
    {
        _inbox = configuration["Import:Inbox"]!;
        _connectionString = configuration.GetConnectionString("Bookstore")!;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            foreach (string file in Directory.GetFiles(_inbox, "*.csv"))
                ImportFile(file);

            await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
        }
    }

    public void ImportFile(string path)
    {
        int imported = 0;
        int failed = 0;
        using SqlConnection connection = new(_connectionString);
        connection.Open();

        // Başlık satırı: OrderNo;CustomerEmail;Isbn;Quantity;UnitPrice;OrderedAt
        foreach (string line in File.ReadLines(path).Skip(1))
        {
            string[] parts = line.Split(';');
            try
            {
                using SqlCommand command = new(
                    "INSERT INTO dbo.ImportedOrders (OrderNo, CustomerEmail, Isbn, Quantity, UnitPrice, OrderedAt) " +
                    "VALUES (@orderNo, @email, @isbn, @quantity, @unitPrice, @orderedAt)", connection);
                command.Parameters.AddWithValue("@orderNo", parts[0]);
                // ... diğer parametreler
                command.ExecuteNonQuery();
                imported++;
            }
            catch (Exception)
            {
                failed++;
            }
        }

        string archive = Path.Combine(_inbox, "done", DateTime.Now.ToString("yyyyMMdd-HHmmss") + "-" + Path.GetFileName(path));
        File.Move(path, archive);
        SmtpHelper.Send("ops@bookstore.example", "Order import finished",
            $"{Path.GetFileName(path)}: {imported} imported, {failed} failed");
    }
}

Yeni talep: sipariş tarihi 30 günden eski satırlar içeri alınmasın, özet e-postasında ayrıca listelensin. Kural basit, ama onu test ederek eklemek istiyoruz. ImportFile için yazacağın ilk test neye takılır? Kaç tane dış dünya bağımlılığı sayabiliyorsun?

Cevap: İlk Test SQL Server'a Gidiyor

Denedim. Geçici bir klasöre tek satırlık bir CSV yazan ve ImportFile'ı çağıran test, daha ilk satıra gelmeden bağlantıda kaldı:

failed Bookstore.Import.Tests.FirstAttemptTests.ImportFile_imports_a_valid_row (4s 566ms)
  Xunit.MicrosoftTestingPlatform.XunitException: Microsoft.Data.SqlClient.SqlException : A network-related or instance-specific error occurred while establishing a connection to SQL Server. ...

Dört buçuk saniye bekleyip düşen bir test. Bağlantı başarılı olsaydı da durum iyi değildi: test gerçek bir tabloya satır yazacak, dosyayı taşıyacak ve SmtpHelper üzerinden gerçek bir SMTP (e-posta gönderme protokolü) sunucusuna bağlanmaya çalışacaktı. Bağımlılık listesi uzun: new SqlConnection, static SmtpHelper.Send, DateTime.Now, dosya sistemi ve IConfiguration. Hiçbiri constructor'dan gelmiyor; hepsi method'un içinde doğuyor.

Michael Feathers'ın Working Effectively with Legacy Code kitabındaki legacy kod tanımı tam bu duruma uyuyor: testi olmayan kod. Kitabın asıl katkısı ise testi olmayan kodu testlenebilir hâle getirirken kullanılan küçük ve güvenli teknikleri adlandırması. Bu yazıda o tekniklerin bir kısmını, kitabın sırasını değil kendi job'umuzun ihtiyacını izleyerek uyguluyoruz.

Değişiklik Noktası ve Test Noktası

Her şeyi aynı anda testlenebilir yapmaya çalışmak yerine iki soruyla başlıyoruz. Değişikliği nereye yapacağız? Satır döngüsünün içine, satırı veritabanına yazmadan önce. Değişikliğin sonucunu nereden gözlemleyebiliriz? Yazılan satırlardan ve e-postanın gövdesinden. İlk soru değişiklik noktasını, ikincisi test noktasını veriyor. Dependency kırma işi yalnız bu iki nokta arasındaki engelleri kaldırmak için yapılır; job'un geri kalanı şimdilik olduğu gibi kalabilir.

Engelleri kaldırmanın aracı seam: davranışı, o noktadaki kodu düzenlemeden değiştirebildiğin yer. Constructor parametresi, interface, virtual method bunun örnekleri. Kavramın kendisi Unit Test serisinin "Test Edilebilir Tasarım: Dependency Injection, Seam'ler, Functional Core ve Humble Object" yazısında anlatıldı; burada seam'leri legacy bir class'a adım adım nasıl açtığımıza bakıyoruz.

Sprout: Yeni Davranışı Ayrı Yerde Büyütmek

Yeni kuralı job'un içine yazarsak, onu test etmek için önce bütün job'u testlenebilir yapmamız gerekir. Daha hızlı yol, kuralı job'dan bağımsız, saf bir yerde büyütüp job'a tek satırla bağlamak. Feathers buna sprout diyor; TDD (Test-Driven Development; önce testi yazıp kodu ona göre geliştirme) serisinin "Bug Fix ve Legacy Kodda TDD: Önce Kırmızı Test, Characterization ve Sprout ile Yeni Kod" yazısında test-first tarafını gördük. Önce kural:

public static class ImportRules
{
    public static readonly TimeSpan MaxAge = TimeSpan.FromDays(30);

    public static bool IsStale(DateTime orderedAt, DateTime now) => now - orderedAt > MaxAge;
}

[Fact]
public void An_order_from_exactly_30_days_ago_is_not_stale() =>
    Assert.False(ImportRules.IsStale(Now.AddDays(-30), Now));

[Fact]
public void An_order_older_than_30_days_is_stale() =>
    Assert.True(ImportRules.IsStale(Now.AddDays(-30).AddMinutes(-1), Now));

Sonra özet. Sayaçları ve eski siparişleri toplayan, e-posta gövdesini üreten küçük bir class; eskiyen satır yoksa gövde eskisiyle harfi harfine aynı:

public sealed class ImportSummary(string fileName)
{
    private readonly List<string> _stale = [];

    public int Imported { get; private set; }
    public int Failed { get; private set; }
    public IReadOnlyList<string> Stale => _stale;

    public void RecordImported() => Imported++;
    public void RecordFailed() => Failed++;
    public void RecordStale(string orderNo) => _stale.Add(orderNo);

    public string MailBody() => _stale.Count == 0
        ? $"{fileName}: {Imported} imported, {Failed} failed"
        : $"{fileName}: {Imported} imported, {Failed} failed, {_stale.Count} skipped as stale ({string.Join(", ", _stale)})";
}

İki class'ın dört testi var ve job'a hâlâ dokunulmadı. Bağlantı commit'i job'da birkaç satırı değiştiriyor:

+        ImportSummary summary = new(Path.GetFileName(path));
+            if (DateTime.TryParse(parts[5], CultureInfo.InvariantCulture, out DateTime orderedAt)
+                && ImportRules.IsStale(orderedAt, DateTime.Now))
+            {
+                summary.RecordStale(parts[0]);
+                continue;
+            }
-                imported++;
+                summary.RecordImported();
-                failed++;
+                summary.RecordFailed();
+        SmtpHelper.Send("ops@bookstore.example", "Order import finished", summary.MailBody());

Bu satırları test eden bir test yok ve DateTime.Now hâlâ orada. Bu bilinçli bir kabul: kural test edildi, job'a bağlantısı küçük ve gözle okunabilir. Ama "gözle okunabilir" bir güvence değil; birazdan bu satırlardan birinin bir hata taşıdığını göreceğiz.

Constructor'ı Parametrelemek ve TimeProvider

Job'un bütününü test edebilmek için zamanı, e-postayı ve veritabanını dışarıdan verebilmemiz gerekiyor. İlk adım zaman. .NET 8 ile gelen TimeProvider, saati soyutlamanın standart yolu. Mevcut constructor'ı değiştirmiyoruz; yeni bir constructor ekleyip eskisini ona yönlendiriyoruz:

public OrderImportJob(IConfiguration configuration)
    : this(configuration, TimeProvider.System)
{
}

internal OrderImportJob(IConfiguration configuration, TimeProvider time)
{
    _inbox = configuration["Import:Inbox"]!;
    _connectionString = configuration.GetConnectionString("Bookstore")!;
    _time = time;
}

DateTime.Now çağrıları _time.GetLocalNow() oluyor; döngüdeki bekleme de Task.Delay(TimeSpan.FromMinutes(5), _time, stoppingToken) overload'ıyla aynı saate bağlanıyor, böylece testte beş dakika gerçekten beklenmeden ilerletilebiliyor. Yeni constructor internal; test projesine <InternalsVisibleTo Include="Bookstore.Import.Tests" /> ile açılıyor. Üretimdeki bağlama değişmedi: Microsoft'un DI (dependency injection; bağımlılıkları dışarıdan veren mekanizma) container'ı yalnız public constructor'lara bakar. AddHostedService<OrderImportJob>() ile kurulan bir host'ta job'un hâlâ çözüldüğünü küçük bir programla doğruladım; GetServices<IHostedService>().OfType<OrderImportJob>().Count() çıktısı 1.

İki constructor'lı bu yapı geçici bir çirkinlik. Bütün bağımlılıklar DI'a kaydedilince tek bir public constructor kalacak; ama bu, bugün yapmak zorunda olduğumuz bir iş değil.

Static Çağrıyı İnce Bir Wrapper'a Almak

SmtpHelper.Send static; araya giremeyiz. Kod tabanında onu çağıran başka yerler de var, o yüzden SmtpHelper'ı değiştirmek yerine önüne ince bir instance katmanı koyuyoruz:

public interface IMailSender
{
    void Send(string to, string subject, string body);
}

// SmtpHelper'ı kullanan diğer kodlar olduğu gibi kalıyor; bu class yalnız ince bir köprü.
public sealed class SmtpMailSender : IMailSender
{
    public void Send(string to, string subject, string body) => SmtpHelper.Send(to, subject, body);
}

Job artık IMailSender alıyor; public constructor ona new SmtpMailSender() veriyor. Wrapper'ın içinde hiçbir mantık yok, bu yüzden testine de ihtiyaç yok. Static helper'ı ileride tamamen kaldırmak, onu çağıran her yeri aynı wrapper'a taşıyarak ayrı bir iş olarak yapılır.

Extract Interface: Bağlantının Ne Zaman Açıldığı da Davranıştır

Son engel SqlConnection. İlk akla gelen interface tek method'luk bir IOrderWriter.Insert(order). Ama eski kodda bağlantı dosya başına bir kez ve satırlar işlenmeden önce açılıyor. Bağlantı açılamazsa exception job'dan dışarı çıkıyor, dosya taşınmıyor, e-posta gitmiyor; bir sonraki turda dosya yeniden deneniyor. Insert her çağrıda kendi bağlantısını açsaydı, veritabanı kapalıyken her satırın exception'ı döngüdeki catch'e düşer, dosya "0 imported, 3 failed" diye arşive taşınırdı ve siparişler kaybolurdu. Bağlantının ne zaman açıldığı davranışın bir parçası; interface bu sırayı korumalı:

public interface IOrderStore
{
    // Bağlantı burada açılır: açılamazsa dosyadaki hiçbir satır işlenmeden exception fırlar.
    IOrderBatch OpenBatch();
}

public interface IOrderBatch : IDisposable
{
    void Insert(ImportedOrder order);
}

SqlOrderStore, job'daki SQL kodunu olduğu gibi taşıyor. Job'un ImportFile'ı da using IOrderBatch batch = _store.OpenBatch(); ile başlıyor. İki küçük interface, iki gerçek ihtiyaç; daha fazlası şimdilik gereksiz.

Testler Geliyor: Fake'ler ve FakeTimeProvider

Bütün seam'ler açıldı. Test, bellek içi bir store, gönderilen e-postaları kaydeden bir mail sender ve Microsoft.Extensions.TimeProvider.Testing paketindeki FakeTimeProvider ile job'u kuruyor:

public OrderImportJobTests()
{
    _time.SetLocalTimeZone(TimeZoneInfo.Utc);
    _time.SetUtcNow(new DateTimeOffset(2026, 11, 14, 8, 0, 0, TimeSpan.Zero));
}

[Fact]
public void ImportFile_imports_valid_rows_and_reports_the_rest()
{
    string csv = WriteCsv(
        "A-1001;ada@example.com;9780441013593;1;450.00;2026-11-10T09:30:00",
        "A-0950;alan@example.com;9780141439518;2;180.00;2026-09-01T12:00:00",
        "A-1002;grace@example.com;9780062316097;x;300.00;2026-11-12T15:45:00");

    Job().ImportFile(csv);

    Assert.Equal(["A-1001"], _store.Orders.Select(o => o.OrderNo));
    Assert.Equal("orders.csv: 1 imported, 1 failed, 1 skipped as stale (A-0950)", _mail.Sent.Single().Body);
    Assert.True(File.Exists(Path.Combine(_inbox, "done", "20261114-080000-orders.csv")));
}

İlk koşu kırmızı, ama beklediğim sebepten değil:

System.IO.DirectoryNotFoundException : Could not find a part of the path '.../inbox.../done/20261114-080000-orders.csv'.

Job done klasörünü kendisi oluşturmuyor; üretimde klasör zaten var olduğu için bu hiç görünmemiş. Bu, characterization testlerinin klasik bir anı: koda dokunmadan önce onun bilmediğin bir varsayımını öğreniyorsun. Şimdilik davranışı değiştirmiyor, test kurulumunda klasörü oluşturuyor ve varsayımı bir yorumla kayda geçiriyoruz. Test yeşil.

Sonra eksik kolonlu bir satır için test ekledim. Eski kod böyle bir satırı, parts[3]'e eriştiği an try bloğunun içinde patlayıp "failed" sayıyordu:

failed Bookstore.Import.Tests.OrderImportJobTests.A_row_with_missing_columns_counts_as_failed (0ms)
  Xunit.MicrosoftTestingPlatform.XunitException: System.IndexOutOfRangeException : Index was outside the bounds of the array.
    at Bookstore.Import.OrderImportJob.ImportFile(String path) in src/Bookstore.Import/OrderImportJob.cs:51

Satır 51, sprout'u bağladığım satır: parts[5]'e try'ın dışında erişiyor. Eksik kolonlu tek bir satır artık bütün dosyanın işlenmesini durduruyor; eski davranışta yalnız o satır "failed" sayılıyordu. Testi olmayan tek satır, hatayı taşıyan satır oldu. Düzeltme kendi commit'inde, koşula bir parts.Length > 5 kontrolü eklemek.

Wrap: Eski Akışa Dokunmadan Etrafına Eklemek

Talebin ikinci yarısı: eskiyen siparişler varsa satış ekibine ayrı bir e-posta gitsin. Bu, mevcut akışın içine değil, sonuna eklenen bir davranış. Wrap tekniğinde eski method'un gövdesi yeni bir private method'a taşınır, eski method'un adı yeni davranışla birlikte onu çağırır:

public void ImportFile(string path)
{
    ImportSummary summary = ImportAndArchive(path);

    // Yeni davranış: eski akışa dokunmadan, onun etrafına eklendi.
    if (summary.Stale.Count > 0)
        _mail.Send("sales@bookstore.example", "Stale orders skipped", string.Join(Environment.NewLine, summary.Stale));
}

private ImportSummary ImportAndArchive(string path)
{
    // ... eski gövde, sonunda: return summary;
}

Sprout yeni davranışı yeni bir yerde büyütür ve eski koda bir çağrı ekler; wrap ise eski davranışı olduğu gibi bir kenara koyup yenisini onun önüne ya da arkasına yerleştirir. İkisinin ortak noktası, yeni kodla eski kodu aynı satırlarda karıştırmamak. Bütün yolculuk on commit:

$ git log --oneline
e78aa99 feat: report stale orders to sales (wrap ImportFile)
7001c6a fix: stale check no longer throws on rows with missing columns
33aeaf0 test: OrderImportJob with in-memory store, recorded mail and FakeTimeProvider
9b6cd47 refactor: extract IOrderStore from OrderImportJob
6d5493a refactor: send mail through IMailSender instead of static SmtpHelper
269c727 refactor: parameterize OrderImportJob with TimeProvider
7063b57 feat: skip orders older than 30 days in OrderImportJob
c5df862 feat: sprout ImportSummary (not wired yet)
20ef4d0 feat: sprout ImportRules.IsStale (not wired yet)
76bc3f4 legacy: OrderImportJob

Testler yedi tane ve bir saniyenin altında çalışıyor. Hiçbiri veritabanına, SMTP sunucusuna ya da gerçek saate dokunmuyor. SqlOrderStore'un kendisi hâlâ testsiz; onun için doğru araç gerçek bir SQL Server'a karşı çalışan bir integration test, ilerideki Integration ve E2E (uçtan uca) Test serisinin konusu.

Sık Düşülen Tuzaklar

Test edebilmek için her şeyi public yapmak. Yeni constructor'ı public yapsaydık, test için açılan giriş üretim API'sinin bir parçası olurdu: başka bir geliştirici onu doğrudan çağırabilir, DI container'ı da public constructor'lar arasından parametrelerini çözebildiği en uzun olanı seçtiği için ileride yapılan bir servis kaydı hangi constructor'ın kullanılacağını sessizce değiştirebilir. internal ve InternalsVisibleTo, test seam'ini üretim API'sine sızdırmadan açar. Aynı şey private method'lar için de geçerli: bir private method'u test etmek için public yapma isteği, genelde onun ayrı bir class'a sprout edilmesi gerektiğinin işaretidir.

İlk adımda büyük bir interface hiyerarşisi kurmak. IOrderRepository, IUnitOfWork, IImportService, IFileSystem ve IClock'u aynı gün eklemek, testten önce mimariyi değiştirir ve büyük bir diff üretir. Burada yalnız test noktasıyla değişiklik noktası arasındaki üç engel için üç seam açtık; dosya sistemine dokunmadık, çünkü geçici klasörlerle test etmek yeterince ucuz.

Extract interface yaparken zamanlamayı kaybetmek. Bağlantının dosya başına açılması gibi yan etkilerin sırası, interface'in şeklini belirler. Her satır için kendi bağlantısını açan bir Insert, veritabanı kesintisini "siparişler hatalı" mesajına çevirirdi.

Bağlantı satırlarını "nasılsa basit" diye test etmemek. Sprout'un değeri yeni kuralı izole test etmek, ama kuralın eski koda bağlandığı satır da kod. Bu yazıdaki tek hata tam orada çıktı ve ancak job bütün olarak test edilebilir hâle gelince yakalandı.

Çirkin ara adımı kalıcı sanmak. İki constructor, SmtpHelper'ı saran bir köprü, InternalsVisibleTo: bunlar legacy kodu testlenebilir yapmak için ödenen geçici bedeller. Bir issue'ya yazılmazlarsa kalıcı olurlar.

Küçük Bir Deneme

★☆☆ Kendi kod tabanında DateTime.Now ya da DateTime.UtcNow kullanan bir class bul. Ona TimeProvider alan internal bir constructor ekle, eskisini TimeProvider.System ile ona yönlendir ve FakeTimeProvider ile tarihe bağlı bir test yaz.

★★☆ Static bir helper'ı (e-posta, log, cache) çağıran bir class seç. Önce değişiklik noktasını ve test noktasını yaz, sonra yalnız bu ikisinin arasındaki engeller için seam aç. Kaç seam'e ihtiyacın oldu, kaçını açmadan bıraktın?

★★★ Bu yazıdaki IOrderStore yerine her çağrıda bağlantı açan tek method'luk bir IOrderWriter tasarla ve veritabanı kapalıyken davranışın nasıl değiştiğini gösteren bir test yaz. Sonra o testi IOrderStore tasarımında çalıştır.

Bu yazıda dependency'leri tek bir class'ın içinden kırdık. Bir sonraki yazıda daha büyük bir değişikliğe geçiyoruz: senkron ve static çağrılarla çalışan LegacyShippingClient'ı modern bir typed HttpClient tabanlı client'la değiştirmek. Değişiklik tek bir commit'e sığmayacak; onu Mikado Method ile planlayıp branch by abstraction ile trunk üzerinde yürüteceğiz.