Ana içeriğe geç
Kesit Python
İleri · Mimari

Tasarım kalıpları Python'da

Strateji, fabrika, gözlemci, tekil, adaptör ve komut kalıplarını Python'a özgü biçimde uygula; fonksiyonların birinci sınıf olduğu yerde kalıbın gereksizleştiğini gör.

Uygulamada aç, 21 alıştırmayı çözÜcretsiz, hesap gerekmez, kurulum yok.

Bu sayfa dersin okuma sürümüdür. Metin bir kutuyu çalıştırmanı ya da adıma geçmeni söylüyorsa dersi uygulamada aç.

Neden önemli?

Tasarım kalıpları (design patterns), tekrar eden tasarım sorunlarına verilmiş isimli çözümlerdir. Kod incelemesinde (code review) "burada strateji kalıbı daha uygun" cümlesi, dakikalarca anlatımın yerini tutar. Ama kalıpların çoğu Java/C++ dünyasında, fonksiyonların nesne olmadığı bir ortamda doğdu. Python'da fonksiyonlar birinci sınıf (first-class) değerdir, sınıflar çalışma zamanında oluşturulabilir, modüller zaten tekil nesnedir. Bu yüzden bu derste her kalıbı iki kez göreceksin: klasik biçimi ve Python'da sade biçimi. Hedef, kalıbı ezberlemek değil; ne zaman gerektiğini ve ne zaman gerekmediğini ayırt etmektir.

Strateji: değiştirilebilir davranış

Anadolu Market'te kargo ücreti şehre, ağırlığa ve kampanyaya göre farklı kurallarla hesaplanıyor. Klasik çözüm bir KargoStratejisi soyut sınıfı ve alt sınıflarıdır. Python'da aynı iş, aynı imzaya sahip fonksiyonlar ve bir sözlükle çözülür.

from typing import Callable

Strateji = Callable[[float, str], float]   # (agirlik_kg, sehir) -> ücret

def sabit(agirlik, sehir):
    return 49.90

def agirlik_bazli(agirlik, sehir):
    return 20 + 8 * agirlik

def bolgesel(agirlik, sehir):
    uzak = {'Hakkari', 'Kars', 'Van'}
    return agirlik_bazli(agirlik, sehir) * (1.5 if sehir in uzak else 1.0)

STRATEJILER: dict[str, Strateji] = {
    'sabit': sabit, 'agirlik': agirlik_bazli, 'bolgesel': bolgesel,
}

def kargo_ucreti(agirlik, sehir, strateji: str = 'agirlik'):
    hesapla = STRATEJILER[strateji]
    return round(hesapla(agirlik, sehir), 2)

for s in STRATEJILER:
    print(f'{s:<9}', kargo_ucreti(3.5, 'Van', s))
Kalıp gerekmeyen durumStrateji, "davranışı parametre olarak geçir" demektir. Python'da bu zaten sorted(key=...), map, dekoratör ile her gün yaptığın şeydir. Stratejinin durumu (state) ve birden fazla metodu varsa o zaman sınıf (ya da Protocol) düşün.

Fabrika ve kayıt (registry)

Fabrika (factory), "hangi sınıfın üretileceğini" çağıran koddan gizler. Ilgaz Bank'ta rapor türüne göre farklı dışa aktarıcı üretmek gibi. Python'da en temiz biçim: bir sözlükte sınıfları tut, __init_subclass__ ya da dekoratörle otomatik kaydet. Sınıflar zaten çağrılabilir olduğu için ayrı bir "Creator" hiyerarşisine gerek kalmaz.

import json, csv, io

KAYIT: dict[str, type] = {}

def disa_aktarici(format_adi):
    def kaydet(cls):
        KAYIT[format_adi] = cls
        return cls
    return kaydet

@disa_aktarici('json')
class JsonAktar:
    def yaz(self, satirlar):
        return json.dumps(satirlar, ensure_ascii=False)

@disa_aktarici('csv')
class CsvAktar:
    def yaz(self, satirlar):
        buf = io.StringIO()
        w = csv.DictWriter(buf, fieldnames=list(satirlar[0]))
        w.writeheader(); w.writerows(satirlar)
        return buf.getvalue().strip()

