Software · Aug 8, 2025 · 10 dk okuma
Abstraction Nedir? Her Şeyi Interface Yapmak Neden İyi Tasarım Değildir?
İyi abstraction, sınıf sayısını artırmak değil, değişimin etkisini doğru yerde durdurmaktır. Interface, abstract class, Dependency Inversion ve YAGNI üzerinden gerçek proje kararlarını inceliyoruz.

Abstraction, yazılımda bir bileşenin nasıl çalıştığını bilmeden onunla çalışabilmeyi sağlayan tasarım kararıdır. İyi bir abstraction, implementation ayrıntılarını gizlerken tüketicisine anlamlı ve güvenilir bir contract sunar. Fakat her class için interface oluşturmak, her dependency'yi bir katmanın arkasına koymak veya bütün davranışları abstract class üzerinden tanımlamak tek başına iyi tasarım değildir. Bunlar doğru koşullarda işe yarayan araçlardır; koşullar oluşmadığında yazılımın anlaşılmasını ve değiştirilmesini zorlaştırırlar.
Bu ayrım özellikle büyüyen projelerde önem kazanır. Başlangıçta düzenli görünen bir mimari, zamanla onlarca interface, birbirini çağıran service katmanları ve gerçekte tek implementation'a sahip soyutlamalar üretebilir. Yeni bir özellik eklemek için beş dosyanın değiştirilmesi gerekirken hiçbir iş kuralı değişmemiş olabilir. Asıl mesele kaç interface bulunduğu değil, sistemde hangi değişikliklerin birbirinden bağımsız gerçekleşebildiğidir.
Abstraction, encapsulation ile aynı şey değildir
Abstraction ve encapsulation çoğu zaman birlikte kullanılır, ancak farklı problemleri çözerler. Abstraction, bir bileşenin dışarıya hangi davranışı sunduğunu belirler. Encapsulation ise bileşenin kendi state'ini ve implementation ayrıntılarını hangi sınırlar içinde tuttuğunu düzenler.
Bir BankAccount class düşünelim. Deposit(amount) ve Withdraw(amount) metotlarını sunarken balance değerinin doğrudan değiştirilmesine izin vermiyorsa encapsulation uygular. Böylece para çekme işleminin gerekli kontrollerden geçmesini sağlayabilir. Bu class için ayrıca IBankAccount interface oluşturulması gerekmez.
Abstraction ise farklı bir soruya cevap verir: Hesapla çalışan kodun hangi bilgileri bilmesi gerekir? Örneğin bir ödeme süreci, hesabın veritabanında nasıl saklandığını bilmeden belirli bir tutarın tahsil edilmesini isteyebilir. Burada uygun bir abstraction, hesabın bütün metotlarını kopyalayan interface değil, ödeme işleminin ihtiyaç duyduğu davranışı tanımlayan bir contract olabilir.
İki kavram arasındaki fark önemlidir çünkü private field kullanmak encapsulation sağlar ama mutlaka anlamlı bir abstraction oluşturmaz. Benzer şekilde bir interface tanımlamak implementation ayrıntılarını otomatik olarak gizlemez. Interface üzerinden konuşan kod hâlâ veritabanı transaction'larını, HTTP status code'larını veya belirli bir vendor'ın hata türlerini biliyorsa abstraction sınırı beklenen işi yapmıyor olabilir.
İyi abstraction'ın temel özelliği bilgi saklamasıdır. Buradaki bilgi yalnızca bir algoritmanın içindeki satırlar değildir. Bir dependency'nin hangi koşullarda başarısız olacağı, hangi kaynakları kullandığı ve hangi değişikliklerin consumer kodunu etkilememesi gerektiği de tasarımın parçasıdır.
Örneğin IStorage interface üzerinden dosya kaydedildiğini düşünelim. Consumer, kaydetme işleminin atomik olduğunu varsayıyorsa ancak implementation bu garantiyi vermiyorsa interface teknik olarak temiz görünmesine rağmen davranışsal contract eksiktir. Method signature tek başına yeterli değildir.
Her class için interface oluşturmak Dependency Inversion değildir
Popüler açıklamalardan biri, SOLID ilkelerine uymak için her service'in bir interface üzerinden çağrılması gerektiğini söyler. Bu yaklaşım Dependency Inversion Principle'ın asıl mekanizmasını kaçırır: Önemli olan dependency'nin interface üzerinden kurulması değil, abstraction'ın hangi tarafın ihtiyacına göre şekillendiğidir.
Dependency Inversion Principle, yüksek seviyeli policy kodunun düşük seviyeli implementation ayrıntılarına bağımlı olmamasını hedefler. İki tarafın da uygun abstraction'lara dayanması beklenir. Buradaki yüksek seviye, class inheritance hiyerarşisindeki konumu değil, sistemin iş kararlarını ifade eden kodu anlatır.
Bir OrderService doğrudan SqlOrderRepository kullanıyorsa sipariş iş akışı belirli bir persistence implementation'ına bağlanmış olabilir. Bu bağımlılık iş mantığını test etmeyi, storage değiştirmeyi veya transaction sınırlarını yeniden düzenlemeyi zorlaştırıyorsa araya bir contract koymak anlamlıdır.
Ancak IOrderService interface oluşturup tek implementation olan OrderService class'ını aynı metotlarla tanımlamak sorunu kendiliğinden çözmez. Controller IOrderService çağırıyor olabilir, fakat interface yalnızca class'ın public metotlarını tekrar ediyorsa yeni bir tasarım sınırı oluşmamıştır.
Daha iyi yaklaşım, abstraction'ı onu kullanan kodun ihtiyacından türetmektir. Sipariş iş akışı yalnızca siparişi yüklemek ve kaydetmek istiyorsa ilgili port bu davranışları sunabilir. Veritabanındaki bütün CRUD metotlarını açmak zorunda değildir.
Burada port, uygulamanın dış dünyadan ihtiyaç duyduğu davranışı temsil eden contract'tır. Adapter ise bu contract'ı belirli bir teknolojiyle gerçekleştiren implementation'dır. OrderRepository port'unun SQL adapter ile çalışması, iş akışının SQL sorgularını bilmesini gerektirmez.
Yine de her veritabanı erişimi için port oluşturmak zorunlu değildir. Basit bir raporlama endpoint'i, belirli bir SQL sorgusunu çalıştırıp sonucu doğrudan döndürüyor olabilir. Bu sorgunun kendisi ürünün ihtiyaç duyduğu davranışsa onu generic repository arkasına gizlemek sorgunun anlaşılmasını zorlaştırabilir.
Özellikle IRepository<T> gibi generic contract'lar dikkat ister. GetAll, Find, Update ve Delete metotlarını her entity için standartlaştırmak ilk bakışta tekrarları azaltır. Fakat farklı aggregate'lerin farklı yükleme kuralları, transaction ihtiyaçları ve sorgu özellikleri olabilir.
Sonuçta abstraction, domain'in gerçek ihtiyaçlarını ifade etmek yerine persistence teknolojisinin ortak paydasını taklit etmeye başlar. Bu durumda Dependency Inversion uygulanmış görünür, ancak yüksek seviyeli kod hâlâ düşük seviyeli storage modelinin sınırlarıyla düşünmek zorundadır.
Interface ile abstract class farklı sözler verir
Interface, bir bileşenin hangi davranışı sağlayacağını tanımlamak için uygundur. Abstract class ise ortak davranışın ve gerektiğinde ortak state'in bir inheritance ilişkisi içinde paylaşılmasını sağlar. İkisini yalnızca farklı syntax seçenekleri olarak değerlendirmek yanlış tasarım kararlarına yol açar.
Bir ödeme sisteminde IPaymentGateway interface düşünelim. Sistem kart ödemesini farklı provider'lar üzerinden gerçekleştirebiliyor olsun. Uygulamanın ihtiyacı ödeme isteği göndermek ve sonucu değerlendirmektir. Provider SDK'larının initialization ayrıntıları, HTTP client kullanımları ve authentication yöntemleri bu contract'ın dışında kalabilir.
Bu durumda interface anlamlıdır çünkü farklı implementation'lar aynı iş beklentisini karşılayabilir. Ancak aynı method signature'a sahip olmak yeterli değildir. Bir provider synchronous olarak kesin sonuç döndürürken diğeri önce pending durumuna geçiyorsa contract bu farkı açıkça temsil etmelidir.
Aksi hâlde consumer, bütün provider'ların aynı davranacağını varsayar ve gerçek dünya farkları beklenmedik yerlerde ortaya çıkar. Interface kullanılmıştır ama substitutability sağlanmamıştır. Substitutability, bir implementation'ın contract'ın vaat ettiği davranışı bozmadan diğerinin yerine geçebilmesidir.
Abstract class farklı bir durumda değerlidir. Birden fazla payment adapter aynı retry sınıflandırmasını, response parsing mantığını veya ortak bir request hazırlama sürecini paylaşıyorsa ortak implementation taşımak düşünülebilir. Fakat bunun gerçekten kalıcı bir inheritance ilişkisi olup olmadığı sorgulanmalıdır.
Örneğin BasePaymentGateway içinde authentication, logging, retry, timeout ve hata dönüştürme davranışlarının tamamını toplamak cazip görünebilir. Zamanla provider'ların farklı retry kuralları veya authentication yöntemleri ortaya çıktığında base class koşullarla dolmaya başlar.
Bu, inheritance'ın yarattığı coupling'dir. Alt class'lar yalnızca public contract'a değil, üst class'ın lifecycle kararlarına ve protected metotlarına da bağımlıdır.
Ortak davranış bağımsız bir nesneye çıkarılabiliyorsa composition çoğu zaman daha esnektir. Örneğin retry politikasını ayrı bir bileşen olarak kullanmak, ödeme adapter'larının tek bir inheritance hiyerarşisine bağlanmasını gerektirmez.
Abstract class seçimini savunabileceğim durum, alt türlerin gerçekten ortak bir algoritmanın kontrollü varyasyonlarını uygulamasıdır. Template Method pattern buna örnektir: Base class algoritmanın sırasını belirler, alt class'lar belirli adımları özelleştirir. Bunun güvenli olması için değişmeyen adımların gerçekten ortak olması gerekir.
Karar verirken basit bir ayrım yararlıdır. Bağımsız implementation'ların karşılaması gereken davranış söz konusuysa interface düşünülür. Ortak lifecycle ve implementation'ın korunması gerekiyorsa abstract class değerlendirilebilir. Yalnızca tekrar eden birkaç satır kod varsa önce composition veya sıradan bir yardımcı bileşen düşünülmelidir.
Gerçek bir sipariş akışında abstraction sınırı
Bir e-ticaret backend'inde sipariş oluşturma akışını ele alalım. PlaceOrderHandler sepeti doğruluyor, stok rezervasyonu yapıyor, ödeme başlatıyor ve siparişi kaydediyor. Bu iş akışında PaymentGateway, InventoryReservation ve OrderRepository gibi dependency'ler bulunabilir.
İlk implementation'da ödeme sağlayıcısının SDK'sı doğrudan handler içinde çağrılıyor olsun. Handler provider'a özgü request nesnesini oluşturuyor, API key kullanımını yönetiyor ve provider response içindeki durum kodlarını yorumluyor. Provider değiştiğinde siparişin iş mantığına ait dosyanın da değiştirilmesi gerekiyor.
Burada abstraction için güçlü bir gerekçe vardır. İş akışı ödeme sağlayıcısının teknik ayrıntılarını değil, ödeme başlatma sonucunu bilmelidir. Örneğin IPaymentGateway.ChargeAsync işlemi, transaction identifier ve Succeeded, Pending, Failed gibi domain'in kullanabileceği durumları döndürebilir.
Bununla birlikte contract'ın adı ve signature'ı tasarımın küçük kısmıdır. Asıl mesele ödeme sonucunun ne anlama geldiğidir. Timeout gerçekleştiğinde ödeme başarısız mı sayılacak, yoksa sonucu bilinmeyen bir işlem olarak mı işlenecek? Aynı request tekrar gönderildiğinde ikinci kez tahsilat yapılması nasıl önlenecek?
Bu noktada idempotency devreye girer. Idempotency, aynı mantıksal işlemin tekrar denenmesinin ikinci bir iş etkisi üretmemesi beklentisidir. Ödeme API'lerinde bunun uygulanması çoğunlukla sabit bir idempotency key'in aynı işlem için yeniden kullanılmasını gerektirir; sağlayıcının bu key'e ilişkin garantileri ayrıca doğrulanmalıdır.
Örneğin sipariş ORD-1042 için ödeme isteği gönderildiğinde ağ bağlantısı kopabilir. Handler response alamadığı için işlemi başarısız kabul edip yeni bir ödeme oluşturursa müşteri iki kez ücretlendirilebilir. IPaymentGateway interface bu sorunu tek başına çözmez.
Doğru contract, belirsiz sonucu temsil edebilmeli ve retry davranışını açıkça tanımlamalıdır. Adapter provider'ın idempotency mekanizmasını kullanabilir, ancak uygulama da aynı ödeme girişiminin kimliğini kalıcı olarak saklamalıdır. Bu işlemde benzersiz bir payment attempt identifier ve uygun persistence kısıtları önemlidir.
Aynı projede OrderValidator için interface oluşturmak ise gereksiz olabilir. Tek bir validation implementation varsa, kurallar domain'e aitse ve başka bir implementation ihtiyacı görünmüyorsa concrete class yeterlidir. Unit test yazmak için her class'ın interface'e sahip olması gerekmez.
Test edilebilirlik açısından asıl ayrım, testin kontrol etmesi gereken sınırdır. Ödeme sağlayıcısına gerçek HTTP isteği göndermeden sipariş akışını test etmek değerlidir. Buna karşılık deterministik bir validation class'ını sırf mock oluşturmak için interface arkasına almak testlerin implementation ayrıntılarına bağlanmasına neden olabilir.
Bu akış için code review yaparken üç somut değişikliği incelerim: Yeni bir payment provider eklendiğinde hangi dosyalar değişiyor, yeni bir sipariş kuralı eklendiğinde hangi dosyalar değişiyor ve provider'ın hata davranışı değiştiğinde hangi testler etkileniyor? Bu değişiklikler birbirinden bağımsız kalabiliyorsa abstraction sınırı işe yarıyordur.
Leaky abstraction ve YAGNI kararın sınırını belirler
Leaky abstraction, gizlenmesi amaçlanan implementation ayrıntılarının consumer koduna sızmasıdır. Bu her zaman kötü isimlendirilmiş bir interface anlamına gelmez. Bazen problem, abstraction'ın gerçek sistemin davranışlarını ifade edemeyecek kadar dar tasarlanmasıdır.
Örneğin ICache.Get(key) ve ICache.Set(key, value) metotları basit görünür. Ancak distributed cache kullanılan bir sistemde expiration, serialization, network failure ve consistency davranışları önem kazanır. Consumer bunları bilmeden doğru karar veremiyorsa interface eksik bir model sunuyordur.
Benzer şekilde generic repository abstraction, SQL transaction sınırlarını gizlediğini iddia ederken application service'in SaveChanges çağırmasını gerektiriyorsa persistence lifecycle dışarı sızmıştır. Sorun yalnızca abstraction'ın varlığı değil, sorumluluk sınırının yanlış seçilmesidir.
Bütün teknik ayrıntıları gizlemek de mümkün değildir. Network üzerinden çalışan bir dependency'nin latency ve failure özellikleri, memory içindeki bir nesneninkiyle aynı değildir. Remote çağrıyı local method gibi göstermek, consumer'ın timeout ve retry kararlarını yanlış vermesine neden olabilir.
Bu nedenle iyi abstraction, implementation ayrıntılarını saklarken consumer'ın doğru davranması için gereken garantileri açıkça taşır. Özellikle transaction, idempotency, ordering ve consistency gibi özellikler yalnızca adapter içine kapatılabilecek rastlantısal detaylar değildir.
Gereksiz abstraction'ın diğer yüzü YAGNI ilkesidir: You Aren't Gonna Need It. Bu ilke gelecekte hiçbir ihtiyaç doğmayacağını varsaymaz. Henüz doğrulanmamış bir ihtiyacın bugünkü mimariye maliyet yüklemesini sorgular.
Örneğin sistem yalnızca PostgreSQL kullanıyorsa sırf ileride başka database seçilebilir düşüncesiyle bütün persistence davranışlarını vendor-independent bir framework içine taşımak çoğu zaman erken bir yatırımdır. Database değiştirmek yalnızca connection string veya repository implementation değiştirmek değildir. Query özellikleri, transaction isolation, indexing ve migration stratejileri de değişebilir.
Bu yüzden tam bağımsızlık vaat eden abstraction, gerçekte sağlanamayacak bir taşınabilirlik hissi oluşturabilir. Üstelik ortak contract, kullanılan database'in güçlü özelliklerinden yararlanmayı da zorlaştırabilir.
Overengineering burada ortaya çıkar. Çözülmemiş bir değişim ihtimali için bugünden yeni katmanlar, factory'ler, generic type'lar ve configuration seçenekleri eklenir. Tasarım daha fazla senaryoyu destekliyormuş gibi görünür, fakat mevcut senaryoyu anlamak için gereken zihinsel yük artar.
Yine de YAGNI, bütün abstraction'ları ertelemek için gerekçe değildir. Ödeme, external messaging veya üçüncü taraf servis entegrasyonları gibi hata davranışı ve lifecycle açısından belirgin sınırlar baştan ayrılmayı hak edebilir. Buradaki gerekçe gelecekte ikinci provider'ın kesinlikle geleceği değildir; bugünkü iş kurallarının provider ayrıntılarından bağımsız kalmasının zaten değer üretmesidir.
Benim tercih ettiğim eşik, ikinci implementation'ın sayısı değil, değişim nedenlerinin ayrışmasıdır. İki class aynı nedenle değişiyorsa aralarına interface koymak sınırlı fayda sağlar. Tek implementation olsa bile consumer ile dependency farklı nedenlerle değişiyorsa açık bir abstraction sınırı savunulabilir.
Abstraction'ı değişim yönüne göre seçin
Yeni bir interface eklemeden önce o interface'in hangi bilgiyi sakladığını ve hangi değişikliği consumer'dan uzak tuttuğunu somut olarak tarif edin. Cevap yalnızca testlerde mock kullanabilmek veya ileride belki başka implementation eklemekse önce concrete dependency ile devam edin. Gerektiğinde interface çıkarmak, baştan kurulan gereksiz bir hiyerarşiyi sökmekten çoğu zaman daha kolaydır.
Bu öneri, dependency'nin değiştirilmesinin çok pahalı olduğu veya contract'ın sistemler arasında önceden kararlaştırılması gereken durumlarda geçerli değildir. Public SDK, plugin architecture, bağımsız deploy edilen servisler ve birden fazla ekibin aynı contract'a göre geliştirme yaptığı sistemlerde abstraction'ı erken tanımlamak bilinçli bir yatırım olabilir. Contract değişikliğinin maliyeti burada implementation maliyetinden daha yüksek olabilir.
Mevcut bir codebase içinde karar verirken önce değişen dosyalara ve değişiklik nedenlerine bakarım. Aynı interface ve implementation neredeyse her commit'te birlikte değişiyorsa bu abstraction'ın sağladığı bağımsızlık sorgulanmalıdır. Buna karşılık bir adapter defalarca değişirken domain kodu sabit kalıyorsa abstraction gerçek bir sınır oluşturmuş olabilir.
Interface sayısını kalite metriği olarak kullanmam. Daha anlamlı ölçüt, bir iş kuralı değişikliğinin kaç ilgisiz bileşene yayıldığı ve bir altyapı değişikliğinin domain davranışını ne kadar etkilediğidir. Gereksiz abstraction'ları kaldırmak da yeni abstraction eklemek kadar meşru bir refactor kararıdır.
Son karar şu olmalıdır: Önce doğru davranışı concrete kodla ifade edin, değişim nedenlerini ayırın, ardından ihtiyaç duyulan sınır için en küçük contract'ı oluşturun. Interface'i farklı implementation'ların davranış sözünü tanımlamak için, abstract class'ı gerçekten ortak bir algoritmayı korumak için kullanın. Abstraction'ın bedeli, sakladığı değişim maliyetinden yüksek hâle geldiğinde onu korumak artık iyi tasarım değildir.
yorumlar
0Henüz yorum yok. İlk notu sen bırak.
Yorum yazmak için giriş yap.
Gmail ile saniyeler içinde, ya da e-posta ile kaydol.