SOLID: Yazılımın Dayanıklı İlkeleri
SOLID: Robust Principles of Software
Single Responsibility Principle (Tek Sorumluluk Prensibi)
There should never be more than one reason for a class to change. (Bir sınıfın değişmesi için asla birden fazla sebep olmamalıdır.)
Bir sınıf değiştirilmesi için bir sebepten daha fazlasına sahip olmamalı, tek bir amaca hizmet etmelidir. ✍️ Örneğin, e-posta gönderen sınıfa sahipseniz EmailService
, sınıfın içerisinde sadece ve sadece e-posta gönderimine ait işlevler bulunmalıdır. Dolayısıyla bir sınıf hem mesaj hem de e-posta gibi birden fazla sorumluluğu üstlenmemelidir.
Neden birden fazla sorumluluğu üstlenmemeli? 🤔 Örneğin Communication.cs
sınıfının hem e-posta hemde mesaj gönderdiğini yani birden fazla sorumluluğu üstlendiğini düşünelim. İlerleyen zamanlarda ekibe yeni katılan bir arkadaş client’a bildirim göndermek istediğinde muhtemelen bahsini geçirdiğimiz sınıf içerisinde bu işlemi yapacaktır. Dolayısıyla ilgili sınıf zamanla büyümeye başlayacak belirli süre sonra işin içinden çıkılmaz 💣 bir hale gelecektir.
Bu gibi sorunların önüne geçebilmek adına sınıfları isimlendirirken genele hitap eden, ortak konuları ilgilendiren isimlendirmelerden kaçınılmalıdır. Örneğin, bir önceki örnekte bahsini geçirdiğimiz Communication.cs
gibi isimlendirmeler uygun olmayabilir.
Yer yer Helper.cs
adında isimlendirmeye sahip sınıflar görürüz. Bu gibi sınıfların neye hizmet ettiğini ayırt etmek zorlayıcıdır. ⛓ Dolayısıyla sınıfları dar bir konuyu kapsayacak şekilde isimlendirmemiz oldukça önem arz etmektedir. 🔑
Başta büyük ölçekli projeler olmak üzere, mümkün olduğunca
helper
yapılarından kaçınmalıyız. Çünkühelper
yapıları single responsibility principle’ı ihlal eden en büyük yapılardan biridir. 🔫🔫
Projelerde bulunan helper sınıfları başta masumiyeti yüksek olan zaman içerisinde suç işleyen 👮🏼♂️ 👮🏼♀️ sınıflardır. Örneğin helper sınıfı içerisine kullanıcının adını ve soyadını birleştiren yardımcı metot eklediğinizi düşünelim.
public static string FullName(Customer customer)
{
return $"{customer.Name} {customer.Surname}";
}
Sınıfın tüm hakimiyeti sizde olmadığı için (ortak bir projede çalıştığınızı düşünerek) takımdaki diğer kişiler mevcut helper sınıfının içerisine konudan bağımsız ekstra metot eklemesi yapabilir. Bir başka kişi farklı bir konuda ekleme yapar! derken gün sonunda 🕵🏼♂️ birden fazla sorumluluğu olan sınıf haline gelir.
- Helper sınıfları kullanılamaz mı?
Tabii ki kullanılabilir. En başta konuştuğumuz üzere daha spesifik, daha dar bağlamda isimler kullanılmalıdır. Örneğin CustomerHelper.cs
ImageHelper.cs
VehicleHelper.cs
UrlHelper.cs
şeklinde kısıtlayıcı isimlerle ilerlemekte fayda vardır. 🏋🏼♂️ Bu tür isimlendirmeler diğer takım üyelerine de çıkarım yapabileceği alan sunacaktır. Ancak sadece Helper.cs
isimlendirmesine yönelirsek (ben dikkat ediyorum sorun yok düşüncesinden çıkmak gereklidir.) diğer takım üyelerinin helper.cs sınıfına farklı konuları içeren yardımcı metotları yazmaları da kaçınılmaz olacaktır.
❌❌ Uygunsuz Örnek ❌❌
public class User
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public bool SaveUser()
{
return true;
}
public bool SendEmail()
{
return true;
}
}
Yukarıdaki örnekte, User
sınıfı hem bir kullanıcının kaydedilmesi hem de e-posta gönderilmesi işlevlerini içeriyor. Bu nedenle, sınıf SRP prensibine uymamaktadır.
✔️✔️ Uygun Örnek ✔️✔️
public class User
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
}
public class UserRepository
{
public bool SaveUser(User user)
{
return true;
}
}
public class EmailService
{
public bool SendEmail(User user)
{
return true;
}
}
Yukarıdaki örnekte, User
sınıfı yalnızca kullanıcının özelliklerini içerirken, UserRepository
sınıfı kullanıcının kaydedilmesi ile ilgili işlevleri ve EmailService
sınıfı ise e-posta gönderim işlemleri ile ilgili işlevleri içerir. Bu nedenle, her bir sınıf tek bir sorumluluğa sahiptir ve SRP prensibine uymaktadır.
Open-Closed Principle (Açık/Kapalı Prensibi)
Software entities should be open for extension but closed for modification. (Yazılım varlıkları genişletmeye açık ancak değiştirmeye kapalı olmalıdır.)
Bir uygulamanın gelişime açık fakat değişeme kapalı olmasını ifade etmektedir. Örneğin projeyi tamamladınız ve müşteriden gelen istekleri karşılamaya başladınız. İlgili değişiklikleri uygulamaya başladığınız anda var olan kodun büyük bir kısmını değiştirmeye başladıysanız bu prensibi ihlal edersiniz. 🔔 Bu örneği özetleyecek olursak, bitmiş bir projeye yeni özellikler eklediğinizde, eklenmek istenen feature var olan projeyi bozuyorsa open-closed prensibine aykırı bir kod yazmış olabileceğinize işarettir.
Genelde switch blokları zararlı olabilmektedir. Özellikle metotlar switch bloklarını kapsıyorsa yüksek ihtimalle open-closed prensibini ihlal ediyordur. Metotlar enum parametrelerini enum’un sayısı kesin olarak (genişlemeyecek) belirli ise almalıdır. İleride sayısında artış olmayacağı kesin senaryolarda metoda parametre olarak enum geçip, switch-case yapısı kurmaktan şüphe duyulmayabilir. Fakat sayısından emin olunamadığı durumlarda metotlar parametre olarak enum almak yerine interface aracılığıyla bu işlemlerin ilerletilmesi open-closed prensibi açısından fayda olacaktır.
❌❌ Uygunsuz Örnek ❌❌
public class ShippingCalculator
{
public decimal CalculateShippingCost(Order order, bool isInternational)
{
decimal shippingCost;
if (isInternational)
{
shippingCost = // uluslararası gönderim ücreti hesaplama
}
else
{
shippingCost = // yerel gönderim ücreti hesaplama
}
return shippingCost;
}
}
Yukarıdaki örnekte ise ShippingCalculator
sınıfı, Open-Closed Principle'a uymamaktadır. Bu sınıf, gönderimin uluslararası veya yerel olması durumuna göre farklı hesaplama işlemleri yapmaktadır. Dolayısıyla yeni bir gönderim ücreti hesaplama stratejisi eklemek istediğimizde, ShippingCalculator
sınıfının kodunu değiştirmek zorunda kalacağız. Bu da sınıfın değiştirilebilirliğini azaltacak ve uygulamanın bakımını 🧼 zorlaştıracaktır.
✔️✔️ Uygun Örnek ✔️✔️
public interface IShippingStrategy
{
decimal CalculateShippingCost(Order order);
}
public class DomesticShippingStrategy : IShippingStrategy
{
public decimal CalculateShippingCost(Order order)
{
decimal shippingCost = // hesaplama yapılır
return shippingCost;
}
}
public class InternationalShippingStrategy : IShippingStrategy
{
public decimal CalculateShippingCost(Order order)
{
decimal shippingCost = // hesaplama yapılır
return shippingCost;
}
}
public class ShippingCalculator
{
private readonly IShippingStrategy _shippingStrategy;
public ShippingCalculator(IShippingStrategy shippingStrategy)
{
_shippingStrategy = shippingStrategy;
}
public decimal CalculateShippingCost(Order order)
{
return _shippingStrategy.CalculateShippingCost(order);
}
}
Yukarıdaki örnekte, ShippingCalculator
sınıfı Open-Closed Principle'a uymaktadır. Bu sınıf, IShippingStrategy
adında bir interface kullanarak, gönderim ücreti hesaplama işlemini farklı stratejilerle yapabilen sınıfların (DomesticShippingStrategy
ve InternationalShippingStrategy
) türetildiği ve enjekte edildiği bir yapıdadır. Bu sayede yeni bir gönderim ücreti hesaplama stratejisi eklendiğinde, ShippingCalculator
sınıfını değiştirmeden, sadece yeni bir strateji sınıfı oluşturarak uygulamaya eklemek mümkündür.
Liskov Substitution Principle (Liskov Yerine Koyma Prensibi)
Bir adet Bank.cs
sınıfımız olsun, bir adet de Denizbank.cs
sınıfımız olsun. Denizbank : Bank
sınıfından miras aldığını düşünelim. Pragmatik (eylemle ilgili) olarak miras alındığı 🧬 için birbirilerinin yerlerine kullanılabilirler. Böyle bir kullanım senaryosunda kodun hata vermemesi gerekmektedir. Prensibe göre de sınıftan alt sınıfa ya da alt sınıftan üst sınıfa geçildiği zaman kodun başarılı şekilde çalışmaya devam etmesi gereklidir.
❌❌ Uygunsuz Örnek ❌❌
class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area() => Width * Height;
}
class Square : Rectangle
{
public override int Width
{
get { return base.Width; }
set { base.Width = base.Height = value; }
}
public override int Height
{
get { return base.Height; }
set { base.Height = base.Width = value; }
}
}
void CalculateArea(Rectangle r)
{
r.Width = 5;
r.Height = 4;
Debug.Assert(r.Area() == 20);
}
Square sq = new Square();
CalculateArea(sq); // Assertion Failed!
Yukarıdaki kod örneğinde, Rectangle
ve Square
sınıfları oluşturulmuştur. Square
sınıfı, Rectangle
sınıfını miras almaktadır. Ancak Square
sınıfı, Width
ve Height
özelliklerini farklı şekilde kullanmaktadır. Width
ve Height
özellikleri eşit olarak atanmalıdır.
Bu nedenle, CalculateArea
fonksiyonuna Square
sınıfının bir örneği gönderildiğinde, Width
ve Height
özellikleri eşit olarak atandığı için, bir kare olarak ele alınmalıdır. Ancak, Square
sınıfı, Width
ve Height
özellikleri eşit olarak atanmış olduğundan, Area()
metodunun döndürdüğü değer beklendiği gibi olmayacaktır. Bu durumda, Liskov Substitution Prensibine uymayan bir kod örneği ile karşı karşıyayız.
✔️✔️ Uygun Örnek ✔️✔️
public class Animal {
public virtual void MakeSound() {
Console.WriteLine("Animal sound");
}
}
public class Dog : Animal {
public override void MakeSound() {
Console.WriteLine("Woof");
}
}
public class Cat : Animal {
public override void MakeSound() {
Console.WriteLine("Meow");
}
}
public class AnimalSound {
public void MakeAnimalSound(Animal animal) {
animal.MakeSound();
}
}
Yukarıdaki kod Liskov Substitution Principle’ına uyuyor çünkü Animal sınıfından türetilen alt sınıflar Dog ve Cat, Animal sınıfındaki tüm davranışları kalıtım yoluyla alırlar ve MakeSound() metodu da virtual anahtar kelimesi kullanılarak ezmeye hazır hale getirilir. MakeAnimalSound() metodunda ise Animal tipinde bir nesne alınarak, herhangi bir alt sınıfın nesnesi geçirilebilir. Bu sayede Liskov Substitution Principle’ı uygulanmış olur.
Interface Segregation Principle (Arayüz Ayırım Prensibi)
Interface’ler genel amaçlı olmamalı, kullanacak clientlara özgü olmalıdır. Dolayısıyla tüm clientların kullanabileceği (ortaklaştırılmış) interface yapılarından uzak durulmalıdır. 💥
❌❌ Uygunsuz Örnek ❌❌
interface IDevice
{
void Print();
void Scan();
void Call();
}
class MultiFunctionPrinter : IDevice
{
public void Print()
{
Console.WriteLine("Printing...");
}
public void Scan()
{
Console.WriteLine("Scanning...");
}
public void Call()
{
Console.WriteLine("Calling...");
}
}
Yukarıdaki kod Interface Segregation Principle’a uymuyor. Çünkü IDevice arayüzü, tüm cihazlar için gerekli olmayan Call() işlevini içeriyor. IDevice arayüzünü, IScanner ve IPrinter gibi daha özelleştirilmiş arayüzlerden türetmek, bu prensibe uygun bir yaklaşım olacaktır. Böylece her cihaz, sadece kendi ihtiyaçlarına uygun interface’leri uygulayacaktır.
✔️✔️ Uygun Örnek ✔️✔️
interface IPrinter
{
void Print();
}
interface IScanner
{
void Scan();
}
class MultiFunctionPrinter : IPrinter, IScanner
{
public void Print()
{
Console.WriteLine("Printing...");
}
public void Scan()
{
Console.WriteLine("Scanning...");
}
}
Yukarıdaki örnekte, IPrinter
ve IScanner
adlı iki interface tanımlanmıştır. MultiFunctionPrinter
sınıfı, hem IPrinter
hem de IScanner
interfacelerini uygular. Bu sayede, MultiFunctionPrinter
sınıfı sadece gereksinim duyulan işlevleri içerir.
Dependency Inversion Principle (Bağımlılık Tersine Çevirme Prensibi)
- High Level Modüller: Controller’lar
- Low Level Modüller: Controller içerisinde kullanılan repository’ler, servisler.
Başka bir bakış açısıyla da servis sınıfları içerisinde repository kullanıyorsak, servis bizim high level modüldür. Servis sınıfının içerisinde kullanılan repository ise low level modüldür. Ve işte bu repositoryler, servisler, controller’ların arasındaki ilişkinin soyutlama üzerinden gerçekleşmesi daha hayırlıdır.
Bir proje eğer dependency injection prensibini uygulamış ise sürdürebilir kod için iyi yönlüdür. Ayrıca çok daha kolay unit test yazılabilmektedir. Çünkü yapılar (modüller, sınıflar, bileşenler) birbirlerini tanımak yerine aralarında interface kullandığınız için (A class’ı high class modül, low level modül sınıfı bilmediğini için) farklı farklı servisler kayıt ederek kullanılabilir. Çünkü A sınıfı sadece interface’i biliyor.
❌❌ Uygunsuz Örnek ❌❌
public class EmailSender
{
public void SendEmail(string to, string subject, string body)
{
// SMTP email sending logic
}
}
Burada EmailSender
sınıfı doğrudan bir e-posta gönderme işlemini gerçekleştirmektedir. Bu durumda, EmailSender
sınıfı e-posta gönderimi işlevselliğinin yanı sıra, e-posta gönderimi için gerekli olan bağımlılıkları da yönetmektedir. Bu nedenle, EmailSender
sınıfı, Dependency Inversion Principle'a uymamaktadır.
✔️✔️ Uygun Örnek ✔️✔️
public interface IEmailService
{
void SendEmail(string to, string subject, string body);
}
public class SmtpEmailService : IEmailService
{
public void SendEmail(string to, string subject, string body)
{
// SMTP email sending logic
}
}
public class EmailSender
{
private readonly IEmailService _emailService;
public EmailSender(IEmailService emailService)
{
_emailService = emailService;
}
public void SendEmail(string to, string subject, string body)
{
_emailService.SendEmail(to, subject, body);
}
}
Yukarıdaki kod, Dependency Inversion Principle’a uyuyor çünkü EmailSender sınıfı, somut bir SmtpEmailService nesnesine bağımlı olmak yerine soyut bir IEmailService referansına bağımlıdır. Bu daha esnek ve kolay değiştirilebilir bir kod sağlar. Örneğin, gelecekte başka bir e-posta servisiyle çalışmak istediğimizde, yalnızca yeni bir sınıf oluşturup IEmailService arayüzünü uygulamak yeterli olacaktır. Böylece EmailSender sınıfı üzerinde herhangi bir değişiklik yapmamız gerekmez.