def aktarici_uret(format_adi: str):
    try:
        return KAYIT[format_adi]()
    except KeyError:
        raise ValueError(f'desteklenmeyen format: {format_adi!r}; seçenekler: {sorted(KAYIT)}')

veri = [{'hesap': 'TR01', 'bakiye': 1500.0}, {'hesap': 'TR02', 'bakiye': -20.5}]
for f in ('json', 'csv'):
    print(aktarici_uret(f).yaz(veri))
try:
    aktarici_uret('xml')
except ValueError as e:
    print(e)

Gözlemci: olaylara abone olmak

Gözlemci (observer), bir olay olduğunda ilgilenen herkese haber verir; yayıncı aboneleri tanımaz. Sipariş onaylanınca stok düşsün, e-posta gitsin, muhasebe kaydı açılsın. Python'da abone bir fonksiyon, abone listesi de bir listedir.

from collections import defaultdict

class OlayYayini:
    def __init__(self):
        self._aboneler = defaultdict(list)

    def abone_ol(self, olay, fn=None):
        if fn is None:                 # @bus.abone_ol('olay') biçimi: dekoratör döndür
            return lambda f: self.abone_ol(olay, f)
        self._aboneler[olay].append(fn)
        return fn

    def yayinla(self, olay, **veri):
        for fn in self._aboneler[olay]:
            fn(**veri)

bus = OlayYayini()
stok = {'kahve': 10}

@bus.abone_ol('siparis_onay')
def stok_dus(urun, adet, **_):
    stok[urun] -= adet
    print(f'stok güncellendi: {urun}={stok[urun]}')

@bus.abone_ol('siparis_onay')
def eposta(musteri, **_):
    print(f'e-posta: {musteri} siparişiniz onaylandı')

bus.yayinla('siparis_onay', urun='kahve', adet=3, musteri='ayse@x.com')
bus.yayinla('iade', urun='kahve')     # abone yok, sessizce geçer
Abone hatalarıBir abonenin fırlattığı istisna diğer abonelere ulaşmayı engeller. Üretim kodunda her aboneyi try/except ile sarıp loglamak, ya da hataları toplayıp sonunda raporlamak gerekir. Ayrıca abonelerin çağrı sırasına güvenme; sıraya bağımlılık varsa gözlemci yanlış kalıptır.

Tekil (singleton) ve neden modül daha iyi

Tekil, bir sınıftan yalnızca tek örnek olmasını garanti eder: yapılandırma, bağlantı havuzu, logger. Klasik gerçekleştirme __new__ üzerinden yapılır. Ancak Python'da her modül yalnızca bir kez yüklenir ve zaten tekildir: config.py içinde AYARLAR = {...} yazmak, aynı garantiyi sıfır kodla verir ve test ederken yerine sahte nesne koymak (monkeypatch) çok daha kolaydır.

class Ayarlar:
    _ornek = None
    def __new__(cls):
        if cls._ornek is None:
            cls._ornek = super().__new__(cls)
            cls._ornek.degerler = {'para_birimi': 'TRY'}
        return cls._ornek

a, b = Ayarlar(), Ayarlar()
print(a is b, a.degerler)

# Pythonic eşdeğer: modül düzeyinde tek nesne
import types
config = types.SimpleNamespace(para_birimi='TRY', kdv=0.20)
print(config.para_birimi, config.kdv)
KalıpÇözdüğü sorunKlasik biçimPython'da sade biçim
StratejiDeğiştirilebilir algoritmaSoyut sınıf + alt sınıflarFonksiyon sözlüğü, key=
FabrikaÜretimi gizlemekCreator hiyerarşisiKayıt sözlüğü + sınıf çağrısı
GözlemciGevşek bağlı bildirimSubject/Observer arayüzüFonksiyon listesi
TekilTek örnek__new__ ile kontrolModül düzeyi nesne
AdaptörUyumsuz arayüzSarmalayıcı sınıfSarmalayıcı sınıf ya da fonksiyon
Komutİşlemi nesne yapmak, geri almaexecute/undo arayüzüdataclass + Protocol

