C#'ta Composition ve SOLID'e Giriş
List'ten derive eden bir sepetin kuralları nasıl deldiği, composition ile sahip olmak, SOLID'in beş prensibi Bookstore örnekleriyle ve prensipleri aşırıya kaçırmanın bedeli.
Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.
Önce Tahmin Et
Sepete en fazla üç ürün eklenebilsin istiyoruz. En kısa yol gibi görünen şey, List<OrderItem>'dan türeyip Add'e bir kontrol koymak. Dördüncü ürün exception ile reddediliyor. Peki son satır kaç yazar?
var cart = new LimitedCart(maxItems: 3);
cart.Add(new OrderItem("978-0441013593", 1));
cart.Add(new OrderItem("978-0141439518", 1));
cart.Add(new OrderItem("978-0547928227", 1));
try
{
cart.Add(new OrderItem("978-0061120084", 1));
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
IList<OrderItem> list = cart;
list.Add(new OrderItem("978-0061120084", 1));
cart.AddRange(new[] { new OrderItem("978-0451524935", 1), new OrderItem("978-0743273565", 1) });
Console.WriteLine(cart.Count);
public record OrderItem(string Isbn, int Quantity);
public class LimitedCart : List<OrderItem>
{
private readonly int _maxItems;
public LimitedCart(int maxItems) => _maxItems = maxItems;
public new void Add(OrderItem item)
{
if (Count >= _maxItems)
throw new InvalidOperationException($"A cart can hold at most {_maxItems} items.");
base.Add(item);
}
}
Inheritance'ın Açık Bıraktığı Kapılar
Çıktı:
A cart can hold at most 3 items.
6
Üç ürünlük sepette altı ürün var. List<T>.Add virtual değil, o yüzden override edemedik ve new ile gizledik. Polymorphism yazısında gördüğümüz gibi new, sadece değişkenin tipi LimitedCart olduğunda devreye girer. IList<OrderItem> üzerinden yapılan çağrı doğrudan List'in kendi Add'ine gitti. AddRange ise bizim method'umuzu hiç çağırmadı. Üstelik Insert, InsertRange ve indexer da hâlâ açık.
Asıl sorun teknik değil, tasarımla ilgili. LimitedCart : List<OrderItem> yazdığında dünyaya "sepet bir listedir, bir listeyle yapabildiğin her şeyi sepetle de yapabilirsin" demiş oluyorsun. Bu doğru değil; sepetin kuralları var. Inheritance, base class'ın bütün public yüzeyini inherit eder ve o yüzeyin her parçası bir söz verir. Tutamayacağın bir sözü inherit etmek, kuralları delik bir kalkana dönüştürür.
Inheritance'ı seçmeden önce şu testi yap: "B, her durumda ve her anlamda bir A mı?" Bir EBook gerçekten bir Book'tur. Bir sepet ise bir liste değildir; bir listeye sahiptir. Bu "sahip olmak" ilişkisinin adı composition.
Composition: Sahip Olmak
Aynı sepeti composition ile, yani listeyi içeride saklayarak yazalım. İndirim hesaplamasını da sepetten ayırıyoruz:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
var cart = new ShoppingCart(maxItems: 3);
cart.Add(new OrderItem("978-0441013593", 3, 12.99m));
cart.Add(new OrderItem("978-0141439518", 1, 7.50m));
var pricer = new OrderPricer(new IDiscountPolicy[]
{
new BulkDiscount(minQuantity: 3, rate: 0.10m),
new CouponDiscount(enteredCode: "WELCOME5", validCode: "WELCOME5", amount: 5m)
});
Console.WriteLine($"Subtotal: {cart.Subtotal}");
Console.WriteLine($"Total: {pricer.Total(cart)}");
Console.WriteLine($"Items: {cart.Items.Count}");
public record OrderItem(string Isbn, int Quantity, decimal UnitPrice)
{
public decimal LineTotal => Quantity * UnitPrice;
}
public sealed class ShoppingCart
{
private readonly List<OrderItem> _items = new();
private readonly int _maxItems;
public ShoppingCart(int maxItems) => _maxItems = maxItems;
public IReadOnlyList<OrderItem> Items => _items;
public decimal Subtotal => _items.Sum(i => i.LineTotal);
public void Add(OrderItem item)
{
if (_items.Count >= _maxItems)
throw new InvalidOperationException($"A cart can hold at most {_maxItems} items.");
_items.Add(item);
}
}
public interface IDiscountPolicy
{
decimal DiscountFor(ShoppingCart cart);
}
public sealed class BulkDiscount : IDiscountPolicy
{
private readonly int _minQuantity;
private readonly decimal _rate;
public BulkDiscount(int minQuantity, decimal rate)
{
_minQuantity = minQuantity;
_rate = rate;
}
public decimal DiscountFor(ShoppingCart cart) =>
cart.Items.Where(i => i.Quantity >= _minQuantity).Sum(i => i.LineTotal * _rate);
}
public sealed class CouponDiscount : IDiscountPolicy
{
private readonly string _enteredCode;
private readonly string _validCode;
private readonly decimal _amount;
public CouponDiscount(string enteredCode, string validCode, decimal amount)
{
_enteredCode = enteredCode;
_validCode = validCode;
_amount = amount;
}
public decimal DiscountFor(ShoppingCart cart) => _enteredCode == _validCode ? _amount : 0m;
}
public sealed class OrderPricer
{
private readonly IReadOnlyList<IDiscountPolicy> _policies;
public OrderPricer(IReadOnlyList<IDiscountPolicy> policies) => _policies = policies;
public decimal Total(ShoppingCart cart)
{
decimal discount = _policies.Sum(p => p.DiscountFor(cart));
return Math.Round(Math.Max(0m, cart.Subtotal - discount), 2, MidpointRounding.AwayFromZero);
}
}
Subtotal: 46.47
Total: 37.57
Items: 2
ShoppingCart listeyi private bir field'da tutuyor ve dışarıya sadece iki şey açıyor: kuralı uygulayan Add ve read-only bir Items. Artık kuralı atlatmanın yolu yok; IList'e cast etmek de işe yaramaz, çünkü ShoppingCart bir liste değil. Dışarıya ne açacağına sen karar veriyorsun, base class değil.
Bunu bir arabayla düşün. Araba bir motor değildir, bir motoru vardır. Motoru değiştirmek için arabayı baştan tasarlamazsın; aynı bağlantı noktalarına uyan başka bir motor takarsın. OrderPricer da indirimleri böyle taşıyor: kendisi indirim hesaplamayı bilmiyor, elindeki IDiscountPolicy'lere soruyor. "Composition over inheritance" (inheritance yerine composition) tavsiyesinin özü bu: davranışı class hiyerarşisine gömmek yerine, değiştirilebilir parçalardan kurmak.
Küçük bir ayrıntı: Math.Round olmasaydı toplam 37.5730 çıkardı; yüzde onluk indirim küsurat üretiyor. Para hesaplamalarında yuvarlamanın nerede ve hangi kuralla yapıldığı her zaman açıkça yazılmalı.
SOLID'in İlk İki Harfi: S ve O
SOLID, Robert C. Martin'in nesne yönelimli (OOP) tasarım için derlediği beş prensibin baş harflerinden oluşur. Kural kitabı değil, kodun neden değiştirmesi zor hâle geldiğini anlamaya yarayan bir bakış açısı. Hepsini yukarıdaki koda bakarak konuşabiliriz.
Single Responsibility (tek sorumluluk): bir class'ın değişmek için tek bir nedeni olmalı. Fiyatlandırmayı, siparişi veritabanına kaydetmeyi ve müşteriye e-posta göndermeyi tek bir OrderService'te toplamayı düşün. Kampanya kuralları değişince, veritabanı şeması değişince ya da e-posta template'i değişince aynı class'a dokunursun; birinin değişikliği diğerini bozabilir. Bizim kodda sepetin kuralları ShoppingCart'ta, indirim mantığı policy class'larında, toplamın hesaplanması OrderPricer'da duruyor. Her biri ayrı bir nedenle değişir.
Open/Closed (açık/kapalı): kod genişlemeye açık, değişikliğe kapalı olmalı. Yarın "üye müşterilere yüzde beş" kampanyası gelirse OrderPricer'a dokunmazsın; IDiscountPolicy'yi implement eden yeni bir class yazıp listeye eklersin. Alternatifi, Total'ın içinde her kampanyada bir dal daha uzayan bir switch; her yeni kampanya, çalışan kodu yeniden açıp test etmek demek.
L: Liskov Substitution
Liskov Substitution (yerine geçebilme): bir base class'ı kullanan kod, ona derived class'lardan biri verildiğinde de sürprizle karşılaşmadan çalışmalı. Bozulduğu yer genelde şöyle görünür:
var items = new List<Book>
{
new PrintedBook("978-0441013593", "Dune", stock: 2),
new EBook("978-0141439518", "Emma")
};
Restock(items, 5);
static void Restock(IEnumerable<Book> books, int amount)
{
foreach (var book in books)
{
book.Stock += amount;
Console.WriteLine($"{book.Title}: {book.Stock}");
}
}
public abstract class Book
{
protected Book(string isbn, string title)
{
Isbn = isbn;
Title = title;
}
public string Isbn { get; }
public string Title { get; }
public abstract int Stock { get; set; }
}
public sealed class PrintedBook : Book
{
public PrintedBook(string isbn, string title, int stock) : base(isbn, title) => Stock = stock;
public override int Stock { get; set; }
}
public sealed class EBook : Book
{
public EBook(string isbn, string title) : base(isbn, title) { }
public override int Stock
{
get => int.MaxValue;
set => throw new NotSupportedException("An e-book has no stock.");
}
}
Dune: 7
Unhandled exception. System.NotSupportedException: An e-book has no stock.
(Stack trace'i çıkardım.) Restock, Book'un verdiği söze güvendi: her kitabın değiştirilebilir bir stoğu var. EBook bu sözü tutamıyor. NotSupportedException fırlatan bir override gördüğünde durup düşün; çoğu zaman hiyerarşinin yanlış kurulduğunu söyler. Burada sorun stoğun Book'a ait olması. Stok, sadece fiziksel ürünlerin bir özelliği:
var items = new List<Book>
{
new PrintedBook("978-0441013593", "Dune", stock: 2),
new EBook("978-0141439518", "Emma")
};
Restock(items.OfType<IStocked>(), 5);
static void Restock(IEnumerable<IStocked> items, int amount)
{
foreach (var item in items)
{
item.Stock += amount;
Console.WriteLine($"{item.Isbn}: {item.Stock}");
}
}
public interface IStocked
{
string Isbn { get; }
int Stock { get; set; }
}
public abstract class Book
{
protected Book(string isbn, string title)
{
Isbn = isbn;
Title = title;
}
public string Isbn { get; }
public string Title { get; }
}
public sealed class PrintedBook : Book, IStocked
{
public PrintedBook(string isbn, string title, int stock) : base(isbn, title) => Stock = stock;
public int Stock { get; set; }
}
public sealed class EBook : Book
{
public EBook(string isbn, string title) : base(isbn, title) { }
}
978-0441013593: 7
Artık Restock'a bir EBook vermek derleme hatası olur, runtime'da patlamaz. Hatayı compiler'a yakalatmak, test'e ya da müşteriye yakalatmaktan her zaman ucuzdur.
I ve D: Interface Segregation ve Dependency Inversion
Interface Segregation (interface ayrımı): kimse kullanmadığı method'lara bağımlı olmaya zorlanmamalı. Kitap listesini gösteren bir sayfa için hem okuma hem yazma hem de CSV dışa aktarma method'ları olan tek bir IBookRepository fazla büyük; o sayfanın test'inde sahte bir repository yazarken kullanmadığın method'ları da doldurmak zorunda kalırsın. Interface'leri ihtiyaca göre bölmek bunu çözer:
var store = new InMemoryBookStore();
store.Add(new Book("978-0441013593", "Dune"));
store.Add(new Book("978-0141439518", "Emma"));
var page = new CatalogPage(store);
page.Show("du");
public record Book(string Isbn, string Title);
public interface IBookReader
{
Book? FindByIsbn(string isbn);
IReadOnlyList<Book> Search(string text);
}
public interface IBookWriter
{
void Add(Book book);
void Remove(string isbn);
}
public sealed class InMemoryBookStore : IBookReader, IBookWriter
{
private readonly Dictionary<string, Book> _books = new();
public Book? FindByIsbn(string isbn) => _books.TryGetValue(isbn, out var book) ? book : null;
public IReadOnlyList<Book> Search(string text) =>
_books.Values.Where(b => b.Title.Contains(text, StringComparison.OrdinalIgnoreCase)).ToList();
public void Add(Book book) => _books[book.Isbn] = book;
public void Remove(string isbn) => _books.Remove(isbn);
}
public sealed class CatalogPage
{
private readonly IBookReader _books;
public CatalogPage(IBookReader books) => _books = books;
public void Show(string text)
{
foreach (var book in _books.Search(text))
Console.WriteLine($"{book.Isbn} {book.Title}");
}
}
978-0441013593 Dune
InMemoryBookStore iki interface'i birden uyguluyor, ama CatalogPage sadece IBookReader'ı tanıyor. Sayfa, kitap silmeyi bilmiyor ve bilmemesi de iyi.
Dependency Inversion (bağımlılığın tersine çevrilmesi): üst seviye kod alt seviye ayrıntılara değil, abstraction'lara bağlı olmalı. CatalogPage içinde new InMemoryBookStore() yazmadık; constructor'dan bir IBookReader istedik. OrderPricer da indirimleri kendisi oluşturmuyor, dışarıdan alıyor. Hangi concrete class'ın kullanılacağına programın en başında (burada Program.cs'in ilk satırlarında) karar veriliyor. Yarın kitaplar veritabanından gelirse CatalogPage'e dokunmadan IBookReader'ı implement eden başka bir class verirsin. Bu bağımlılıkları elle değil bir container ile bağlamayı İleri Seviye serisindeki dependency injection yazısında, ASP.NET Core'daki karşılığını da .NET Core Web API serisinde görüyoruz.
Sık Düşülen Tuzaklar
Kod tekrarını önlemek için inheritance kullanmak. İki class'ta ortak üç satır var diye ortak bir base class açmak, aralarında gerçek bir "is-a" ilişkisi yoksa ileride ikisini birbirine kilitler. Ortak kodu ayrı bir class'a koyup iki tarafın da onu kullanması (yine composition) çoğu zaman daha esnektir.
Her class için bir interface açmak. Tek bir implementasyonu olan ve olması beklenmeyen bir IOrderItemFormatter, sadece dosya sayısını artırır. Interface'i değiştirilebilir olması gereken yerlere koy: dış dünyaya açılan noktalar (veritabanı, e-posta, saat, dosya sistemi) ve gerçekten birden çok çeşidi olan davranışlar (indirim politikaları gibi).
SOLID'i bir kontrol listesi gibi uygulamak. Prensipler değişimin acısını azaltmak için var. Hiç değişmeyecek küçük bir script'te beş katmanlı abstraction kurmak, prensibin amacını ters çevirir. Önce sade yaz, değişim ihtiyacı gerçekten geldiğinde ayır.
Composition'ı da aşırıya kaçırmak. On küçük parçadan kurulmuş ve hangisinin ne yaptığını görmek için on dosya açmayı gerektiren bir yapı da okunaksızdır. Amaç parçalara ayırmak değil, her parçanın tek ve anlaşılır bir işi olması.
Küçük Bir Deneme
★☆☆ IDiscountPolicy'yi implement eden bir MemberDiscount yaz: sepet toplamının yüzde beşi kadar indirim yapsın. OrderPricer'a hiç dokunmadan listeye ekle ve yeni toplamı yazdır.
★★☆ İndirimlerin toplamı sepet tutarını aşarsa ne oluyor? Math.Max satırını kaldırıp 10 EUR'luk bir sepete 15 EUR'luk bir kupon uygula. Bu kuralı OrderPricer'da tutmak mı doğru, her policy'nin içinde mi?
★★★ ShoppingCart'a aynı ISBN tekrar eklendiğinde yeni satır açmak yerine mevcut satırın miktarını artıran bir kural ekle. OrderItem bir record olduğu için bunu with ile yapman gerekecek. Bu değişiklik SOLID'in hangi harfini ilgilendiriyor?
Bu yazıda object'lerin birbirine nasıl bağlandığını konuştuk. Bir sonraki yazıda object'lerin ne kadar yaşadığına bakıyoruz: garbage collector bellek işini nasıl yürütür, neden dosya ve bağlantı gibi kaynakları ona bırakamayız ve IDisposable ile using bu boşluğu nasıl doldurur.