Software · Feb 7, 2025 · 10 dk okuma
Coupling ve Cohesion: İyi Yazılım Tasarımının Görünmeyen İki Temeli
Bir değişiklik neden 15 dosyayı etkiliyor? Coupling ve cohesion, kodun nasıl organize edildiğinden çok değişikliklerin sistem içinde nasıl yayıldığını anlamamızı sağlar. İyi modüler tasarımın ölçüsü, dosya sayısı değil değişiklik maliyetidir.

Yazılım tasarımının kalitesi, kodun ne kadar düzenli göründüğünden çok, bir gereksinim değiştiğinde sistemin nasıl tepki verdiğiyle anlaşılır. Coupling, modüllerin birbirine ne ölçüde bağımlı olduğunu; cohesion ise bir modülün içindeki sorumlulukların ne kadar anlamlı bir bütün oluşturduğunu anlatır. İyi bir tasarım genellikle loose coupling ve high cohesion hedefler. Fakat bu iki kavramı yalnızca sınıfların birbirini çağırması veya dosyaların kaç satır olduğu üzerinden değerlendirmek, asıl problemi kaçırır.
Asıl mesele değişikliğin maliyetidir. Bir iş kuralını güncellemek için 15 dosyayı değiştirmek zorunda kalıyorsanız, sistemin dependency yapısı size bir şey söylüyordur. Ancak 15 dosyanın değişmesi tek başına kötü tasarım kanıtı değildir. Önemli olan bu değişikliklerin aynı nedenle mi yapıldığı, yoksa tek bir kararın ilgisiz modüllere mi yayıldığıdır. Coupling ve cohesion bu ayrımı yapabilmek için kullanılır.
Coupling, bağımlılığın değişiklik maliyetidir
Coupling, iki yazılım bileşeni arasındaki bağımlılığın derecesidir. Bir modülün çalışması veya değişmesi başka bir modülün iç yapısını bilmeyi gerektiriyorsa, aralarında güçlü bir coupling vardır. Bağımlılık yalnızca method çağrısı değildir; paylaşılan database tabloları, event payload'ları, configuration değerleri ve örtük iş kuralları da coupling yaratır.
Tight coupling, bir bileşenin başka bir bileşenin uygulama ayrıntılarına sıkı biçimde bağlanmasıdır. Örneğin bir sipariş servisi, ödeme sağlayıcısının SDK'sını doğrudan kullanıyor ve sağlayıcının response formatına göre iş kararları veriyorsa, ödeme sağlayıcısındaki değişiklik sipariş mantığını etkileyebilir.
class OrderService {
async checkout(order: Order) {
const gateway = new StripeGateway();
const result = await gateway.charge(order.total);
if (result.status === 'succeeded') {
order.status = 'paid';
}
}
}Buradaki sorun yalnızca new StripeGateway() kullanılması değildir. OrderService, dış sistemin succeeded değerini de kendi iş mantığının parçası haline getirmiştir. SDK değişirse, başka bir sağlayıcıya geçilirse veya ödeme süreci asynchronous hale gelirse sipariş modülünün değişmesi gerekebilir.
Loose coupling, bileşenlerin birbirleriyle sınırlı ve kararlı sözleşmeler üzerinden ilişki kurmasıdır. Bu yaklaşımda sipariş modülü, ödeme sağlayıcısının nasıl çalıştığını bilmek zorunda değildir.
type PaymentResult = {
accepted: boolean;
};
interface PaymentGateway {
charge(amount: number): Promise<PaymentResult>;
}
class OrderService {
constructor(private payment: PaymentGateway) {}
async checkout(order: Order) {
const result = await this.payment.charge(order.total);
if (result.accepted) {
order.status = 'paid';
}
}
}Burada dependency injection kullanılır: OrderService, ihtiyaç duyduğu bağımlılığı kendi oluşturmak yerine dışarıdan alır. Böylece ödeme sağlayıcısına ilişkin kod farklı bir modülde tutulabilir.
Ancak interface eklemek bağımlılığı ortadan kaldırmaz. Yalnızca bağımlılığın yönünü ve şeklini değiştirir. PaymentGateway sözleşmesi sürekli değişiyorsa veya sağlayıcıya özgü alanları dışarı sızdırıyorsa coupling hâlâ yüksektir.
Daha önemlisi, bu örnek yalnızca dependency sınırını gösterir. Gerçek bir ödeme sisteminde accepted her zaman tahsilatın tamamlandığı anlamına gelmez. Asynchronous ödeme, başarısız tahsilat, retry ve idempotency gibi konular ayrıca modellenmelidir. İyi abstraction, gerçek iş semantiğini gizleyerek değil, doğru yerde temsil ederek coupling azaltır.
Cohesion, sorumlulukların neden birlikte durduğunu açıklar
Cohesion, bir modülün içindeki öğelerin ortak bir amaca ne kadar güçlü biçimde hizmet ettiğini anlatır. High cohesion, birlikte değişmesi beklenen davranışların anlamlı bir sınır içinde toplanmasıdır. Low cohesion ise farklı nedenlerle değişen sorumlulukların aynı yerde bulunmasıdır.
Aşağıdaki sınıf ilk bakışta kullanışlı görünebilir:
class UserManager {
createUser() {}
validatePassword() {}
sendWelcomeEmail() {}
generateMonthlyReport() {}
exportUsersToCsv() {}
}Bu sınıfın sorunu beş method içermesi değildir. Kullanıcı oluşturma kuralları, email gönderimi ve raporlama farklı nedenlerle değişir. Email sağlayıcısı değiştiğinde raporlama koduyla aynı modülü düzenlemek veya test etmek zorunda kalmak, sorumlulukların yanlış yerde birleştiğine işaret eder.
Daha anlamlı bir ayrım şöyle olabilir:
class UserRegistrationService {
register() {}
}
class WelcomeEmailSender {
send() {}
}
class UserReportGenerator {
generateMonthly() {}
exportCsv() {}
}Bu ayrımın amacı class sayısını artırmak değildir. Her modülün değişmesi için daha belirgin bir neden oluşturur. Bununla birlikte generateMonthly ve exportCsv davranışları farklı ekipler tarafından, farklı zamanlarda ve farklı kurallarla değişiyorsa onların da ayrılması gerekebilir.
High cohesion, bütün ilgili işlemleri tek bir büyük sınıfa doldurmak anlamına gelmez. Bir modül, tek bir iş yeteneğini gerçekleştirmek için birden fazla class, function ve dosya içerebilir. Modül sınırını belirleyen şey fiziksel dosya sayısı değil, davranışların ortak değişim nedenidir.
Bu nedenle cohesion değerlendirilirken şu soru işe yarar: Bu modülün içindeki kod parçaları hangi iş kararı değiştiğinde birlikte değişiyor?
Cevap verilemiyorsa modül muhtemelen teknik bir kategori etrafında kurulmuştur. Helpers, Common, Utils ve zamanla büyüyen Manager sınıfları bu durumun sık görülen örnekleridir. Fakat Utils isminin varlığı tek başına problem değildir; problem, birbirinden bağımsız sorumlulukların aynı değişiklik sınırını paylaşmasıdır.
Interface kullanmak coupling sorununu tek başına çözmez
Coupling hakkında yaygın açıklama şudur: Bir class başka bir class'ı doğrudan kullanıyorsa tight coupling vardır; araya interface koyarsanız loose coupling elde edersiniz.
Bu açıklama eksiktir. Çünkü coupling yalnızca kaynak kodda görünen dependency graph üzerinden oluşmaz. Asıl mekanizma, bir kararın kaç bileşen tarafından bilinmesi gerektiğidir. Dependency graph, modüller arasındaki bağımlılık yönlerini gösterir; fakat dependency'nin ne kadar istikrarlı olduğunu veya hangi değişiklikte kırılacağını tek başına söylemez.
Örneğin bir e-ticaret sisteminde sipariş durumları şu şekilde tanımlanmış olsun:
type OrderStatus =
| 'pending'
| 'paid'
| 'shipped'
| 'cancelled';Bu durum değerleri ödeme, stok, bildirim, raporlama ve müşteri arayüzünde ayrı ayrı yorumlanıyorsa, yeni bir partially_refunded durumu eklemek birçok modülü etkileyebilir.
Her modül kendi interface'ini kullanıyor olsa bile değişiklik yayılır. Çünkü modüller aynı iş kuralının ayrıntılarına bağımlıdır. Burada coupling, doğrudan class referanslarından değil, paylaşılan semantik bilgiden kaynaklanır.
Bu tür bağımlılık özellikle tehlikelidir. Kod seviyesinde temiz görünen bir mimari, davranış seviyesinde sıkı biçimde bağlı olabilir.
Dependency inversion bu sorunu azaltmak için yararlıdır. Üst düzey iş politikalarının, alt düzey teknik ayrıntılara değil, uygun abstraction'lara bağımlı olmasını hedefler. Ancak abstraction yanlış yerde tanımlanırsa yalnızca mevcut coupling başka bir dosyaya taşınmış olur.
Örneğin IOrderService, IOrderRepository ve IOrderManager oluşturmak, iş kurallarını daha iyi ayırdığınız anlamına gelmez. Bu interface'ler aynı değişikliklerde birlikte güncelleniyorsa mimari hâlâ değişiklik yayılımına açıktır.
Bir başka sorun da zamansal coupling'dir. İki method doğru sırayla çağrılmak zorundaysa bileşenler arasında görünmeyen bir bağımlılık vardır:
await orderService.reserveStock(order);
await orderService.capturePayment(order);
await orderService.confirmOrder(order);Bu sıra bozulduğunda veri tutarsızlığı oluşabiliyorsa, method'ların ayrı interface'lerde bulunması sistemi bağımsız yapmaz. İş akışının sahibi belirlenmeli ve başarısızlık durumları açıkça modellenmelidir.
Loose coupling, hiçbir bağımlılık olmaması demek değildir. Amaç, değişmesi muhtemel ayrıntıları doğru sınırların arkasına almak ve gerekli bağımlılıkları görünür kılmaktır.
Bir değişiklik neden 15 dosyayı etkiliyor?
Bir backend geliştiricisinin sipariş iptali üzerinde çalıştığını düşünelim. Gereksinim değişmiştir: Önceden yalnızca ödeme yapılmamış siparişler iptal edilebilirken artık ödeme alınmış fakat henüz gönderilmemiş siparişler de iptal edilebilecektir. İkinci durumda refund başlatılması gerekir.
Geliştirici OrderController.ts dosyasını açar. Ardından OrderService.ts, PaymentService.ts, OrderRepository.ts, NotificationService.ts ve çeşitli test dosyaları gelir. Bir süre sonra değişiklik 15 dosyaya ulaşır.
Bu sayı tek başına bir tasarım kusuru göstermez. Önce dosyaların neden değiştiğini ayırmak gerekir.
Yeni davranışın API contract'ını değiştiren dosyalar, gerekli database migration ve ilgili testler meşru değişiklikler olabilir. Buna karşılık aynı iptal koşulunun controller, service, frontend ve notification katmanlarında ayrı ayrı yeniden yazılması farklı bir sorundur.
Kötü ayrılmış bir sistemde kontrol şu şekilde dağılmış olabilir:
// OrderController.ts
if (order.status === 'pending') {
await cancelOrder(order);
}
// NotificationService.ts
if (order.status === 'pending') {
await sendCancellationEmail(order);
}
// RefundService.ts
if (order.status === 'paid') {
await refund(order);
}Burada iptal edilebilirlik kuralının tek bir sahibi yoktur. Her bileşen sipariş durumunu kendi bağlamında yorumlar. Gereksinim değiştiğinde geliştirici bütün yorumları bulmak zorundadır.
Daha iyi bir sınır, iptal kararını domain katmanında temsil etmektir. Domain, sistemin iş kurallarını ve bunların kullandığı kavramları ifade eden bölümdür.
type CancellationDecision =
| { allowed: false; reason: string }
| { allowed: true; refundRequired: boolean };
function decideCancellation(
status: OrderStatus
): CancellationDecision {
switch (status) {
case 'pending':
return { allowed: true, refundRequired: false };
case 'paid':
return { allowed: true, refundRequired: true };
default:
return {
allowed: false,
reason: 'Order cannot be cancelled'
};
}
}Bu function, iptal edilebilirlik kararını tek noktada toplar. Application service kararı kullanarak gerekli işlemleri başlatır; controller ise kararı yeniden üretmek yerine sonucu HTTP response'a dönüştürür.
Ancak merkezi karar function'ı tek başına güvenli bir iptal süreci oluşturmaz. paid durumundaki sipariş için refund çağrısı başarısız olabilir. Stok rezervasyonu kaldırılırken ödeme işlemi tamamlanmamış olabilir. İki istek aynı siparişi eşzamanlı iptal etmeye çalışabilir.
Bu nedenle gerçek uygulamada bir state transition tasarlanmalıdır. State transition, siparişin bir durumdan diğerine hangi koşullarda geçebildiğini tanımlar. Database transaction, optimistic locking veya uygun bir concurrency kontrolü aynı sipariş üzerinde çakışan değişiklikleri engelleyebilir.
Ödeme sağlayıcısı dış sistem olduğu için yerel database transaction ile refund işlemini atomik hale getirmek genellikle mümkün değildir. Bu durumda kalıcı bir refund isteği kaydı, idempotency key ve retry mekanizması gerekebilir. Refund'un tamamlanması ile iptal talebinin kabul edilmesi ayrı durumlar olarak modellenebilir.
Buradaki tasarım kazancı bütün işlemleri tek dosyaya taşımak değildir. İptal politikasının tek sahibi olurken ödeme entegrasyonu kendi teknik sorumluluğunu korur. Böylece iş kuralındaki değişiklik ile sağlayıcı API'sindeki değişiklik farklı modüllerde karşılanabilir.
Değişikliğin yayılma etkisini değerlendirmek için Git geçmişi de kullanılabilir. Aynı iş kuralı değiştiğinde sürekli birlikte düzenlenen dosyalar, olası bir modül sınırına işaret eder. Fakat birlikte değişme, tek başına aynı modülde bulunmaları gerektiğini kanıtlamaz; geniş kapsamlı refactor, formatting veya ortak dependency güncellemesi de aynı görüntüyü oluşturabilir.
Modüler tasarım, doğru bağımlılık sınırını seçmektir
Modüler tasarımın amacı sistemi mümkün olan en fazla parçaya bölmek değildir. Amaç, bir parçanın değişme nedenini diğerlerinden mümkün olduğunca bağımsız hale getirmektir.
Bu nedenle önce iş davranışları belirlenmeli, sonra dependency sınırları çizilmelidir. Dosya yapısından başlayarak iş kurallarını rastgele klasörlere dağıtmak, temiz görünen ama değişmesi zor bir sistem oluşturabilir.
Benim tercih ettiğim yaklaşım, abstraction eklemeden önce değişiklik nedenlerini incelemektir. Bir dış servis değişebilir, bir iş politikası farklılaşabilir veya bir veri modeli yeni alanlar kazanabilir. Bunların her biri aynı tasarım müdahalesini gerektirmez.
Örneğin bir ödeme sağlayıcısının değiştirilmesi bekleniyorsa PaymentGateway gibi bir interface anlamlıdır. Fakat uygulama yalnızca tek bir sağlayıcı kullanıyorsa ve sağlayıcının SDK'sı zaten küçük bir adapter içinde izole edilmişse, birden fazla abstraction katmanı oluşturmak ek bakım maliyeti getirebilir.
Her abstraction bir sözleşme yaratır. Sözleşmenin versioning, testing ve hata yönetimi maliyeti vardır. Yanlış abstraction, doğrudan dependency'den daha zor değiştirilebilir hale gelebilir.
Aynı durum microservices için de geçerlidir. Bir monolith içindeki iki modül yüksek coupling taşıyorsa onları ayrı servislere taşımak coupling'i ortadan kaldırmaz. Bağımlılık artık function çağrısı yerine network request, event contract veya paylaşılan veri beklentisi üzerinden gerçekleşir.
Üstelik dağıtık sistemlerde latency, partial failure ve veri tutarlılığı gibi yeni sınırlar ortaya çıkar. Bu yüzden modülerlik ile fiziksel dağıtım birbirinden ayrı kararlardır. İyi ayrılmış bir monolith, kötü ayrılmış bir microservices mimarisinden daha bağımsız değiştirilebilir olabilir.
Cohesion ile coupling arasında da otomatik bir denge formülü yoktur. Çok küçük modüller yüksek cohesion gösterebilir, fakat aralarındaki koordinasyon maliyeti artabilir. Çok büyük bir modül ise güçlü bir domain sınırını temsil ettiği sürece anlamlı olabilir.
Bu nedenle class sayısını, interface sayısını veya dosya uzunluğunu kalite hedefi olarak kullanmam. Ölçmeye çalıştığım şey, belirli bir değişikliğin kaç bağımsız sorumluluğu etkilediğidir.
Bir refactor kararı verirken önce gerçek değişiklik örneklerini toplamak gerekir. Son birkaç feature veya bug fix sırasında hangi dosyalar birlikte değişmiş, hangi kurallar tekrar edilmiş, hangi testler ilgisiz nedenlerle kırılmış? Bu sorular bir dependency graph'tan daha açıklayıcı olabilir.
Ardından tek bir iş kuralını seçip sahibini belirlemek, genellikle geniş kapsamlı yeniden yazımdan daha güvenlidir. Mevcut davranışı characterization test'lerle sabitlemek, sınırı taşımak ve sonuçta değişiklik alanının daralıp daralmadığını görmek gerekir. Characterization test, mevcut davranışı doğru olduğunu varsaymadan kaydeden testtir; refactor sırasında istenmeyen davranış değişikliklerini yakalamaya yardımcı olur.
Bununla birlikte her tekrar eden koşulu ortak bir function'a taşımak da doğru değildir. İki iş akışı bugün aynı kurala sahip olabilir, ancak farklı nedenlerle değişeceklerse ortak abstraction gelecekte coupling yaratır. Kod tekrarının küçük bir kısmını kabul etmek, yanlış ortak bağımlılık oluşturmaktan daha ucuz olabilir.
Bu yüzden önerim, loose coupling ve high cohesion'ı ulaşılması gereken mutlak sayısal hedefler olarak değil, değişikliklerin daha yerel kalmasını sağlayan tasarım ilkeleri olarak kullanmaktır. Bir modülün sınırı, gerçek iş kararlarının değişim ritmine dayanmalıdır.
Bu öneri, modüller arasında gerçekten farklı değişim nedenleri bulunduğunda güçlüdür. İki bileşen aynı iş kuralı nedeniyle her zaman birlikte değişiyorsa, onları zorla ayırmak koordinasyon maliyetini artırabilir. Benzer şekilde performansın kritik olduğu düşük seviyeli kodda, belirli bir doğrudan bağımlılık soyutlama maliyetinden daha kabul edilebilir olabilir.
Karar verirken son soru şudur: Yarın bu iş kuralı değiştiğinde, değişikliğin nerede yapılacağını önceden biliyor muyuz? Cevap evetse ve ilgisiz bileşenleri açmadan değişikliği tamamlayabiliyorsak, modüler tasarım amacına yaklaşıyor demektir.
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.