Adaptör: uyumsuz arayüzleri bağlamak

Eski bir ödeme kütüphanesi charge(amount_cents) bekliyor, yeni kodun ise tahsil_et(tutar_tl) çağırıyor. Hiçbirini değiştirmeden ikisini bir adaptörle bağlarsın. Adaptör ince olmalı: dönüştür, yönlendir, bitir.

class EskiOdemeKutuphanesi:          # değiştiremediğin dış kod
    def charge(self, amount_cents: int) -> dict:
        return {'ok': amount_cents > 0, 'cents': amount_cents}

class OdemeAdaptoru:
    def __init__(self, eski):
        self._eski = eski
    def tahsil_et(self, tutar_tl: float) -> bool:
        return self._eski.charge(round(tutar_tl * 100))['ok']

def siparisi_tamamla(saglayici, tutar):
    return 'tamam' if saglayici.tahsil_et(tutar) else 'reddedildi'

print(siparisi_tamamla(OdemeAdaptoru(EskiOdemeKutuphanesi()), 149.90))
print(siparisi_tamamla(OdemeAdaptoru(EskiOdemeKutuphanesi()), 0))

Komut: işlemi nesne yapmak

Komut (command) kalıbında her işlem; çalıştırılabilen ve geri alınabilen bir nesnedir. Geri al/yinele, işlem kuyruğu, denetim kaydı (audit log) için kullanılır. Ilgaz Bank'ta hesap hareketlerini komut olarak modellemek, hatalı bir toplu işlemi tersine çevirmeyi mümkün kılar.

from dataclasses import dataclass, field

@dataclass
class YatirKomutu:
    hesaplar: dict
    hesap: str
    tutar: float
    def uygula(self):
        self.hesaplar[self.hesap] += self.tutar
    def geri_al(self):
        self.hesaplar[self.hesap] -= self.tutar

@dataclass
class TransferKomutu:
    hesaplar: dict
    kaynak: str
    hedef: str
    tutar: float
    def uygula(self):
        if self.hesaplar[self.kaynak] < self.tutar:
            raise ValueError('yetersiz bakiye')
        self.hesaplar[self.kaynak] -= self.tutar
        self.hesaplar[self.hedef] += self.tutar
    def geri_al(self):
        self.hesaplar[self.hedef] -= self.tutar
        self.hesaplar[self.kaynak] += self.tutar

@dataclass
class Islemci:
    gecmis: list = field(default_factory=list)
    def calistir(self, komut):
        komut.uygula()
        self.gecmis.append(komut)
    def geri_al(self):
        if self.gecmis:
            self.gecmis.pop().geri_al()

h = {'TR01': 100.0, 'TR02': 0.0}
p = Islemci()
p.calistir(YatirKomutu(h, 'TR01', 50))
p.calistir(TransferKomutu(h, 'TR01', 'TR02', 120))
print(h)
p.geri_al(); print('transfer geri alındı:', h)
p.geri_al(); print('yatırma geri alındı:', h)
Derinleş

Derste olmayan altı kalıp, Python'ca

Strateji, fabrika, gözlemci, adaptör ve komutu gördün. Kurumsal Python kodunda en az onlar kadar sık karşına çıkan altı kalıp daha var. Hepsinin ortak noktası, "değişecek olanı değişmeyenden ayırmak"tır. Python'da çoğu, sınıf hiyerarşisi yerine birkaç fonksiyonla ya da küçük bir sözlükle kurulur.

KalıpÇözdüğü sorunPython'daki kısa yoluNerede görürsün?
Durum (State)Nesnenin davranışı durumuna göre değişir, if/elif patlarGeçiş tablosu sözlüğü ya da EnumSipariş, kredi başvurusu akışı
Sorumluluk zinciri (Chain of responsibility)İstek birkaç adımdan sırayla geçmeliFonksiyon listesi, ara katman (middleware)Web çatıları, doğrulama hattı
Şablon metot (Template method)Algoritmanın iskeleti sabit, adımları değişirTaban sınıfta somut metot + soyut adımlarRapor üretimi, ETL işleri
Kurucu (Builder)Çok parçalı nesneyi adım adım kurmakself döndüren akıcı (fluent) metotlarSQL sorgu kurucular, HTTP istemcileri
Depo (Repository) + bağımlılık enjeksiyonuİş kuralı veritabanına yapışık, test edilemezDepoyu kurucuya parametre olarak verServis katmanı, birim testler
Süsleyici (Decorator) nesneNesneye çalışma anında yetenek eklemekAynı arayüzü uygulayan sarmalayıcı sınıfLoglama, önbellek (cache), yeniden deneme

Durum makinesi: geçişleri veri olarak yaz

Anadolu Market siparişi yeni → ödendi → kargoda → teslim akışında ilerler; iptal yalnızca kargoya verilmeden önce mümkündür. Bu kuralları if/elif ile her metoda dağıtırsan yeni bir durum eklemek tüm metotları değiştirmeyi gerektirir. Geçiş tablosu kuralları tek yere toplar, tablo aynı zamanda belge görevi görür.

GECISLER = {
    "yeni":   {"ode": "odendi", "iptal": "iptal"},
    "odendi": {"kargola": "kargoda", "iptal": "iptal"},
    "kargoda": {"teslim": "teslim"},
}

class Siparis:
    def __init__(self, no):
        self.no, self.durum, self.gecmis = no, "yeni", ["yeni"]

    def olay(self, ad):
        hedef = GECISLER.get(self.durum, {}).get(ad)
        if hedef is None:
            raise ValueError(f"{self.durum!r} durumunda {ad!r} yapılamaz")
        self.durum = hedef
        self.gecmis.append(hedef)

s = Siparis(1042)
for ad in ["ode", "kargola", "iptal"]:
    try:
        s.olay(ad)
    except ValueError as h:
        print("Reddedildi:", h)
print(s.durum, s.gecmis)

Sorumluluk zinciri: ara katmanlar

Bir API isteği önce kimlik doğrulamadan, sonra hız sınırından, sonra loglamadan geçip asıl işleyiciye ulaşır. Her katman ya isteği reddeder ya da bir sonrakine devreder. Flask, Django ve FastAPI'deki middleware'ler bu kalıptır. Python'da her katman (istek, sonraki) alan bir fonksiyon olabilir.

def kimlik(istek, sonraki):
    if not istek.get("token"):
        return {"durum": 401}
    return sonraki(istek)

def log(istek, sonraki):
    yanit = sonraki(istek)
    print(f"{istek['yol']} -> {yanit['durum']}")
    return yanit

def isleyici(istek):
    return {"durum": 200, "veri": "bakiye: 1500"}

def zincir(katmanlar, son):
    for katman in reversed(katmanlar):
        son = (lambda k, s: lambda istek: k(istek, s))(katman, son)
    return son

uygulama = zincir([log, kimlik], isleyici)
uygulama({"yol": "/hesap", "token": "abc"})
uygulama({"yol": "/hesap"})

Şablon metot ve kurucu

Şablon metotta taban sınıf algoritmanın sırasını sabitler (uret(): veriyi getir, süz, biçimle), alt sınıflar yalnızca değişen adımları doldurur. Strateji kalıbıyla farkı: strateji davranışı dışarıdan verilen bir nesneyle değiştirir (bileşim), şablon metot ise kalıtımla (inheritance) değiştirir. Kurucu (builder) ise çok seçenekli bir nesneyi zincirleme çağrılarla kurar; her metot self döndürür, en sonda sql(), build() gibi bir metot sonucu üretir. SQLAlchemy'nin select(...).where(...).order_by(...) yazımı buna örnektir.

from abc import ABC, abstractmethod

class Rapor(ABC):
    def uret(self):                         # şablon: sıra sabit
        satirlar = [s for s in self.veri() if self.dahil_mi(s)]
        return self.bicimle(satirlar)

    @abstractmethod
    def veri(self): ...

    def dahil_mi(self, satir):              # isteğe bağlı kanca (hook)
        return True

    @abstractmethod
    def bicimle(self, satirlar): ...

class BuyukSiparisCSV(Rapor):
    def veri(self):
        return [("İzmir", 1200), ("Bursa", 300), ("Ankara", 5400)]
    def dahil_mi(self, s):
        return s[1] >= 1000
    def bicimle(self, satirlar):
        return "\n".join(f"{a};{t}" for a, t in satirlar)

print(BuyukSiparisCSV().uret())

Depo kalıbı ve bağımlılık enjeksiyonu

İş kuralı içinde sqlite3.connect("prod.db") yazarsan o kuralı veritabanı olmadan test edemezsin. Çözüm: veri erişimini bir depo (repository) arayüzünün arkasına koy ve servisi kurarken depoyu parametre olarak ver (dependency injection). Üretimde SQL deposu, testte bellek içi sözlük deposu verilir; servis kodu değişmez. Python'da bunun için çatıya gerek yoktur: kurucu parametresi yeter. Arayüzü Protocol ile tanımlamak (ileri OOP dersi) tip denetleyicisine de sözleşmeyi bildirir.

from typing import Protocol

class HesapDeposu(Protocol):
    def bakiye(self, no: str) -> int: ...
    def guncelle(self, no: str, yeni: int) -> None: ...

class BellekDepo:
    def __init__(self, veri): self.veri = dict(veri)
    def bakiye(self, no): return self.veri[no]
    def guncelle(self, no, yeni): self.veri[no] = yeni

class HavaleServisi:
    def __init__(self, depo: HesapDeposu):
        self.depo = depo                     # enjekte edilen bağımlılık
    def havale(self, kimden, kime, tutar):
        if self.depo.bakiye(kimden) < tutar:
            raise ValueError("Yetersiz bakiye")
        self.depo.guncelle(kimden, self.depo.bakiye(kimden) - tutar)
        self.depo.guncelle(kime, self.depo.bakiye(kime) + tutar)

depo = BellekDepo({"TR1": 1000, "TR2": 0})
HavaleServisi(depo).havale("TR1", "TR2", 250)
print(depo.veri)
Kalıp için kalıp yazmaÜç satırlık bir if bloğunu durum makinesine, tek kullanımlık bir nesneyi kurucuya çevirmek kodu zorlaştırır. Kalıbı, aynı değişikliği ikinci ya da üçüncü kez yaparken hissettiğin acıya cevap olarak getir.
Gerçek işteMülakatlarda "hangi tasarım kalıplarını biliyorsun?" sorusunun iyi cevabı bir liste değil, bir hikâyedir: "Sipariş durumları her sprintte değişiyordu, geçiş tablosuna taşıdık; artık yeni durum bir satırlık değişiklik ve tablo ürün ekibiyle paylaştığımız belge oldu." Kalıbın adını, çözdüğü sorunu ve bedelini birlikte anlat.
Uzmanlaş

Kalıp seçiminde kurumsal refleksler

Kod incelemelerinde en sık görülen kalıp hatası "fazla mimari"dir: üç satırlık bir koşul için strateji hiyerarşisi, iki format için fabrika sınıfı. Kuralı şöyle koy: kalıbı, aynı sorunu ikinci kez çözerken tanıt, birincide değil. Birinci sınıf fonksiyonlar, functools.partial, dataclass ve Protocol, klasik kalıpların büyük kısmını iki üç satıra indirir.

Buna karşılık bazı kalıplar Python'da bile sınıf ister: komut (durum + geri alma), gözlemci (abone yönetimi, hata izolasyonu), adaptör (dış sistem sınırı). Bunların ortak noktası, kodun bir sınırda durmasıdır: dış kütüphane, ağ, veritabanı, kullanıcı etkileşimi. Sınırlarda açık arayüz (Protocol) tanımla; içeride sade fonksiyonlarla kal.

Testte kalıpların karşılığı: strateji sözlüğünü testte sahte fonksiyonla değiştir; gözlemcide aboneleri listeye toplayıp yayın sonrası kontrol et; komutlarda uygula + geri_al sonrası durumun başlangıca döndüğünü doğrula (round-trip testi). Tekil yerine modül düzeyi nesne kullanmanın en büyük getirisi de burada: monkeypatch.setattr(config, 'kdv', 0) ile tek satırda izole test.

Alıştırmalara geç (21)