# Muhammet Şafak — llms-full.txt > Bu dosya, sitedeki yayınlanmış içeriğin tamamını tek parça düz metin olarak taşır. Harita ve bölüm bağları için: https://muhammetsafak.com/tr/llms.txt - Üretim tarihi: 2026-10-08 - tr-TR --- # Blog ## 2026'da geliştirme ortamım: editör, terminal ve akış - Kaynak: https://muhammetsafak.com/tr/blog/2026da-gelistirme-ortamim/ Geliştirme ortamı yazıları genellikle bir araç listesidir. Bu yazı öyle değil; benim ortamım kolay kolay değişmez ve asıl anlatmak istediğim şey, **neyin niye değiştiği ya da neden değişmediği.** ## İşletim sistemi: gürültüden uzaklaşmak Yıllarca masaüstünde Windows, dizüstünde Ubuntu ile geliştirme yaptım. Bu yılın benim için köklü değişikliklerinden biri, taşınabilir cihaz olarak MacBook'a geçmek oldu. Artık hem gündelik hem de iş kullanımında birincil işletim sistemim macOS. Ubuntu, masaüstünde Windows'un yanına geçip ikincil sistemim oldu; Windows ise yalnızca oyun oynadığım bir sisteme indi. Bu geçişin sebebi tek cümleye sığıyor: işletim sistemi gürültüsünden sıyrılıp daha odaklı bir çalışma ortamı kurmak. ## Editör: tuttuğum tek söz, hızlı olması Kod yazmaya Notepad++ ile başladım; oradan VS Code'a ve JetBrains IDE'lerine uzanan yolda onlarca editör ve IDE denedim. Kariyerimin büyük bölümü VS Code ve JetBrains IDE'leriyle geçti. 2026'da bu tablo değişti: yapay zekâ geliştirme sürecime daha fazla girdikçe, kapsamlı ama hantal IDE'ler gündelik akışımın dışında kaldı. Artık bir editörden beklediğim en önemli şey hız. Hızlı açılmalı, hızlı aramalı, dosyalar arasında hızlı geçmeli. Geri kalan her şey — temalar, eklentiler — ikincil. Eklenti konusunda her zaman katı oldum, hâlâ da öyleyim: yalnızca her gün kullandığım ve gerçek bir ihtiyacımı çözen eklentiyi kuruyorum. Her eklenti, küçük de olsa bir yük ve bir kararsızlık kaynağı. Bir editörü "kişiselleştirmek" ile onu yavaşlatmak arasındaki çizgi düşündüğünüzden ince. ## Terminal: evim burası Günümün büyük bölümü terminalde geçiyor. Bir terminal çoğaltıcı (multiplexer) ile birden çok oturumu tek pencerede tutuyorum: bir bölmede çalışan uygulama, bir bölmede testler, bir bölmede git. Bağlam değiştirmeyi pencere aramaya değil, bir kısayola indirgemek günde onlarca küçük kesintiyi ortadan kaldırıyor. Sık yaptığım işleri kısa takma adlara (alias) ve küçük betiklere bağlıyorum. Bunların hiçbiri etkileyici değil; ama her biri, tekrar eden bir hareketi düşünmeden yapılır hâle getiriyor. Verimlilik genellikle parlak bir araçtan değil, sürtünmenin sessizce azaltılmasından gelir. ## Yapay zekâ araçları: yardımcı, sürücü değil Yapay zekâ araçlarını 2022'nin sonundan beri düzenli olarak kullanıyorum. 2025'e kadar benim için tarayıcıda açılan bir sekmeydiler; çoğunlukla analiz yaptırdığım bir yardımcı. 2026'da ise geliştirme ortamımın doğal bir parçası hâline geldiler. Ama yerlerini ve görev tanımlarını net çizdim. Mimariyi ve uygulanacak desenleri ben belirliyorum; hangi paketin projeye girip girmeyeceğinden yeni bir modülün ne kadar soyutlanacağına kadar bütün kararları ben veriyorum. Ortamımda yapay zekânın üç işi var: benim için kod yazmak, kodu benden önce denetleyip ilk okumayı yapmak ve kodun içinde benim için basit analizler yapmak. Karar vermek bu listede yok. ## Neden çoğu yeniliği denemeden geçiyorum Her ay yeni bir editör, yeni bir terminal, yeni bir araç çıkıyor. Eskiden hepsini denerdim. Artık denemiyorum — ve bu bir tembellik değil, bilinçli bir seçim. Bir aracı değiştirmenin görünmeyen bir maliyeti var: yeniden öğrenme, kas hafızasını yeniden kurma, ince ayarları yeniden yapma. Bu maliyeti ancak eldeki araç gerçekten yetersiz kaldığında ödemeye değer. Aracım işimi görüyorsa, "daha iyisi çıkmış olabilir" diye onu değiştirmek, kazanç değil dağılma getiriyor. ## Kurulumu taşınabilir tutmak Ortamımın sade olması kadar önemli bir şey daha var: taşınabilir olması. Yeni bir makineye geçtiğimde ya da bir disk değiştiğinde, kurulumumu saatlerce yeniden inşa etmek istemiyorum. Bu yüzden ortamımın yapılandırmasını — editör ayarları, terminal yapılandırması, takma adlar, git ayarları — tek bir depoda tutuyorum. Yeni bir makinede birkaç komutla her şey yerine geliyor. Bu alışkanlık başta gereksiz bir özen gibi görünür; ama makine değiştirmek zorunda kaldığınız ilk günde değerini fazlasıyla geri ödüyor. Bunun görünmeyen bir faydası daha var: yapılandırmanız bir depodaysa, üzerinde düşünmeye başlıyorsunuz. Hangi ayarın neden orada olduğunu biliyorsunuz; çünkü onu bir dosyaya yazıp kayıt altına aldınız. Ortamınız, üzerine yıllarca rastgele eklenmiş ayarların yığını olmaktan çıkıp bilinçli kurulmuş bir şeye dönüşüyor. ## Özet [İyi bir geliştirme ortamı](/tr/blog/laravel-homestead-ile-herkeste-ayni-gelistirme-ortami/), en yeni araçların toplamı değil; sizi düşünmekten alıkoymayan, sürtünmesi en aza inmiş, kararlı bir kurulumdur. Benimki bu yıl birkaç köklü değişiklik geçirdi; ama hepsinin arkasında aynı ilke duruyor: sürtünmeyi azaltmak. ## Üçüncü MCP sunucusu makuldü, sonrakiler değildi - Kaynak: https://muhammetsafak.com/tr/blog/proje-basina-mcp-sunucusu-kurmayi-birakmak/ Her projeye ayrı bir MCP sunucusu kurmak ilk üçünde makul görünüyordu. Sonra sunucular birbirinden ayrı yaşamaya başladı: hangisi hangi dizini indeksliyordu, hangisi en son ne zaman senkronlanmıştı, hangisinin kendi chunk'lama kodu vardı. Bu yazı, geliştirme ortamımdaki onlarca projenin dokümanını ajanlara açarken karşılaştığım dört gereksinimin dört tasarım kararına nasıl döndüğünün hikâyesi. Kısa cevap şu: ajana bağlam vermenin doğru birimi proje başına bir **sunucu** değil, proje başına bir **uç**. Bu dördünü kuran şey [Contextator](/tr/portfolyo/contextator/) oldu. ## Sorun token faturası değildi İlk teşhisim yanlıştı. Ajanın her oturumda projeyi baştan keşfetmesini bir maliyet kalemi sanıyordum: dosyaları geziyor, okuyor, aynı yapıyı tekrar tekrar çıkarıyor ve bunun bir faturası var. Fatura gerçek ama asıl mesele o değil. Keşfin bittiği yerde model durmuyor. Aradığı şeyi bulamadığında bir sonuç üretmeye devam ediyor ve boşluğu varsayımla dolduruyor. Bu sessiz oluyor: çıktıda "bu dosyayı bulamadım" diye bir satır olmuyor, makul görünen ama dayanaksız bir cevap oluyor. Token faturasını ayın sonunda görürsünüz; varsayımı ancak o kodu okuyan biri yakalarsa görürsünüz. Doğru cevap, modelin araması gereken yeri ona vermek — yani MCP. Zor olan protokol değil. Spesifikasyon (sürüm 2026-07-28) bunu şöyle tanımlıyor: "[Model Context Protocol (MCP) is an open protocol that enables seamless integration between LLM applications and external data sources and tools.](https://modelcontextprotocol.io/specification/2026-07-28)" ## Proje başına bir MCP sunucusu neden ölçeklenmiyor? Proje başına bir MCP sunucusu yazmak ilk seferinde birkaç saatlik iş. İkincisinde kopyala-yapıştır. Üçüncüsünde hâlâ makul. Beşincisinde şunu fark ediyorsunuz: her yeni proje aynı chunker'ı, aynı embedding döngüsünü ve aynı artımlı indeksleyiciyi bir kez daha yazmak demek — ve sonrasında hepsini ayrı ayrı sürdürmek. Biri yeni bir dosya tipini destekliyor, diğeri desteklemiyor. Biri silinen dosyaları indeksten düşürüyor, diğeri düşürmüyor. Hangisinin ne zaman senkronlandığını görmek için her birine ayrı bakmak gerekiyor. Üstüne bir de şu var: bir projenin dokümanı tek bir yerde durmuyor. Bir kısmı depoda, bir kısmı Notion'daki el kitabında, bir kısmı bir Obsidian kasasında, bir kısmı paylaşılan sürücüdeki bir arşivde. Tek klasör indeksleyen bir araç, önce bir insanın hepsini tek yere toplamasını bekliyor — ki bu hiç olmuyor. Contextator bu tekrarı tek kuruluma indiriyor. Bir proje, kaynaklarının toplamı: bağlı bir klasör, bir git deposu (ya da onun tek bir alt dizini), yüklenmiş bir arşiv, bir Obsidian kasası, bir Notion çalışma alanı. Hepsi tek uçta birleşiyor ve her kaynak kendi adının altına monte ediliyor, böylece bir doküman `handbook/install.md` diye okunuyor. ## Birinci karar: yalıtım bir ayar değil, adresin kendisi Her proje kendi URL'ini alıyor: `/mcp/`. `/mcp/billing`'e bağlı bir istemci `/mcp/mobile`'ı hiç görmüyor. Alternatifi tek bir uç açıp isteğe bir `project` parametresi koymaktı. O tasarımda yalıtım, ajanın doğru parametreyi göndermesine bağlı kalırdı — ve bir modelin argüman seçimine dayanan gizlilik, gizlilik değildir. Bedeli açık ve peşinen kabul edilmiş: **projeler arası arama yok.** "Şu deseni hangi projede kullanmıştım?" diye tek soruda bakamıyorsunuz. Bu bir eksik değil, kararın kendisi: bakılabilseydi her arama sessizce her projeye yayılırdı. ## İkinci karar: çalışmak için anahtar istememesi Embedding'ler varsayılan olarak makinenin CPU'sunda üretiliyor. Sebep maliyetten önce şu: sürekli sorgulanmak üzere kurulmuş bir aracın **çalışmak için** bir API anahtarı istemesi, "kendi sunucunda çalışır" vaadini baştan bozar. Birkaç ortam değişkeniyle OpenAI embedding'lerine geçmek mümkün, ama varsayılan yol dışarıya hiç çıkmıyor. Bedeli ilk açılışta ödeniyor: model indiriliyor ve container'ın ayağa kalkması birkaç dakika sürüyor. Sonraki başlangıçlar saniyeler sürüyor. Kurulumu tek komuta indirmek için PostgreSQL ile uygulama aynı imajda duruyor — "tek container tek süreç" ortodoksisinden bilinçli bir sapma ve bedeli de bu. ## Üçüncü karar: aramanın iki dizine birden sorması Bir cümle modelinin `HALYARD_DISPATCH_TIMEOUT` diye bir şeyden haberi yok. Yalnız çevresindeki kelimelerden haberi var. Oysa dokümantasyonda aranan şeyin önemli bir kısmı tam olarak budur: bir sabit adı, bir hata kodu, bir bayrak. O yüzden her chunk hem vektör hem tam metin dizininde duruyor ve bir arama iki listeyi birden alıp sıralamalarını birleştiriyor. Skorları değil sıralamaları — çünkü skorların ağırlıklandırılması, embedding modeli her değiştiğinde yeniden öğrenilmek zorunda kalırdı; sıralamalar model değişimini olduğu gibi atlatıyor. Görünür bedeli şu: sonuçtaki benzerlik skoru artık o sonucun neden orada olduğunu açıklamıyor. ## Dördüncü karar: bulunamayanı söylemek Arama hiçbir şeyi alaka eşiğinin üstüne çıkaramadığında en kötü sonucunu döndürmüyor; "iyi bir eşleşme yok" diyor ve dokümanları listeleyen araca yönlendiriyor. Aynı disiplin indekslemede de var: metin katmanı olmayan taranmış bir PDF indekslenmiyor, reddediliyor. Çünkü fark edilmeyen başarısızlık, bir doküman *üreten*tir — boş metin indekslenir, proje artık var olan, listelenen, hiçbir şeyle eşleşmeyen ve açıldığında boş görünen bir dokümana sahip olur. ## Ölçülüp bırakılan sınır Dürüst olmak gerekirse çalışmayan bir şey var: diller arası arama. Türkçe sorulan bir sorunun cevabı İngilizce bir sayfada duruyorsa çoğunlukla bulunmuyor. Bu bir yapılacak iş değil, kapatılmış bir karar. İki çözüm denendi. Önce hibrit arama: tanımlayıcıları kurtardı, tek bir doğal dil sorusunu kurtarmadı. Sonra çok dilli bir yeniden sıralama modeli: diller arası isabeti yükseltti ama bir aramayı milisaniyelerden saniyelere çıkardı ve sıradan sorulardan bir kısmının birinci sıradaki cevabını götürdü. İkisi de ölçüldü, ölçümler depoda duruyor. Gerçek çözüm ikinci bir çeviri-eğitimli model olurdu: dört kat indirme, ikinci bir vektör sütunu ve her kurulumda tam yeniden indeksleme. Bir doküman sunucusu için bu fiyat ödenmedi. O yüzden kayıt "yapılacaklar" listesinde değil, dokümantasyonda duruyor — ve okuyucunun corpus'u gerçekten iki dilliyse, bu arama biçimi de kritikse, bu kayıt onun başka bir araç seçmesi için bir sebeptir. ## Bunun toplam bedeli nedir? Kazandığım şey şu: yeni bir projenin dokümanını ajana açmak artık bir sunucu yazmak değil, panele bir kaynak eklemek. Kaynaklar değiştiğinde yalnız değişen dosya yeniden gömülüyor, silinen dosyanın dokümanları düşüyor. Ödediğim bedel de açık: projeler arası arama yok, ilk açılış model indirmesi bekliyor, tek container tek süreç değil ve iki dil arasında arama yapmıyor. Dördü de bir karardan çıktı ve dördü de geri alınabilir değil — bir aracın ne yaptığını bilmek kadar, ne yapmadığını bilmek de kurulum kararının parçası. Kurduğum yol ile kararların gerekçesi [Labs kaydında](/tr/labs/contextator-lab/), ürünün künyesi [portfolyoda](/tr/portfolyo/contextator/). Araç açık kaynak ve kendi sunucunuzda çalışıyor. --- ### Kaynaklar - [Model Context Protocol: Specification, sürüm 2026-07-28, erişim 2026-10-07](https://modelcontextprotocol.io/specification/2026-07-28) ## RAG'e karar verdiniz. Asıl iş şimdi başlıyor - Kaynak: https://muhammetsafak.com/tr/blog/rag-karari-sonrasinda-kalan-is/ RAG mı fine-tuning mi, her yerde yazıyor; RAG dedikten sonra ne işlettiğiniz hiçbir yerde. Anahtar nerede duruyor, hangi uygulama hangi modeli kullanıyor, harcama nerede kesiliyor — üçü de o yazıların bittiği yerde başlıyor. Bu yazı, kendi dokümanıyla çalışan bir LLM özelliği kurarken retrieval'ın etrafında kalan işin dört tasarım kararına nasıl döndüğünün hikâyesi. Kısa cevap şu: RAG'e karar vermek işin kolay yarısı; zor yarısı onu taşıyan **gateway** — uygulama ile sağlayıcı arasındaki geçit. Bu dördünü kuran şey [Ragmux](/tr/portfolyo/ragmux/) oldu. ## Fine-tuning'i asıl pahalı kılan şey nedir? Önce bir düzeltme, çünkü ilk gerekçem eksikti. "Model eğitmek ya da fine-tune etmek küçük bir ekip için imkânsız" diye düşünüyordum. Bunun yarısı artık doğru değil: tek bir A100 üzerinde 7B-13B ölçeğindeki açık kaynaklı bir modelin LoRA fine-tune'unun GPU maliyeti tipik olarak [Winder.AI: RAG vs Fine-Tuning in 2026: A Decision Framework for LLM Teams](https://winder.ai/rag-vs-fine-tuning-2026-decision-framework/) çerçevesine göre 50 ilâ 500 dolar bandında ve sınıflandırma türü işler için birkaç yüz iyi örnek yetebiliyor. Düşmeyen şey, o örnekleri üretmenin maliyeti. Aynı kaynak temiz, etiketli ve tekilleştirilmiş bin örneklik bir eğitim setinin çoğu zaman kıdemli bir mühendisin bir-iki haftasını aldığını söylüyor — yani pahalı olan hiçbir zaman GPU değildi, veriydi. Ama benim için asıl belirleyici olan maliyet bile değil. Fine-tune edilmiş bir model **dondurulmuş bilgi** taşıyor: dokümantasyon değiştiğinde onu yeniden eğitmeniz, cevabın hangi belgeden geldiğini soramamanız ve temel modeli değiştirmek istediğinizde işi baştan yapmanız gerekiyor. RAG'i seçilir kılan şey ucuzluğu değil, **kaynağın modelden ayrı durması**: belgeyi değiştirirsiniz, cevap değişir; modeli değiştirirsiniz, belge yerinde kalır. ## Kolay yarıyı bitirince kalanlar Diyelim karar verildi ve iş yapıldı: doküman ayrıştırıldı, chunk'landı, gömüldü; arama çalışıyor. Bu noktada üç soru kalıyor ve üçü de retrieval'ın parçası değil: - Sağlayıcı anahtarı nerede duruyor? - Hangi uygulama hangi modeli kullanıyor? - Harcama nerede kesiliyor? Bu üçünü her uygulamanın kendi içinde çözmesi, aynı kodun uygulama sayısınca kopyalanması demek — ve anahtarın uygulama sayısınca dağılması. Ragmux üçünü uygulamanın dışına, aradaki geçide taşıyor: uygulama tek bir OpenAI uyumlu uca konuşuyor, resmî SDK'larda değişen tek şey `base_url` ile `api_key` oluyor. ## Birinci karar: `model` alanı hiçbir şey seçmiyor İsteğin içindeki `model` alanı yönlendirme yapmıyor. Hangi modele gidileceğini **projenin bağlantısı** belirliyor; istemcinin yazdığı değer yalnızca cevapta ve her akış parçasında olduğu gibi geri yankılanıyor, böylece o alanı karşılaştıran SDK'lar kırılmıyor. Bunun getirdiği şey, sağlayıcı kimlik bilgisinin uygulamaya hiç gitmemesi: sunucuda şifreli duruyor ve hiçbir arayüzde geri gösterilmiyor. Uygulamanın elinde yalnız projenin kendi anahtarı var; o anahtar sızarsa döndürdüğünüz şey sağlayıcı anahtarınız olmuyor. Bedeli de açık: istemci artık model seçemiyor. Model seçimi bir dağıtım kararı olmaktan çıkıp bir yapılandırma kararına dönüşüyor — sağlayıcı değiştirmek yeniden deploy gerektirmiyor, ama bir uygulamanın "bu isteği şu modele gönder" demesi de mümkün olmuyor. ## İkinci karar: bütçe parayla değil token'la kesiliyor Her isteğin tahmini maliyeti hesaplanıyor ve panelde duruyor. Ama **maliyet bilgidir**: hiçbir istek bir para eşiğine ulaşıldığı için reddedilmiyor. Harcamayı sınırlamanın yolu token sınırlamak — proje başına dakikalık istek ve token tavanı, günlük ve aylık token bütçesi. Bu bilinçli bir sınır. Bir isteğin gerçek maliyeti ancak sağlayıcı cevapladıktan sonra biliniyor; para üzerinden bir tavan uygulamak, tahminin üzerine tahmin koymak olurdu. Token da aynı sorunu daha küçük ölçekte taşıyor ve saklanmıyor: **tek bir istek bütçeyi kendi boyutu kadar aşabiliyor**, reddedilen de bir sonraki istek oluyor. Reddedilen istek sağlayıcıya hiç gitmiyor, dolayısıyla hiçbir şeye mal olmuyor; ve limitler retrieval'dan **önce** kontrol ediliyor, yani sınırı dolmuş bir istemci bir embedding çağrısı bile harcamıyor. ## Üçüncü karar: tek veritabanı, ikinci bir depo yok Kullanıcılar, bağlantılar, projeler, dokümanlar, chunk'lar, vektörler, istek kayıtları ve kullanım sayaçları — hepsi pgvector'lü tek bir PostgreSQL'de. Redis yok, ayrı bir vektör veritabanı yok. pgvector README'si (v0.8.7) aynı fikirle açılıyor: "[Store your vectors with the rest of your data.](https://github.com/pgvector/pgvector/blob/v0.8.7/README.md)" Bu bir sadelik tercihinden fazlası. Sayaç bir süreç değişkeni değil bir **satır** olduğu için, aynı veritabanına bakan birkaç kopya aynı bütçeyi paylaşabiliyor; bir kopyanın verdiği oturum çerezi diğerinde de geçerli oluyor; doküman işleme kopyalar arasında kiralamayla (lease) paylaştırılıyor. Yatay ölçekleme için yapılacak şey, ayrı bir koordinasyon katmanı kurmak değil, birkaç süreci aynı veritabanına bakacak şekilde başlatmak. Bedeli şu: her şeyin tek bir PostgreSQL'e bağlı olması, o PostgreSQL'in de buna göre boyutlandırılması demek — ve varsayılan tek-container dağıtımı veritabanını kendi içinde barındırdığı için bilinçli olarak tek örneklik. ## Dördüncü karar: retrieval başarısızlığı isteği düşürmüyor Yeniden sıralama servisi cevap vermezse, arama arka ucu kullanılamıyorsa ya da bağlam getirilemiyorsa istek iptal edilmiyor. Olay loglanıyor, elde olan sıralama kullanılıyor ve istek bağlamsız devam ediyor. Dürüst olmak gerekirse cevapta bunu ayırt eden bir işaret yok: bağlam getirilemediğinde de eşleşme bulunamadığında da aynı "sıfır isabet" değeri dönüyor, fark yalnız logda. Bunun tersi — "bağlam yoksa 500 dön" — daha dürüst görünüyor ama pratikte daha kötü: kullanıcı, cevabın biraz daha zayıf olması yerine hiç cevap alamamış oluyor. Burada seçim, sessizce yanlış cevap vermekle görünür biçimde eksik cevap vermek arasında değil; **zayıf bir cevapla hiç cevap vermemek** arasında — ve gerekçeyi cevap değil, log taşıyor. ## Kendi kuralını çiğnediğini bulan gözden geçirme Kayıtlı beş kararın üçü bir yayın öncesi gözden geçirmeden doğdu, ikisi de kodla dokümanın çeliştiği yerlerden. Rol matrisi `viewer`'ı salt-okunur anlatıyordu ama kod ona kendi gateway anahtarını üretme hakkı veriyordu — yani bir "okuyucu" para harcayabiliyordu. Bir fiyat tablosu satırı, ücretli bir uca bakan bağlantıyı "0,00 $" diye raporluyordu; eksik fiyat, sıfır fiyat gibi görünüyordu. Beşinci karar ise aynı gün yumuşatıldı. Görsel indirme tavanına kiracı başına sert bir pay konmuştu. Ölçüm, tek projeli bir kurulumda — yani en yaygın hâlde — tavanın yarısının kalıcı olarak boş kaldığını gösterdi. Pay o gün esnetildi: kimse beklemiyorsa tek kiracı tavanın tamamını kullanabiliyor. Bu üçünü yazmamın sebebi şu: bir aracın hangi kararları verdiğinden çok, o kararların **nasıl denetlendiği** ilgilendiriyor beni. Kendi dokümanıyla çeliştiğini bulabilen bir proje, bulamayan bir projeden daha güvenilir. ## Bunun toplam bedeli nedir? Kazandığım şey: sağlayıcı anahtarı tek yerde duruyor, hangi uygulamanın hangi modeli kullandığı bir yapılandırma satırı ve harcama proje başına kesiliyor. Yeni bir uygulama eklemek yeni bir anahtar dağıtmak değil, yeni bir proje açmak. Ödediğim bedel: istemci model seçemiyor, bütçe parayla değil token'la kesiliyor ve tek bir istek bütçeyi kendi boyutu kadar aşabiliyor, her şey tek bir PostgreSQL'e bağlı ve bağlam getirilemediğinde cevap yine de üretiliyor. Dördü de bir karardan çıktı ve dördünün de tersi savunulabilir. Bir geçit kurarken asıl soru "hangisi doğru" değil; hangi bedeli bilerek ödediğiniz. Kurduğum yol ile kararların gerekçesi [Labs kaydında](/tr/labs/ragmux-lab/), ürünün künyesi [portfolyoda](/tr/portfolyo/ragmux/). Araç açık kaynak ve kendi sunucunuzda çalışıyor. --- ### Kaynaklar - [Winder.AI: RAG vs Fine-Tuning in 2026: A Decision Framework for LLM Teams](https://winder.ai/rag-vs-fine-tuning-2026-decision-framework/) - [pgvector README, v0.8.7, erişim 2026-10-07](https://github.com/pgvector/pgvector/blob/v0.8.7/README.md) ## 18.750 istek/sn: kesin, tekrarlanmış ve üç kat yanlış - Kaynak: https://muhammetsafak.com/tr/blog/kesin-tekrarlanmis-ve-yanlis-bir-olcum/ Ölçüm üç tekrar koştu, üçü de birbirine yakın çıktı, medyan 18.750'ydi. Aynı adayı aynı dört çekirdekte dakikalar önce ölçen başka bir faz 55.715 demişti. İkisi de kendi içinde tutarlıydı ve ikisi de aynı koşunun parçasıydı. ## Nereden çıktı İş yerinde, her istekte OAuth2 token'ı doğrulayıp saniyede on binlerce okuma ve yazma karşılayacak bir servis için Go önerdim. Ekip haklı bir soru sordu: ne kadar kazanıyoruz? Ölçmeyi seven biri olduğum için bu tam istediğim türden bir soruydu ve aynı API'yi Go, PHP-FPM ve FrankenPHP worker ile üç kez yazıp bir tezgâh kurdum. Tezgâh şunu yapıyordu: karma trafiği (yarısı okuma, yarısı yazma) sabit bir hızda bas, hız hedefi tutuyorsa ve p99 on milisaniyenin altındaysa geç, değilse kal. Sonra hızı ikiye katla, başarısız olunca aradaki aralığı ikili aramayla daral. Kapasite, servis seviyesini karşılayan en yüksek hız olsun. Bu, benim de başkalarının da yazacağı türden makul bir düzenek. ## Çıkan sayı sonuç gibi okunuyordu Dört çekirdekli Go için tezgâh 18.750 istek/sn verdi. "Üç tekrarın medyanı" etiketiyle. Bir tabloya yazsanız kimse itiraz etmez. Aynı koşunun ayar fazı vardı: her adayı her çekirdek bütçesinde farklı havuz boyutlarıyla tam gaz koşturup en iyisini seçiyordu. O faz, aynı Go servisini aynı dört çekirdekte 55.715 ölçmüştü. Üç kat fark. İkisinden biri yanlıştı ve hangisi olduğunu bana **hiçbir şey söylemiyordu**. Merdivenin üç tekrarı birbirine yakındı; ayar fazının iki tekrarı da birbirine yakındı. Her iki ölçüm de kendi içinde tutarlıydı. Tutarlılık doğruluk değil. Veriye bakınca daha kötüsü çıktı: merdivenin tekrarları 1.250 ile 48.000 arasında geziyordu. Medyan 18.750'ydi çünkü ortadaki tekrar oraya düşmüştü. ## Birinci teşhis: doğruydu ve yetmedi Bozuk pencereleri tek tek okudum. Bir hücrenin **ilk** adımı, saniyede 3.000 istekte, yazma p99'unu 10,6 ms göstermişti — eşiğin altı onda biri kadar üstünde. Okuma p99'u aynı pencerede 3,3 ms'ydi. Yani darbe yalnız yazma tarafına gelmişti. Sebep tezgâhımdaydı: her adımdan önce tabloyu tohumlanmış hâline döndürüyor ve `CHECKPOINT` alıyordum. Checkpoint'in geri yazımı ölçüm penceresine taşınca yazmalar takılıyordu. Üstel arama da bunu bir hükme çeviriyordu: 48.000 taşıyacak bir hücrenin ilk adımı başarısız yazılınca, aramanın bisect edecek geçmiş bir başarısı olmuyor, aşağı dönüyor ve hücrenin geri kalanını Go'nun 1.250 istek/sn taşıdığını doğrulamakla geçiriyordu. Reset'ten sonra bekleme koydum. Başarısız bir pencereyi, arama ona göre davranmadan önce ikinci kez ölçtürdüm. Yeniden koşturdum. ## İkinci deneme daha kötü çıktı Sonuç şuydu: | Hücre | tekrar 1 | tekrar 2 | tekrar 3 | |---|---|---|---| | Go, 1 çekirdek | 1.250 | 6.500 | 3.500 | | PHP-FPM, 1 çekirdek | 1.750 | 2.750 | 750 | Aynı hücrenin tekrarları beş kat ayrışıyordu. Ve bu sefer **hiçbir pencere bozuk değildi**: istekler cevaplanıyor, hata dönmüyor, CPU sabit bir yerde duruyordu. Düzelttiğim şey gerçek bir sorundu ama asıl sorun o değildi. ## oha neden iki gecikme raporluyor? PHP-FPM, tek çekirdek, saniyede 3.000 istek. Doksan bin isteğin hepsi cevaplanmış, hedef hız birebir tutmuş, medyan 1,44 ms, CPU tek çekirdeğin 0,76'sı — doygun bile değil. Ve p99: 3.718 ms. Yük üretecinin çıktısında iki ayrı gecikme sütunu vardı: | | p50 | p75 | p99 | en yavaş | |---|---|---|---|---| | time to first byte | 1,42 ms | 47 ms | **191 ms** | 267 ms | | latency-corrected | 1,44 ms | 1.539 ms | **3.718 ms** | 3.819 ms | Sunucu 191 ms'de cevaplamıştı. Kalan 3,5 saniye, isteğin **gönderilmeyi bekleme** süresiydi. ## Mekanizma: doğru şeyi yapmak sınırı uçuruma çevirir `--latency-correction`, isteği gönderildiği andan değil **gönderilmesi gerektiği** andan itibaren sayar. Bu doğru şeydir: yük üretecinin sistemle birlikte nazikçe yavaşlayıp "geçti" demesini engelleyen tek mekanizma budur. oha README'si (v1.16.0) bayrağı şöyle tanımlıyor: "[Correct latency to avoid coordinated omission problem. It's ignored if -q is not set.](https://github.com/hatoo/oha/blob/v1.16.0/README.md)" [Coordinated omission](https://highscalability.com/your-load-generator-is-probably-lying-to-you-take-the-red-pi/) literatürü bunu uzun uzun anlatır ve orada biter. Anlatmadığı şey, düzeltmeyi uyguladıktan *sonra* ne olduğu. Cevaplar üretecin bağlantılarını dolduracak kadar yavaşlayınca takvim kayıyor ve sonraki her istek kaymayı devralıyor. Sonuç: bir hız ya rahatça karşılanıyor ya da felaket biçimde ıskalanıyor, arada neredeyse hiçbir şey yok. Sınır bir eğim değil, **uçurum**. Ve uçurum tekrarlar arasında yer değiştiriyor. İşte aramanın ölçemediği şey bu. Üstel arama ile ikili bölme, geçme/kalmanın hızda monoton ve tek tek sorgulanabilecek kadar kararlı olduğunu varsayar. İkisi de doğru değildi. Her tekrar farklı bir soru dizisi soruyor, hareketli bir sınırın üzerinde farklı bir yol yürüyor ve yolun bittiği yeri rapor ediyordu. ## Çözüm ve bir yalan daha Aramayı attım. Yerine hücre başına yedi sabit hız koydum — her tekrarda aynı yedisi — ve bir hız, tekrarların çoğunluğu onu taşıdıysa taşınmış sayıldı. Yönlendirilecek bir şey yok; bozuk bir pencere bir tekrarda bir noktaya mal oluyor, oy onu yutuyor. Sonra ana makine yalan söyledi. Tavan fazını koştururken sayılar yine saçmaydı: dört çekirdekli Go 17.490 istek/sn. Süreçlere baktım — macOS'ta otuz kadar headless Chromium süreci toplam %1.019 CPU kullanıyordu. On iki çekirdeğin onundan fazlası. Docker VM'ine %94 kalmıştı. Bu benim tezgâhımın kusuru değildi ama tezgâhımın görmesi gereken bir şeydi. İki şey ekledim: her tekrardan önce, uygulama kodu çalıştırmayan bir nginx'i adayın kendi çekirdeklerine koyup beklenen hızın %115'ini isteyen bir **yoklama** — altında kalan tekrar adayı değil üreteci ölçmüştür, elenir — ve her tekrarın yanına macOS'un o anki yükünün kaydı. ## Sonuç Sakin bir makinede, hücre başına beş tekrar: 45 tekrarın 45'i yoklamadan geçti. Go'nun dört çekirdekteki beş tekrarı 55.836 ile 57.321 arasına düştü, medyan 56.571. Ve on iki saat önce, farklı bir bağlantı sayısıyla, kapalı çevrimde ölçen ayar fazı 55.715 demişti. İki bağımsız ölçüm birkaç yüzde içinde örtüştü. Sayının doğru olduğunu bana söyleyen şey buydu — istatistik değil, ikinci bir yöntem. ## Ölçüm Aşağıdaki sayıların hepsi sentetik bir tezgâhta (karışık trafik, yarısı okuma yarısı yazma, p99 on milisaniyenin altında kalırsa geçer) dört çekirdekli Go için ölçüldü; üretim trafiği değil ve hepsi yukarıdaki bölümlerde geçiyor. | Faz | Dört çekirdekli Go | |---|---| | Merdiven, bölmeli üstel arama, üç tekrarın medyanı | 18.750 istek/sn (tekrarlar 1.250 ile 48.000 arasında) | | Ayar fazı, kapalı çevrim, sonuna kadar yükleyerek | 55.715 istek/sn | | Tavan fazı, headless Chromium süreçleri makineyi tutarken | 17.490 istek/sn | | Sakin makinede sabit hızlı merdiven, beş tekrar | 56.571 istek/sn medyan (55.836 ile 57.321) | > **Tan:** Bu, ileriye olduğu kadar geriye de bakan bir ders: daha önce yayımladığınız sayılardan hangilerinin ikinci bir yolu var? ## Bundan ne öğrendim? Bariz çöp üreten bir ölçüm iyi haberdir: bakarsınız. Tehlikeli olan, üç ondalık basamakla ve "üç tekrarın medyanı" etiketiyle yanlış olandır, çünkü o sayı bir sunuma girer ve kimse itiraz etmez. Tekil bir ölçüm kendi kendini denetleyemez. Tekrar sayısını artırmak yetmez — benim üç tekrarım da tutarlıydı. Daha iyi bir istatistik de yetmez; medyan tam da ortadaki yanlış tekrarı seçmişti. Yetecek olan, **aynı büyüklüğü başka yoldan ölçen ikinci bir faz**. Benimki kazara vardı: havuz boyutunu seçmek için kapalı çevrimde koşan ayar fazı, açık çevrimdeki merdivenle üç kat çelişti ve alarmı o çaldı. Bugün bilerek tutuyorum. Kendi düzeneğinize eklemek için üç somut şey: - Aynı büyüklüğü **farklı çevrimde** ölçen ikinci bir faz (biri açık, biri kapalı) ve ikisi ayrıştığında koşuyu geçersiz sayan bir kural. - Her tekrardan önce bir **yoklama**: ölçtüğünüz şeyin gerçekten aday olduğunu kanıtlayan, uygulama kodu çalıştırmayan bir hedef. - Tekrarın yanında **ortamın kaydı**. Sayının yanlış olduğunu sonradan anlarsanız nedenini de bilirsiniz. Ölçümün kendisi, çıkan sayılar ve elenen üç denemenin ham verisi [araştırma defterinde](/tr/research/go-php-oauth2-cekirdek-basina-kapasite/); teşhisin tamamı deponun [EXCLUDED.md](https://github.com/muhammetsafak/php-go-bench/blob/main/results/capacity-2026-09-17/EXCLUDED.md) dosyasında. Sildiğim sayıları da orada tutuyorum — yanlış bir ölçümün nasıl yanlış olduğu, doğru olanın kendisinden çok şey öğretiyor. --- ### Kaynaklar - [oha README, v1.16.0, erişim 2026-10-07](https://github.com/hatoo/oha/blob/v1.16.0/README.md) - [High Scalability: Your Load Generator Is Probably Lying To You, erişim 2026-10-07](https://highscalability.com/your-load-generator-is-probably-lying-to-you-take-the-red-pi/) ## Oyun geliştirmek planımda yoktu — kapsamı kısıt seçti - Kaynak: https://muhammetsafak.com/tr/blog/oyun-gelistirmek-planimda-yoktu-regnarium/ Oyun geliştirmek hiçbir zaman planımda yoktu. On yedi yıldır yazdığım şey iş uygulamasıydı; oyun benim için oynanan bir şeydi, yapılan bir şey değil. Birkaç arkadaşımın oyun geliştirmeye başlaması ve ısrarla itmesi olmasa bu yazı da olmazdı. İtki geldiğinde ilk sorduğum soru "ne yapayım" değil, "ne yapabilirim"di. Bu yazı o sorunun cevabının — bir kısıt listesinin — nasıl bir oyun tasarımına dönüştüğünün hikâyesi. Kısa hâli şu: **kısıtlar kapsamı daraltmadı, kapsamı seçti.** "Tek oturumda bitmeli" kararı tur sınırını, zarsız savaşı ve sunucusuz mimariyi tek tek belirledi; ben bunların hiçbirini ayrı ayrı seçmedim. Ortaya çıkan şeyin adı [Regnarium](/tr/portfolyo/regnarium/). ## Tek bildiğim tür Kendime dürüst olduğumda elimde tek bir varlık vardı: strateji oyunlarına gömdüğüm on binlerce saat. Başka bir türü oynadığım kadar bilmiyordum, ve bilmediğim bir türü tasarlamaya çalışmak, tek kişilik bir işte harcanacak en pahalı zaman olurdu. Bu ilk kısıttı ve aslında bir avantajdı. Bir türü oyuncu olarak iyi bilmek, tasarımcı olarak "burada canım sıkılıyor" diyebilmek demek. 4X stratejide o nokta bellidir: oyunun ortası. Keşif bitmiş, savaş başlamamış, siz sıra tuşuna basıp duruyorsunuz. ## Elimde olmayanların listesi İkinci kısıt ölçekti. Tek kişiyim, bir stüdyo değil. Bu, şununla yarışamayacağım anlamına geliyordu: içerik hacmi. Büyük yapımların yüzlerce birimi, onlarca uygarlığı, derin teknoloji ağaçları var ve bunların hepsi insan-yılı demek. Buradaki hata, azıyla aynı şeyi yapmaya çalışmak olurdu — yani her sistemin yarısını kurup hiçbirini bitirmemek. Onun yerine listeyi bilinçli olarak kısa tuttum: beş birim, beş bina, dört arazi, üç kaynak. Tek kural şuydu: her kaydın tahtada **ayırt edilebilir bir işi** olacak. Bir birim başka bir birimin biraz daha iyisiyse, o birim listeyi uzatıyor ama oyunu zenginleştirmiyor. ## "Yarım saatte bitmeli" neyi seçti Üçüncü kısıt en belirleyicisiydi ve kişiseldi: bu oyunu bir bakıma kendim için, beklemelerde oynamak için kurguluyordum. Yani el, yarım saatte bitmeliydi. Bu tek cümlenin seçtiği şeylere bakın: - **Sabit bir tur sınırı.** Oyun kendini bitiriyor: el azami altmış turda kapanıyor ve fetih tamamlanmadıysa kazananı puan belirliyor. Süre bir uyarı değil, kuralın parçası. "İstersen devam et" diyen bir tasarım, yarım saatlik vaadi ilk gün bozardı. - **Küçük ama dolu bir harita.** Tahtanın ölçüsü doğrudan tur sayısından türedi: keşfin bitmesi, genişlemenin sıkışması ve cephenin oluşması altmış tura sığmalıydı. - **Beklemeyen bir rakip.** Yapay zekâ rakibin karar süresi bir bütçe kalemi hâline geldi; oyuncunun sırasını beklediği her saniye, yarım saatten çalınan bir saniye. Hiçbirini "iyi tasarım" olduğu için seçmedim. Üçü de tek bir kısıttan düştü. ## Zarsız savaş bir tasarım kararıdır Dördüncü karar kısıttan değil, kendi oyunculuğumdan geldi — ve en çok düşündüğüm yer burasıydı. Zar atan bir savaş sistemi, kaybedilen her eli sisteme yıkma imkânı verir. Oyuncu kendi kararını sorgulamak yerine talihi suçlar; kısa bir oyunda bu ölümcül, çünkü telafi edecek üç saatiniz yok. O yüzden savaş tamamen deterministik: saldırıdan önce gördüğünüz önizleme, motorun işlettiği hesabın **aynısı**. Ayrı bir "tahmin" fonksiyonu yazmak, önizleme ile sonucun ayrışabileceği tek kapıyı açardı; o kapı hiç açılmadı. Sonuç şu cümleye indi: sürpriz yok, pişmanlık var. Kaybettiğiniz el, sizin okumadığınız bir sayı yüzünden kaybedildi. Rastgelelik tamamen yok değil — harita tohumlu bir üreticiden geliyor — ama oynanışın içine sızmıyor. Bu ayrım aynı zamanda çevrimdışı çalışmayı mümkün kılan şey: aynı tohum ve aynı komut dizisi her zaman aynı eli ürettiği için, kayıt tam bir durum kopyası olmak zorunda değil. Tohum ve komut listesi yeterli; yükleme onu yeniden oynatıyor. Sunucu, hesap ve senkronizasyon ihtiyacı buradan düştü. ## Kodu ben yazmadım, mimariyi ben tuttum Bir künye satırı olarak söylemek gerekiyor: mekanikleri ve mimariyi ben tasarladım, kodun büyük kısmını yapay zekâ yazdı. Benim işim üretmek değil, **sapmayı engellemekti** — belirlediğim mimariden çıkılmaması, yanlış kütüphanenin projeye girmemesi, bir kuralın iki yerde hesaplanmaya başlamaması. Bu ayrımı daha önce [üç seviye üzerinden yazmıştım](/tr/blog/vibe-coding-mi-ai-destekli-gelistirme-mi/); burada kendi üzerimde denedim ve tezimi değiştirmedi. Tek şey ekleyeceğim: bir kuralı gözle korumak ölçeklenmiyor. O yüzden kısıtların çoğunu makineye devrettim — kural çekirdeğinin saate, ağa ve tarayıcıya erişmesi denetim kapısında yasaklı, ve kapının gerçekten tuttuğu, kasıtlı olarak hatalı yazılmış dosyalarla ayrıca sınanıyor. Yazılmış bir kural, bağlı olduğunun kanıtı değil. ## Günün sonunda Ortaya çıkan iş beni tatmin etti, ve bunun sebebi oyunun büyük olması değil; bitmiş olması. Tek kişilik bir işte kapsamı kesmek bir taviz gibi hissettiriyor ama pratikte tam tersi: kesilmemiş kapsam, hiçbir sistemi bitirmemek anlamına geliyor. Yan proje yapan birine söyleyebileceğim tek şey bu. Kısıt listenizi önce yazın — ne kadar zamanınız var, neyi gerçekten biliyorsunuz, ürün nerede çalışacak — ve tasarım kararlarını o listeden düşürün. Boş bir sayfadan başlamak özgürlük gibi görünüyor; aslında bitirmemenin en kısa yolu. Nasıl kurulduğu ve hangi kararın neden verildiği [Labs kaydında](/tr/labs/regnarium-lab/); oyunun kendisi tarayıcıda, hesapsız ve çevrimdışı. ## Prod erişimi tek kişideydi, o kişi izne çıktı - Kaynak: https://muhammetsafak.com/tr/blog/prod-erisimi-tek-kisideydi-queryproxy/ Ekipte üretim veritabanına erişimi olan tek kişi vardı. Bu, uzun süre bir sorun gibi görünmedi — ta ki o kişi izne çıkana kadar. Bir hafta içinde şunu fark ettik: bug'lar tespit edilemiyordu. Kod okunabiliyordu, testler koşuyordu, hipotezler kuruluyordu; ama hiçbiri "üretimde bu kaydın hâli ne" sorusunun yerine geçmiyordu. Geliştirme durmadı, ama teşhis durdu — ve teşhis durunca geri kalanı da yavaşladı. Bu yazı o haftanın çıkardığı dört cümlelik ihtiyaç listesinin, dört tasarım kararına nasıl dönüştüğünün hikâyesi. Kısa cevap şu: sorunun çözümü erişimi genişletmek değil, **erişimi bir kapıdan geçirmek.** Geliştirici üretim parolasını hiç görmeden veriye bakabilir; bunun için sorgunun kendisinin denetlenmesi, bir onaydan geçmesi, kalıcı bir ize düşmesi ve sonucun maskelenmesi yeterli. Bu dördünü kuran şey [QueryProxy](/tr/portfolyo/queryproxy/) oldu. ## Yanlış çözümler neden yanlıştı? İki hazır cevap vardı ve ikisi de bir hafta içinde elendi. **"Parolayı ekiple paylaşalım."** Bunun bedeli ilk gün görünmüyor. Parola bir kez dağıldığında geri toplanamıyor: kimde olduğu bilinmiyor, biri ayrıldığında döndürmek gerekiyor, ve en önemlisi kimin ne çalıştırdığı sorusunun cevabı kalmıyor. Bir `UPDATE` yanlış satırı güncellediğinde elinizde yalnızca bir tahmin oluyor. **"Herkese salt-okunur kullanıcı açalım."** Bu daha iyi ama yetmiyor. Salt okunur bir kullanıcı da tablonun tamamını çekebiliyor; kişisel verinin maskelenmesi diye bir şey yok, ve gerçek teşhisin bir kısmı okumayla bitmiyor — bazen bozuk bir kaydı düzeltmek gerekiyor. O an geldiğinde yine tek kişiye dönüyorsunuz. OWASP SQL Injection Prevention Cheat Sheet de aynı tabandan başlıyor: "[Make sure that accounts that only need read access are only granted read access to the tables they need access to.](https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html)" Bu, bir hesabın neler yapabileceğini daraltır; hangi ifadenin çalıştığı hakkında bir şey söylemez. Ortak kusur şu: ikisi de soruyu **kim** ekseninde çözmeye çalışıyor. Oysa asıl soru **ne** — hangi ifadenin çalışacağı. ## Birinci ihtiyaç: parolayı görmeden erişmek İlk karar, kimlik bilgisinin nerede duracağıydı. Bağlantı künyesi uygulamanın içinde şifreli duruyor, parola hiçbir arayüzde geri gösterilmiyor; sorguyu çalıştıran da geliştiricinin makinesi değil, portalın kendisi. Geliştiricinin elinde kalan tek şey yazdığı SQL. Bunun bedeli gerçek ve peşinen kabul edildi: sorgu artık anında çalışmıyor. Karşılığında üretim parolası hiçbir zaman bir insanın eline geçmiyor. Bir kurum için asıl kazanç da bu — erişimi geri almak, artık bir kullanıcıyı silmekten ibaret. ## İkinci ihtiyaç: her SQL bir kapıdan geçsin İkinci karar en çok düşündüğümüz yerdi: kapı neye bakacak? İlk akla gelen yol desen eşleştirmesi — `DELETE` görünce uyar, `DROP` görünce reddet. Bu yol ilk gün aşılır. Araya yorum sıkıştırmak (`DROP/**/DATABASE`), ifadeyi bölmek ya da bir alt sorguya saklanmak yeterli oluyor. Düzenli ifadeler metne bakar; SQL ise metin değil, **yapı.** O yüzden gelen sorgu ayrıştırılıyor ve ne olduğu çözümlenmiş yapısından okunuyor. Pratik karşılıkları şunlar: - `WHERE`'i olmayan bir `UPDATE` ya da `DELETE` reddediliyor. `WHERE 1 = 1` gibi her zaman doğru olan bir koşul da eksik sayılıyor. - Limitsiz bir `SELECT` varsayılan bir satır sınırıyla dönüyor, üst sınırı aşan istek tavana kırpılıyor. - Veritabanı silme, kullanıcı/rol yönetimi ve sunucu tarafı dosya işlemleri koşulsuz engelleniyor — rolü ne olursa olsun. - Yorumlar ve dize sabitleri normalize edildiği için araya yorum sıkıştırarak kapı aşılamıyor. Bir ayrıntı bu tasarımın özetini veriyor: kapının **anlayamadığı** bir limit ifadesi kırpılmıyor, reddediliyor. Anlaşılmayan bir sınır, zorlanamayan bir sınırdır; belirsizlikte doğru davranış geçirmek değil durdurmaktır. Kapı yalnız yarısı. Diğer yarısı onay: istek bir DBA'ya düşüyor, onaylanınca kuyrukta çalışıyor. Buradaki sert kural, bir DBA'nın kendi isteğini onaylayamaması; sistem admini bunu aşabiliyor ve aşma denetim kaydına aşım olarak düşüyor. Onay üç kanaldan gelebiliyor — web, Slack, Teams — ama üçü de aynı servise iniyor, çünkü kuralı kanal başına yazmak, kuralın kanal başına ayrışması demek. ## Üçüncü ihtiyaç: sonradan kimin ne çalıştırdığını bulmak Üçüncü ihtiyaç bir tarih sorusuydu: üç ay sonra "bu kaydı kim değiştirdi" sorulduğunda cevap bulunabilmeli. Buradaki karar, denetim kaydını **değiştirilemez** kılmak oldu; ve bunun uygulamadaki karşılığı basit üç şey: kayıt tablosunda güncelleme sütunu yok, güncelleme ve silme model düzeyinde istisna atıyor ve kayıt satırları hiçbir yabancı anahtar kısıtına bağlanmıyor. Son madde en kolay kaçırılan yer: bir bağlantı silindiğinde onun geçmişinin de silinmesi, denetim izini tam ihtiyaç duyduğunuz anda boşaltır. Dürüst bir sınır: bu, uygulama düzeyinde bir değiştirilemezlik. Veritabanına doğrudan erişen biri satırı hâlâ değiştirebilir. Kriptografik bir zincir değil, uygulamanın sızdırmadığı bir kapı — ve neyi vaat ettiğini bilerek kullanmak gerekiyor. ## Dördüncü ihtiyaç: kritik bilgi maskelensin Son karar, maskelemenin **nerede** durduğuyla ilgiliydi ve en öğretici olanı bu. Kolay yol, sonucu kaydedip gösterirken maskelemek. O tasarımda maskesiz veri diske bir kez yazılıyor ve ondan sonrası temenniye kalıyor: yedek alındığında, dosya indirildiğinde ya da log'a düştüğünde maskesiz hâl orada duruyor. Bunun yerine sonuç akışı satır satır okunuyor ve her satır **dosyaya yazılmadan önce** maskeleniyor. Sonuç deposuna maskesiz bir hücre hiç girmiyor. Fark kulağa küçük geliyor ama güvenlik açısından kategorik: birinde "maskelemeyi atlatan bir yol var mı" diye düşünmeniz gerekiyor, diğerinde atlatılacak bir şey yok. Aynı disiplin kuralların kendisinde de var. Maskeleme kuralları düzenli ifade kabul ediyorsa, kötü yazılmış bir ifade işçiyi kilitleyebilir; o yüzden kural kaydedilirken uzun bir prob dizesiyle sınanıyor. Ve çalışma anında bir ifade değerlendirilemezse değer açıkta bırakılmıyor, maskeleniyor — belirsizlikte kapalı tarafa düşmek, kapı kuralının aynısı. ## Bunun toplam bedeli nedir? Dürüst olmak gerekirse bu kurulumun bir maliyeti var ve saklanacak bir tarafı yok. Sorgu artık anlık değil; birinin onaylaması gerekiyor ve bu, gece yarısı bir olayda sürtünme demek. Ekip yeni bir servis işletiyor. Ve en önemlisi, onay kuyruğu bir ritüele dönüşürse — yani DBA bakmadan onaylıyorsa — bütün yapı tiyatroya dönüşüyor; o hâlde parolayı paylaşmaktan tek farkı, yanlış bir güven duygusu vermesi olur. Karşılığında alınan şey ise şu: teşhis artık bir kişinin takvimine bağlı değil, üretim parolası kimsede yok, ve üç ay sonra sorulacak soruların cevabı duruyor. Bir hafta boyunca teşhis edemediğimiz bug'ların bana öğrettiği şey, erişim yönetiminin bir izin meselesi değil bir **tasarım** meselesi olduğuydu. Aracın kendisi açık kaynak ve self-hosted; kurduğum yol ile kararların gerekçesi [Labs kaydında](/tr/labs/queryproxy-lab/), ürünün künyesi [portfolyoda](/tr/portfolyo/queryproxy/). --- ### Kaynaklar - [QueryProxy: SQL guards](https://queryproxy.com/docs/sql-guards/) - [QueryProxy: Data masking](https://queryproxy.com/docs/data-masking/) - [QueryProxy: Approval workflow](https://queryproxy.com/docs/approval-workflow/) - [OWASP: SQL Injection Prevention Cheat Sheet, erişim 2026-10-07](https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html) ## Hangi şirket size uygun? Bu bir kimlik değil, bir dönem kararıdır - Kaynak: https://muhammetsafak.com/tr/blog/hangi-sirket-size-uygun-bir-donem-kararidir/ "Ben kurumsal adamı değilim." Bu cümleyi yıllar içinde çok duydum. Söyleyenlerin bir kısmı gerçekten öyleydi. Ama azımsanmayacak bir kısmı, üç yıl sonra bir kurumsalın içindeydi — ve şaşırtıcı biçimde mutluydu. Değişen şirket değildi; onların hayatındaki neydi ki cümleyi kurdurmuştu, o değişmişti. Bu diziyi [ilk yazıda](/tr/blog/kurumsal-mi-patron-sirketi-mi/) bir sözlükle açmıştım: şirket tiplerini ayıran şey büyüklük değil, kararın nerede alındığı ve ne kadarının yazıya döküldüğü. Aradaki yazılarda bunun karara, sürece, role, bilgiye, terfiye, toplantıya, krize ve büyümeye nasıl yansıdığını tek tek açtım. Bu son yazı hepsini tek bir soruya bağlıyor: **siz nerede olmalısınız?** Cevabım kısa: bilmiyorum. Ama cevabı bulmanız için elimde iyi bir çerçeve var — ve dizinin bütün amacı buydu. ## Doğru şirket tipi yok, yanlış eşleşme var Kariyer tavsiyelerinin en zararlı yanı, kişilik testine dönüşmeleri. "Startup insanı", "kurumsal insanı" diye bir şey yok. Şirket tipi bir kimlik değil, bir **kısıt kümesi**. Sizin de bir kısıt kümeniz var. Soru şu: bu iki küme şu anda örtüşüyor mu? Örtüşmeyi belirleyen dört şey: **Kariyer döneminiz.** Öğrenme eğrinizin neresindesiniz? Bir junior için patron şirketi genellikle acımasızdır: kimse size zaman ayırmaz, çünkü kimsenin zamanı yoktur. Ama bir mid-level için aynı yer bir hızlandırıcıdır — kurumsalda beş yılda dokunacağınız işe orada bir yılda dokunursunuz. Aynı şirket, aynı yıl, iki farklı insana iki farklı şey yapar. **Risk toleransınız.** Bunu "cesaret" sanmayın; bu bir muhasebe kalemi. Nakit yastığınız kaç ay? Bakmakla yükümlü olduğunuz kimse var mı? Kredi ödemeniz? Patron şirketinin hızının bir yüzü şudur: o hız bazen şirketin kapanma hızıdır. Bu kabul edilebilir bir risk olabilir — ama **bilinerek** kabul edilmeli. **Neyi öğrenmek istediğiniz.** İkisi farklı şeyler öğretiyor ve bu gerçekten böyle: - **Kurumsal** ölçek, sistem ve sabır öğretir. Bir kararın on kişiyi nasıl etkilediğini değil, bin kişiyi nasıl etkilediğini görürsünüz. Sabır burada bir erdem değil, bir beceridir — ve dışarıdan öğrenilmez. - **Patron şirketi** sahiplenme, hız ve uçtan uca görüş öğretir. Bir işin fikirden faturaya kadar tamamını görürsünüz. Bunu bir kez gördükten sonra hiçbir işe aynı gözle bakmazsınız. Sadece birinde kalmanın körlüğü **her iki yönde de** çalışır. Sadece kurumsalda kalan, hiçbir şeyin gerçekte kaça mal olduğunu bilmez. Sadece patron şirketinde kalan, hiçbir şeyin kendisinden büyük ölçekte nasıl ayakta kaldığını bilmez. İkisi de eksik; ikisi de bunu bilmiyor. **Hayat evreniz.** Ve bu, diğer üçünü tek başına ezer. Küçük çocuğu olan biriyle, otuz iki yaşında hiçbir yükümlülüğü olmayan biri aynı şirkette aynı işi yapmaz — yapsa da aynı bedeli ödemez. Kimse bunu iş ilanında konuşmuyor ama kararı en çok belirleyen değişken çoğu zaman bu. > **Yanlış eşleşme, kötü şirkette çalışmak değildir; yanlış zamanda doğru şirkette çalışmaktır.** Ve bu ikisi dışarıdan tıpatıp aynı görünür. ## Mülakatta soracağınız sekiz soru Dizinin operasyonel çıktısı bu. Sekiz soru; her biri dizinin bir yazısından çıkıyor ve her biri size iş ilanının tamamından fazlasını söylüyor. | Soru | Ne ölçüyor | | --- | --- | | 1. Bu kararı kim geri alabilir? | Kararın kişide mi rolde mi olduğu | | 2. Son bir yılda **kaldırdığınız** bir süreç var mı? | Sürecin canlı mı ölü mü olduğu | | 3. Son ciddi arızada ilk sorulan soru neydi? | Korku mu, merak mı | | 4. Terfi kararını kim, hangi kanıtla veriyor? | Emeğin ölçülüp ölçülmediği | | 5. Kurucu/patron kod tabanına ne sıklıkla giriyor? | Devrin gerçekleşip gerçekleşmediği | | 6. Burada yazılı olmayan hangi kural var? | Kültürün dürüstlüğü | | 7. Son ayrılan üç kişi neden ayrıldı? | Şirketin kendini tanıyıp tanımadığı | | 8. Bir işi "bitti" ilan eden kim? | Sınırların netliği | Bu soruların hiçbiri agresif değil ve hepsi meşru. Cevapların içeriği kadar **cevaplanma biçimi** de bilgi taşıyor: soru 6'ya "hiçbiri, biz çok şeffafız" diyen bir şirket, soruyu anlamamıştır. Soru 7'ye "bilmiyorum, ayrılanlarla konuşmuyoruz" cevabı bir cevaptır ve iyi bir cevap değildir. **Soru:** Bu soruları sormak beni zor beğenen biri gibi göstermez mi? **Cevap:** Gösterir. Ve bu tam olarak istediğiniz şey. Bu soruları sorduğunuz için sizi elemek isteyen bir şirket, size iki yıl kaybettirmeden kendini eledi. Karşılıklı bir iyilik yaptınız. ## Üç masadan bakış Bu dizide her yazının bir bölümü buydu. Kapanışta üçünü de "yanlış eşleşme" ekseninde topluyorum. **Kıdemli çalışanın masasından.** Yanlış eşleşmenin işareti şu: artık öğrenmiyorsunuz ve artık kızmıyorsunuz bile. Öfke bir bağlılık belirtisidir; kayıtsızlık değildir. Bir şirkette hâlâ tartışıyorsanız oradasınız demektir. Tartışmayı bıraktığınız gün, aslında çoktan ayrılmışsınızdır — sadece bordro henüz haberdar değildir. Kısıtınız: ayrılmanın bir maliyeti var ve bu maliyeti sadece siz ödüyorsunuz. Bu yüzden karar duygusal değil, [hesaplı](/tr/blog/is-degistirme-karari-kalmak-mi-gitmek-mi/) verilmeli. **Yöneticinin masasından.** Yanlış eşleşmenin işareti: ekibinizi savunmaktan yorulduğunuz an. Aşağıya karşı yukarıyı, yukarıya karşı aşağıyı savunmak işin kendisidir; ama savunduğunuz şeye artık inanmıyorsanız o iş sizi tüketir. Kısıtınız: ekibiniz sizin ayrılmanızı bir ihanet olarak okuyabilir — ve çoğu zaman okur. **Patronun masasından.** Yanlış eşleşmenin işareti: kendi kurduğunuz şirkette artık en sevmediğiniz işi yapıyor olmanız. Şirketiniz büyüdükçe sizi kendinizden uzaklaştırıyorsa, mesele şirket tipinin değişmesi değil, **sizin ne kurduğunuza dair kararınızın** hiç verilmemiş olmasıdır. Kısıtınız: siz ayrılamazsınız. Herkesin bir çıkış kapısı var; sizinki yok — ve bunu kimse hesaba katmıyor. ## Dizinin bıraktığı üç cümle Bu on yazının tamamı üç cümleye sığıyor: **Süreç bir erdem değil, bir maliyet kalemidir.** Bazı yerlerde bedelini fazlasıyla öder; bazı yerlerde sadece fatura keser. İkisini ayırt etmek, bir şirkette hayatta kalmanın en pratik becerisidir. **Şirket tipi bir kader değil, bir kısıttır.** Kısıtı değiştiremezsiniz ama tanıyabilirsiniz — ve tanıdığınız kısıt, kızdığınız kısıttan her zaman ucuzdur. **Bilerek seçmek, bilmeden katlanmaktan iyidir.** İki yılınızı bir patron şirketinde hızlanarak geçirmek de, bir kurumsalda sistem öğrenerek geçirmek de meşru bir tercihtir. Meşru olmayan tek şey, orada nasıl olup da bulunduğunuzu bilmemektir. Dizinin başında bir soru sormuştum: *"Bu kararı kim geri alabilir?"* Şimdi sonunda aynı soruyu size soruyorum — bulunduğunuz yer hakkındaki kararı, kim geri alabilir? Cevap sizseniz, geri kalan her şey ayrıntıdır. ## Şirket büyürken ne kırılır: patron şirketinden kurumsala geçiş - Kaynak: https://muhammetsafak.com/tr/blog/sirket-buyurken-ne-kirilir-patron-sirketinden-kurumsala-gecis/ Kariyerimin bir döneminde, tam ortasından geçtiği bir yılda bir şirkete girdim. "Tam ortası" derken şunu kastediyorum: eski hız gitmişti, yeni sistem henüz gelmemişti. Bir iş için artık patrona sorup beş dakikada cevap alamıyordunuz — araya bir yönetici, bir de form girmişti. Ama o form da işe yaramıyordu, çünkü kimse ona bakmıyordu. Yani patron şirketinin belirsizliği hâlâ oradaydı; üstüne kurumsalın yavaşlığı eklenmişti. İnsanlar iki dünyanın da yalnızca kötü tarafını yaşıyordu. Herkes birbirine kızgındı ve herkes haklıydı. En kötüsü şuydu: kimse bunun **geçici** olduğunu bilmiyordu. Bir dönemin ortasında olduğunuzu, o dönemin içindeyken göremiyorsunuz. Bu dizinin ilk yazısında şirket tiplerini ayıran şeyin büyüklük değil, [kararın nerede alındığı](/tr/blog/kurumsal-mi-patron-sirketi-mi/) olduğunu söylemiştim. Bu yazı, o iki tip arasındaki **yolculuğa** dair: şirket büyürken tam olarak ne kırılıyor, kim ne kaybediyor ve siz bu dönemde nerede duruyorsunuz. ## Büyüme sürekli değil, basamaklıdır Şirketler yumuşak bir eğriyle büyümüyor. Bir süre aynı şekilde çalışıyor, sonra bir eşikte bir şey **çatlıyor**, yeni bir çalışma biçimi kuruluyor, ve o biçim bir sonraki eşiğe kadar dayanıyor. Aşağıdaki sayılar bir yasa değil; benim gördüğüm bir örüntü. Şirketten şirkete kayar, sektöre göre değişir. Ama sırası hep aynı. | Yaklaşık eşik | Kırılan şey | İlk kez duyulan cümle | | --- | --- | --- | | **~10 kişi** | Patron artık herkesi tek tek göremiyor | "Benim haberim yoktu." | | **~30–40 kişi** | Orta kademe doğuyor; şirketin birbirini tanımayan ilk iki insanı çıkıyor | "O ekipten sorumlu bir arkadaş var, ona sorayım." | | **~100 kişi** | Departman siloları; ekipler birbirinin müşterisi oluyor | "Onlar bizim iç müşterimiz." | Bu tablodaki her satırda kırılan şey aslında aynı: **enformel koordinasyonun kapasitesi.** Yani koridorda çözülen şeyin koridorda çözülemez hâle gelmesi. Küçük şirketin sırrı süreci olmaması değil; sürecin **insanların kafasında** olması. On kişiyseniz herkes herkesin ne yaptığını yaklaşık olarak biliyor, eksik bilgi bir kahve molasında tamamlanıyor. Bu bedava görünüyor ama bedava değil — sadece faturası görünmüyor. Kırk kişi olduğunuzda o fatura kesiliyor: aynı işi iki kişi yapıyor, bir iş hiç yapılmıyor, bir müşteriye iki farklı söz veriliyor. **Soru:** Şirket büyürken tam olarak ne kırılır? **Cevap:** Süreç değil, **enformel koordinasyon** kırılır. Kurumsallaşma, insanların kafasında taşınan koordinasyonun taşınamaz hâle gelmesi ve yazıya dökülmesidir. Süreçler bu ihtiyacın sebebi değil, sonucudur. ## İlk profesyonel yöneticinin gelişi Geçişin en sancılı anı, dışarıdan ilk gerçek yöneticinin gelmesidir. Bu kişi çoğu zaman görevini iyi yapar. Bir toplantı düzeni kurar, bir talep akışı tanımlar, kimin neye karar verdiğini yazar. Ve tam bu noktada, kurucunun "biz eskiden çok hızlıydık" hafızasına çarpar. Yeni yöneticinin aynı anda iki şeyi yapması gerekir: **sistem kurmak** ve **meşruiyet kazanmak**. Sorun şu ki bu ikisi birbirini baltalar. Sistem kurdukça "bürokrat" olur — ekipteki eski çalışanların gözünde işleri yavaşlatan adamdır. Kurmazsa "niye geldi bu zaten" olur. Kazanamayacağı bir oyunun içindedir ve bunu genelde ilk üç ayda anlar. Buradaki çatışma kişisel değil, **yapısaldır**. Kim gelse aynı yere sıkışırdı. Bu yüzden yeni yöneticiyi kovmak sorunu çözmez; sadece geciktirir ve şirketin bir sonraki denemesini pahalılaştırır — çünkü ikinci gelen kişi, birincisinin nasıl bittiğini biliyordur. ## Kurucunun yas süreci Bu bölümü atlamak isterdim, çünkü çalışan tarafında oturunca kurucuya kızmak kolay. Ama gördüklerim beni başka bir yere getirdi. Kontrolü bırakmak, kurucu için bir yönetim tekniği değil. Kendi kurduğu şeyin artık kendisi olmadığını kabul etmek. Bir çalışan için şirket bir **yer**dir; kurucu için bir **uzuv**dur. Şirketin bir kararı, onun bir kararıdır. Şirketin bir hatası, onun hatasıdır — hem duygusal hem de nakit olarak. "Delege et" tavsiyesi teknik olarak doğru, duygusal olarak eksik. Kimse size uzvunuzu nasıl bırakacağınızı öğretmiyor. Ve kurucu, devrettiği her kararı geri alma dürtüsüyle boğuşuyor. Bu dürtü kötü niyetten değil, hâlâ riskin onun cebinde durmasından geliyor. Ama bir kez geri alırsa devir biter. Ekip bunu iki gün içinde öğrenir ve bir daha karar vermez — kâğıt üzerinde yetki verilmiştir, pratikte kimse kullanmaz. Şirket bundan sonra "karar bekleyen" bir organizasyona dönüşür ve kurucu bunu ekibin inisiyatifsizliğine yorar. ## Erken çalışanların sessiz statü kaybı En az konuşulan şey bu. Şirketin ilk yıllarındaki çalışan, patronla aynı odada oturmuştur. Bir işi kırk saniyede halletmiştir. Kimse ona ne yapacağını söylememiştir, çünkü ne yapılacağını zaten o biliyordur. Bu, resmî bir yetki değildi — bir **ayrıcalıktı**. Kurumsallaşma bu ayrıcalığı alır. Aynı işi artık bir talep formundan geçirirsiniz, bir önceliklendirme toplantısında savunursunuz, sizden altı ay sonra gelmiş bir yöneticinin onayını beklersiniz. Kaybettiğiniz şey unvan değil, **mesafedir**: patronla aranızdaki mesafe açıldı ve bunu kimse size söylemedi. Bunun kötü tarafı şu: bu kaybı yüksek sesle söyleyemiyorsunuz. Çünkü kulağa şımarıklık gibi geliyor. "Eskiden patrona doğrudan söyleyebiliyordum" cümlesini kurmak, hakkınızı aramak gibi değil, ayrıcalık istemek gibi duruyor. Meşru bir dili yok. O yüzden söylenmiyor. Onun yerine "burası eskisi gibi değil" deniyor, "ruh gitti" deniyor, ve insanlar sessizce ayrılıyor. Şirket de neden gittiklerini hiçbir zaman öğrenmiyor — çıkış görüşmesinde "yeni bir fırsat" yazılıyor. Başka bir yazıda [kalmak mı gitmek mi kararını](/tr/blog/is-degistirme-karari-kalmak-mi-gitmek-mi/) ayrıca konuştuk; geçiş dönemindeki ayrılıkların çoğu, aslında dile getirilememiş bir statü kaybının geç faturasıdır. ## Bu dönemde kıdemli geliştiricinin iki yolu var Geçiş dönemi bir kayıp dönemi gibi görünüyor. Ama kıdemli biri için tam tersi: kariyerin en yüksek getirili anlarından biri. Sebebi basit. Şirketin şu anda **hiç sistemi yok** ve birinin kurması gerekiyor. Kurulduktan sonra o sistemi değiştirmek yıllar alacak. Yani şu anda yazılan her kural, önümüzdeki beş yıl boyunca insanların nasıl çalışacağını belirleyecek — ve bu kuralları yazacak kişi henüz belli değil. İki yol var: - **Mimarı olmak.** Code review düzenini, teknik karar kaydını, on-call'ı, işe alım çıtasını siz önerirsiniz. Bunu yaparken kendi çalışma biçiminizi de korursunuz — çünkü sistemi yazan kişi, sistemin içine kendi çalışma biçimini gömer. Bu bir kurnazlık değil; sadece işin nasıl yürüdüğünü bilen kişinin yazmasıdır. - **Kurbanı olmak.** Süreç size kurulur. Sizin hiç katılmadığınız bir toplantıda, sizin işinizi hiç yapmamış biri tarafından. Sonra siz ona uyum sağlarsınız ve beş yıl boyunca "bu saçma kural niye var" diye söylenirsiniz. Çoğu insan bu dönemi bir şikâyet dönemi olarak geçiriyor. Şikâyetin haklı olması bir şeyi değiştirmiyor. [Kıdemin unvanla gelmeyen sorumluluğu](/tr/blog/kidem-unvanla-gelmeyen-sorumluluk/) tam olarak burada devreye giriyor: sistemin kurulduğu odada olmak, kimsenin size vermediği ama kimsenin de engellemediği bir yetkidir. Girmeniz yeterli. ## Üç masadan bakış Bu yazıda masalar biraz kayıyor — çünkü geçiş döneminde herkesin rolü kayıyor. **Kıdemli / erken çalışanın masasından.** Yeni sistem ayrıcalığınızı alıyor ve bunu itiraf edemiyorsunuz. Kısıtınız: kaybınız **gerçek** ama meşru bir dille ifade edilemiyor. Söylerseniz şımarık, söylemezseniz küskün görünüyorsunuz. Üçüncü bir seçenek var ama zahmetli: kaybettiğiniz hızı bir ayrıcalık olarak değil, bir **kural** olarak geri istemek. "Bana doğrudan sorun" değil, "bu tip kararlar tek onayla geçsin." **(Yeni) yöneticinin masasından.** Hem sistem kurmak hem meşruiyet kazanmak zorundasınız ve bu ikisi çelişiyor. Kısıtınız: ekibin hafızasında siz yoksunuz. "Biz eskiden şöyle yapardık" cümlesinin öznesi değilsiniz ve o cümle her kurulduğunda odadaki en yabancı kişi olduğunuz hatırlatılıyor. O hafızayı satın alamazsınız; ancak yeni bir hafıza üretecek kadar kalırsanız kazanırsınız. **Patronun / kurucunun masasından.** Devrettiğiniz her kararı geri alma dürtüsüyle boğuşuyorsunuz. Kısıtınız gerçek: risk hâlâ sizin cebinizde. Devrettiğiniz kişi hata yaparsa faturayı yine siz ödüyorsunuz — ve bu hiç adil değil. Ama bir kez geri alırsanız devir biter; ondan sonra hem faturayı hem kararları taşımaya devam edersiniz. Devir, hata yapma hakkını da devretmektir; devretmediğiniz şey yetki değil, **hata payı**dır. Bu üç masa aynı olayı yaşıyor ve üçü de kendini yalnız hissediyor. Kimse diğerinin neyi kaybettiğini bilmiyor. ## Kapanış Geçiş dönemi kimsenin suçu değil. Şirket büyüyorsa bu dönem gelecek; büyümüyorsa zaten başka bir yazının konusu. Bu dönemin içindeyken görebileceğiniz tek şey, kendi masanızdaki kayıp. Görmediğiniz şey, diğer iki masadaki insanın da bir şey kaybettiği. Kurucu bir uzvunu, yeni yönetici bir geçmişini, siz bir ayrıcalığınızı. Bunu bilmek dönemi kolaylaştırmıyor. Ama en azından kime kızacağınızı seçerken bir kere daha düşünmenizi sağlıyor — ve o dönemde kurulan sistemin odasında, kızmak yerine oturmayı akıl ediyorsunuz. ## Yabancı bir kod tabanını devralmak - Kaynak: https://muhammetsafak.com/tr/blog/yabanci-bir-kod-tabanini-devralmak/ Bir geliştiricinin işinin büyük bölümü, kendi yazmadığı kodun içinde geçer. Yeni bir işe başlarsınız, devredilen bir projeyi alırsınız ya da yıllar önce kendi yazdığınız ama artık yabancılaştığınız bir koda dönersiniz. [Sıfırdan yazmak istisnadır](/tr/blog/sifirdan-hizli-mevcut-sistemde-yavas/); asıl beceri, var olan bir kod tabanına hızla ve sağlam biçimde hâkim olmaktır. Yıllar içinde bunun için oturmuş bir düzenim oldu. ## Önce çalıştırın İlk yaptığım şey kodu okumak değil, **çalıştırmak.** Projeyi yerel makinemde ayağa kaldırana kadar onun hakkında hiçbir şey bildiğimi varsaymıyorum. Bu adım çoğu zaman tek başına öğreticidir. Kurulumda hangi adımların belgelenmediğini, hangi gizli bağımlılıkların olduğunu, ortamın ne kadar kırılgan olduğunu daha ilk saatte görürsünüz. Bir projenin sağlığı hakkındaki ilk dürüst sinyal, "kurulumu ne kadar sürdü?" sorusunun yanıtıdır. ## Testleri okuyun Dokümantasyon eskir; yorumlar yalan söyleyebilir; ama testler çalışıyorsa, sistemin gerçekte ne yaptığını anlatır. Bir kod tabanını tanımak için testlerden daha dürüst bir kaynak az bulunur. Testler bana iki şey veriyor: sistemin en kritik davranışlarının hangileri olduğu ve bir şeyi değiştirdiğimde neyin beni uyaracağı. Test yoksa, bu da bir bilgidir — değişiklik yaparken çok daha temkinli olmam gerektiğini en baştan öğrenmiş olurum. ## Tek bir akışı uçtan uca izleyin Bütün kodu okumaya çalışmak boğucu ve verimsiz. Bunun yerine **tek bir isteği** seçip onu uçtan uca takip ediyorum: giriş noktasından başlayıp veritabanına ve geri dönüş yanıtına kadar. Bu tek dikey kesit, kod tabanının nasıl düşündüğünü gösterir — katmanları nasıl ayırdığını, adlandırma alışkanlıklarını, kararların nerede verildiğini. Bir akışı gerçekten anladığınızda, ikincisini anlamak çok daha hızlıdır; çünkü artık deseni biliyorsunuzdur. ## Erken refactor etmeyin En sık yaptığım gençlik hatası buydu: yabancı bir koda bakıp "bu yanlış yazılmış" deyip hemen düzeltmeye girişmek. Yıllar bana şunu öğretti — tuhaf görünen kodun çoğu, görmediğim bir kısıtın izidir. O acayip `if`, bir zamanlar gerçek bir hatayı kapatmıştır. Bu yüzden ilk birkaç hafta neredeyse hiç refactor yapmıyorum. Önce kodun *neden* böyle olduğunu anlıyorum. Bir şeyi değiştirme hakkını, ancak onu neden değiştirmemem gerekebileceğini de gördükten sonra kazanıyorum. ## İnsanlar ve git geçmişi de birer kaynaktır Kodun kendisi tek bilgi kaynağı değil. İki kaynağı daha düzenli kullanmayı öğrendim. Birincisi, git geçmişi. Tuhaf görünen bir satırla karşılaştığımda, o satırın hangi commit'le geldiğine ve o commit'in mesajına bakıyorum. Çoğu zaman "şu hatayı düzeltir" diyen bir mesaj, o acayip kodun neden orada olduğunu tek cümlede açıklıyor. Kod ne yaptığını söyler; git geçmişi neden yaptığını. İkincisi, insanlar. Projeyi daha önce bilen biri varsa, ona soracağım soruları biriktiriyorum — tek tek değil, toplu olarak. "Bu modül neden ayrı?" gibi bir soru, bazen yarım günlük okumayla bulamayacağım bir bağlamı bir dakikada veriyor. Yabancı bir koda hâkim olmak yalnız yapılan bir iş değil; doğru soruları doğru kişiye sormayı da içeriyor. ## Özet Yabancı bir kod tabanını devralmak bir zekâ sınavı değil, bir sabır meselesidir: çalıştır, testleri oku, bir akışı izle, erken dokunma. Hızlı görünen — hemen düzeltmeye girişmek — aslında en yavaş yoldur; çünkü anlamadan yapılan değişiklik, çoğu zaman geri alınması gereken bir değişikliktir. ## Kriz anı: şirketin karakteri orada belli olur - Kaynak: https://muhammetsafak.com/tr/blog/kriz-ani-sirketin-karakteri-orada-belli-olur/ Gecenin bir vakti odaya toplanmıştık. Production yanıyordu, telefonlar çalıyordu, herkes ekranına bakıyordu. Ve odadaki ilk soru "Ne oldu?" olmadı. "Kim yaptı?" oldu. O soru sorulduğu anda odadaki herkes bir şey öğrendi. Kimse bir şey söylemedi, kimse itiraz etmedi; sadece öğrendik. Sorunu birkaç saat içinde çözdük, sabaha kadar ayaktaydık, sistem düzeldi. Ama o geceden sonra o şirkette kimse kötü haberi bir daha erken getirmedi. Bir şeyin ters gittiğini fark eden insan önce kendi başına çözmeye çalıştı, çözemeyince biraz daha uğraştı, sonra biraz daha — ve haber odaya hep geç ulaştı. Sorun düzeldi. Şirket bozuldu. [Bu dizinin ilk yazısında](/tr/blog/kurumsal-mi-patron-sirketi-mi/) dört eksen saymıştım; üçüncüsü şuydu: *hatanın maliyeti kime yazılıyor?* Bu yazı tamamen ona ayrılmış durumda. Krizin teknik tarafı — nasıl tespit edilir, nasıl geri alınır, nöbet nasıl kurulur — bu yazının konusu değil. Konu şu: **o gece odada ne söylendi, kim sustu, ve altı hafta sonra ne değişti.** ## Kriz refleksleri şirket tipine göre öngörülebilir İki tipte de kriz yaşadım ve bir süre sonra fark ettim ki refleksler neredeyse tahmin edilebilir. **Patron şirketi fail arar.** Bunun bir mantığı var: orada karar bir kişide, dolayısıyla hata da bir kişide olmalı. Hızlıdır — bir isim bulunur, iş kapanır, herkes uyumaya gider. Ama o hız korku üretir. Ve korku raporlamayı öldürür. Bir sonraki krizde sistemi bir saatte değil, altı saatte öğrenirsiniz; çünkü aradaki beş saatte birileri sessizce durumu kurtarmaya çalışmıştır. **Kurumsal prosedür üretir.** Bu da mantıklı: orada karar bir rolde, dolayısıyla hata da sistemde olmalı. Sorumluluk dağıtılır, kimse tek başına suçlanmaz. Bu, patron şirketinden daha insancıldır — ama bir yerde tuhaflaşır. "Blameless postmortem" denen şey bazen bir toplantıdan çok bir **performansa** dönüşür: kimse kimseyi suçlamaz, çünkü kimse hiçbir şeyin sahibi değildir. Kimsenin suçlanmadığı ama kimsenin de sorumluluk almadığı bir oda, kimsenin bir şey öğrenmediği bir odadır. İkisi de arızalı. Ama arızaları farklı yönde: biri sahipliği tek bir kişiye yıkar, diğeri sahipliği buharlaştırır. Aradaki denge — sahiplik var ama ceza yok — kendiliğinden oluşmuyor. ## Suçlu aramanın gerçek maliyeti bir kişi değil, bir sistemdir Suçlu bulmanın bariz maliyeti, suçlanan kişinin morali sanılır. Değil. Asıl maliyet çok daha sinsi. Suçlanan kişi bir daha erken haber vermez. Bunu bilinçli bir kararla, "bir daha söylemeyeceğim" diye düşünerek yapmaz; sadece bir sonraki sefer bir şeyi kontrol etmek için biraz daha bekler. Emin olmak ister. Çok emin olmak ister. Ve onu izleyenler de aynısını yapar. Ceza bir kişiye kesilir, ders bütün odaya yazılır. Sonuç şu: bir sonraki krizde şirket olayı bir saat sonra değil, bir gün sonra öğrenir. **Korku, tespit süresini uzatır — ve tespit süresi, krizin gerçek maliyetidir.** Sistemin yarım saatte mi yoksa yarım günde mi düzeldiği, hatanın ne kadar "ağır" olduğuyla değil, haberin odaya ne kadar hızlı ulaştığıyla belirlenir. Suçlu arayan şirket, kendi tespit süresini kendi eliyle uzatır ve bunu asla bir maliyet kalemi olarak görmez. ## Sağlıklı refleks: "Bu nasıl mümkün oldu?" Doğru soru bellidir ve söylemesi kolaydır: *Bu nasıl mümkün oldu?* Kişi sorusu değil, sistem sorusu. "Kim deploy etti" değil, "birinin bunu deploy edebilmesi nasıl mümkün oldu". Aynı olayı anlatan iki cümle; birbirinden tamamen farklı iki şirket. Ama dürüst olalım. Bunu bir blog yazısında yazmak kolay. Krizin ikinci saatinde, para kaybederken, müşteri telefonun ucunda bağırırken, uykusuzken bunu yapmak zor. O anda kafanızda beliren cümle "bu nasıl mümkün oldu" değil, "bunu kim yaptı"dır. Bu insani bir şey; ayıp değil. Şunu görmek gerekiyor: **kimsenin doğal refleksi bu değildir.** Bu refleks öğrenilir. Ve neredeyse her zaman **yukarıdan** öğrenilir. Odadaki en kıdemli kişi hangi soruyu sorarsa, oda o soruyu öğrenir. Kimse bir kültür dokümanı okuyarak öğrenmez bunu; birinin kriz anında ne dediğine bakarak öğrenir. ## Krizde susan insanlar en pahalı insanlardır Her krizde odada en az bir kişi vardır: bir şeyin ters gittiğini biliyordur ve söylemiyordur. Bu kişiyi kötü niyetli sanmak kolay ve yanlış. O kişi sabotajcı değil. O kişi **daha önce konuşmanın bedelini ödemiş** biridir. Bir kere bir riski dile getirmiş, "vakit yok, geç" cevabını almış; bir kere bir hatayı erken söylemiş, o hatanın sahibi ilan edilmiştir. Şimdi susuyor. Sessizliği bir karakter kusuru değil, bir **öğrenme** çıktısı. Bu yüzden "burada herkes rahatça konuşabilir" demek hiçbir işe yaramaz. Şirketin sessizliğini kültür değil, **hafıza** üretir. İnsanlar sizin ne söylediğinizi değil, en son konuşan kişiye ne olduğunu hatırlar. **Soru:** Bir şirkette psikolojik güvenliğin gerçekten var olup olmadığını nasıl anlarım? **Cevap:** Şirketin son krizinde kötü haberi ilk getiren kişinin bugün ne durumda olduğuna bakın. Terfi ettiyse ya da en azından hiçbir şey olmadıysa güvenlik vardır. Sessizce kenara çekildiyse, herkes bunu zaten görmüştür ve kimse size bunu söylemeyecektir. ## Asıl test krizden altı hafta sonra yapılır Krizden hemen sonra herkes doğru cümleleri kurar. "Ders çıkardık." "Bir daha olmayacak." "Süreç ekliyoruz." Bu cümleler samimi olarak da kurulabilir; sorun samimiyette değil. Ölçü, söylenen değil, **altı hafta sonra gerçekten değişmiş olan** şeydir. Çoğu şirkette altı hafta sonra değişmiş olan tek şey bir dokümandır. Bir sayfa yazılmıştır, bir yere konmuştur, kimse açmamıştır. O doküman kimseyi korumaz — çünkü kriz anında kimse doküman okumaz. Bir sonraki krizde aynı odaya aynı insanlar aynı yüzle girer ve aynı ilk soru sorulur. Gerçekten bir şey değişmişse, bunu bir dokümanda değil, insanların davranışında görürsünüz: bir şeyden emin olmayan biri artık daha erken sesleniyordur; bir hatayı fark eden kişi onu saklamıyordur; "ben yaptım" cümlesi bir itiraf gibi değil, bir bilgi gibi kuruluyordur. Bunlar ölçülebilir şeyler değil, ama görülebilir şeyler. Bir şirketin krizden bir şey öğrenip öğrenmediğini anlamak için krizin ertesi gününe bakmayın. Altı hafta sonrasına bakın. ## Üç masadan bakış **Kıdemli çalışanın masasından.** Krizde en değerli iki şeyiniz var: **sakinlik** ve **kayıt**. Ne yaptığınızı yazın — kendinizi savunmak için değil, iki saat sonra kimsenin ne yaptığını hatırlamayacağı için. Krizin ortasında hafıza ilk kaybolan şeydir; kim neyi ne zaman denedi, yarım saat sonra kimse bilmez. Kısıtınız: krizin ortasında sakin kalmak bir karakter özelliği değil, bir **pratiktir**. İlk birkaç krizinizde sakin olmayacaksınız; elleriniz titreyecek, yanlış komut yazacaksınız. Bu normaldir ve bunun için kendinizi yargılamayın. Sakinlik, birkaç kriz sonra gelir — daha önce değil. **Yöneticinin masasından.** Ekibinizi **kalkanlamalısınız**. Yukarıdan gelen "kim yaptı" sorusuyla ekibinizin arasına girmezseniz, bir daha kimse size kötü haberi getirmez ve siz şirkette her şeyi en son öğrenen kişi olursunuz — yani işinizi yapamaz hâle gelirsiniz. Kısıtınız şu: yukarıdan **size de** bir isim soruluyor ve "sistem" cevabı yukarıyı tatmin etmiyor. İsim vermezseniz, isim siz olursunuz. Kalkanlamanın bedelini siz ödüyorsunuz. Çoğu yöneticinin bunu yapmamasının sebebi kötü niyet değil, bu bedel. Ama bir kere ödediğinizde ekibiniz bunu asla unutmaz — ve o günden sonra size her şeyi erken söylerler. **Patronun masasından.** İlk 24 saatte kurduğunuz cümle, şirketin kültürünü **iki yıl** belirler. Öfkeniz meşru: para sizin, müşteri sizin, itibar sizin; sabaha kadar uyumayan sizsiniz ve kaybedilen sizin kaybınız. Kimse bunu size çok görmüyor. Ama o gece söylediğiniz cümle, önümüzdeki iki yıl boyunca size kötü haberin **ne kadar geç ulaşacağını** belirleyecek. "Kim yaptı" derseniz, bir dahaki sefere olayı en son siz duyacaksınız. Kısıtınız: kimse size krizde ne diyeceğinizi öğretmedi. Çalışanın bir yöneticisi, yöneticinin bir patronu var; sizin danışacağınız kimse yok ve o gece odada en yalnız kişi sizsiniz. Üç masa da haklı, üç masa da eksik. Ama üçünün içinde kültürü belirleyen tek bir cümle var ve o cümle en üstteki masadan çıkıyor. ## Kapanış Kriz, şirketin karakterini yaratmaz — sadece görünür kılar. Zaten orada olan şey, baskı altında yüzeye çıkar. Bir kararın sonucunu sahiplenmek, [kıdemin ünvanla gelmeyen tarafıdır](/tr/blog/kidem-unvanla-gelmeyen-sorumluluk/); bir şirketin hatanın sonucunu nasıl sahiplendiği de öyle. İkisi de kriz anında ölçülür, iş ilanında değil. Bir sonraki krizinizde odada bulunuyorsanız, tek bir şeye dikkat edin: ilk soru ne oldu. Cevabını not edin. O soru, o şirkette geçireceğiniz sonraki iki yılı, size şirketin bütün kültür dokümanlarından daha dürüst anlatacak. ## Toplantı, raporlama ve görünürlük tiyatrosu - Kaynak: https://muhammetsafak.com/tr/blog/toplanti-raporlama-ve-gorunurluk-tiyatrosu/ Bir dönem, haftada on bir saatini toplantıda geçiren bir ekiple çalıştım. Merak edip takvimi tek tek çıkardım: on bir saat. Hiçbirinde bir karar alınmıyordu. Hepsinde durum aktarılıyordu — kimin ne yaptığı, neyin geciktiği, neyin "bu hafta bitecek" olduğu. Aynı bilgi, farklı odalarda, farklı insanlara üç kez anlatılıyordu. Aynı yıl, tamamen öbür uçta bir yer daha gördüm. Takvim bomboştu; toplantı diye bir kültür yoktu. Ama patron günde ortalama dört kez masaya gelip "N'aptın?" diye soruyordu. Toplantı yok, kesinti sonsuz. İlk bakışta bunlar birbirinin zıddı görünüyor. Değiller. İkisi de aynı şeyi yapıyor: **başkasının içi rahat etsin diye sizin odağınızı harcıyor.** Bu yazı, [dizinin ilk yazısında](/tr/blog/kurumsal-mi-patron-sirketi-mi/) kurduğum ayrımın en görünür yerine bakıyor — çünkü toplantı, bir şirketin karakterinin haftalık olarak sahneye çıktığı yerdir. Bir şeyi baştan söyleyeyim: bu yazı "toplantılar saçma" demeyecek. O cümle kolay, ucuz ve yanlış. Bir şeyi eleştirmek istiyorsanız önce neden var olduğunu ciddiye almanız gerekir. ## Kurumsalda statü toplantısı bir risk sigortasıdır Kurumsalda toplantıların çoğu bilgi aktarmak için yapılmaz. Bunu anlamak biraz zaman aldı bende. Büyük bir şirkette kimsenin düşmek istemediği tek bir konum vardır: **"haberim yoktu."** Bir şey patladığında sorulan ilk soru "kim biliyordu?" olur. Bilmiyor olmak, o an için masumiyet gibi görünür ama kurumsalda öyle işlemez; bilmiyor olmak, bilmesi gereken yerde durmamış olmaktır. Kariyerinizi bitirmez, ama sizi bir sonraki odada dinlenmeyen kişi yapar. Haftalık statü toplantısı tam olarak bunun için var. Amacı bilgiyi aktarmak değil, **bilgiyi almış olmayı kayda geçirmek.** Odadaki herkes duydu, herkes onaylayarak başını salladı, dolayısıyla artık kimse "benim haberim yoktu" diyemez. Bu bir bilgi mekanizması değil, bir sorumluluk dağıtma mekanizmasıdır. Rapor da öyle. Kimsenin sonuna kadar okumadığı o haftalık rapor bir iletişim aracı değildir; bir savunma belgesidir. İşin gerçek durumunu anlatmaz, işin durumunun **anlatılmış olduğunu** kanıtlar. İki farklı şey. Bunu anladığınız an kurumsaldaki toplantılar birdenbire mantıklı görünmeye başlar. Ve tam da bu yüzden kaldırılamazlar: kimse kendi sigortasını iptal etmez. "Bu toplantıyı e-postaya çevirelim" dediğinizde karşınızdakinin duyduğu şey verimlilik önerisi değil, koruma kaybıdır. Direnç irrasyonel değil; sizinkinden farklı bir riski yönetiyor. ## Patron şirketinde bunun karşılığı "geçerken uğrama yönetimi" Patron şirketinde takvim temizdir ve bununla gurur duyulur. "Biz toplantı yapmayız, iş yaparız." Yapılan şeyin adı toplantı değil, ama işlevi aynı: patron içi rahat etsin diye siz durup anlatırsınız. Fark şu ki bu, takvimde görünmez. Görünmediği için **maliyeti de görünmez** — dolayısıyla bedava sanılır. Oysa bir sorunun bedeli, o sorunun süresi değildir. Bir sorunun bedeli, kırdığı odağın toparlanma süresidir. Karmaşık bir işin ortasındaki bir geliştirici için beş dakikalık soru, kırk dakikalık iştir; kafasındaki yapıyı yeniden kurması gerekir. Günde dört kez uğrarsanız, o gün bitmiştir. Kimse fazla mesai yapmıyor değildir — herkes akşam yediden sonra çalışıyordur, çünkü asıl iş ancak o saatte başlayabiliyordur. Patron bunu genellikle bilmez, ve bu bir kötü niyet meselesi değil. Onun kendi işi zaten kesintilerden oluşur: gün boyu telefon, müşteri, banka, tedarikçi. Kesinti onun için işi bölen bir şey değil, **işin kendisidir.** Kendi çalışma biçiminde maliyeti olmayan bir şeyin başkasında maliyeti olduğunu hayal etmesi zordur. Sonuç ironik: toplantısı olmadığı için kendini verimli sanan şirket, çoğu zaman toplantılı olandan daha az odaklanmış çalışır. En azından toplantının bir başlangıç ve bitiş saati vardır. ## Görünürlük tiyatrosu nedir, ne değildir Buraya kadar okuyup "demek ki görünürlük saçmalık, ben işimi yapayım yeter" sonucunu çıkarmayın. Bu, dizide en sık gördüğüm ve en pahalıya patlayan yanılgı. Görünürlük işin bir parçasıdır. Sebebi basit ve acı: **fark edilmeyen iş, yapılmamış işten ayırt edilemez.** Kimsenin görmediği bir refactor ile hiç yapılmamış bir refactor, kurumun defterinde aynı satırdadır. Bunu bir haksızlık olarak yaşayabilirsiniz — ama değiştirmez. Yazmanın, anlatmanın, işi görünür kılmanın neden bir kariyer yatırımı olduğunu [ayrı bir yazıda](/tr/blog/gorunur-olmak-yazmak-neden-bir-kariyer-yatirimidir/) anlatmıştım; buradaki mesele onun sınırı. Tiyatro, görünürlüğün üretimin **yerine geçtiği** andır. Görünürlük yapılmış işi taşır; tiyatro, taşınacak iş olmadığı için taşımanın kendisini iş sayar. Aradaki çizgi ince ama testi net: > **Bu toplantı bir karar mı üretiyor, yoksa sadece bir kayıt mı?** Kayıt üreten toplantı bir belge olabilirdi. Belge olabilecek bir şey için sekiz kişinin bir saatini almak, tiyatronun tanımıdır. ## Pratik ayrım: üç tür toplantı Yıllar içinde şuna vardım: toplantı diye tek bir şey yok. Üç ayrı şey var ve aynı isimle anılıyorlar. Karışıklığın çoğu buradan çıkıyor. | Tür | Amacı | Sonunda ne çıkar? | Gerekli mi? | | --- | --- | --- | --- | | **Karar toplantısı** | Bir seçim yapmak | Bir karar ve sahibi | Evet — kısa tutun | | **Keşif toplantısı** | Problemi anlamak | Daha iyi bir soru | Evet — dağınık olması normal | | **Statü toplantısı** | Durumu bildirmek | Bir kayıt | Çoğu zaman hayır — bir mesaj olabilirdi | Bu üçünü ayırmak, toplantı sayısını azaltmaktan daha değerli. Çünkü asıl israf toplantının varlığı değil, **hangi toplantıda olduğunu bilmeyen insanların** orada bulunmasıdır. Keşif toplantısına karar bekleyerek giren kişi "vakit kaybı" der. Karar toplantısına keşfetmeye giren kişi toplantıyı uzatır. Statü toplantısına ne için girdiğini bilmeyen kişi ise sadece bekler. Bir şeyi de burada bırakayım: **dahil olmak, davet edilmiş olmakla aynı şey değildir.** Takvimdeki her davet size ait değil. "Bu toplantıda benim rolüm ne?" sorusunu sormak kabalık değil, meslek ahlakıdır — ve cevabı olmayan bir toplantı, cevabı olmayan bir maliyettir. **Soru:** Görünürlük ile görünürlük tiyatrosu arasındaki fark nedir? **Cevap:** Görünürlük, yapılmış bir işin sonucunu görünür kılmaktır. Tiyatro, iş yapılmamışken meşguliyeti görünür kılmaktır. Birincisi işi taşır, ikincisi işin yerine geçer. ## Üç masadan bakış **Kıdemli çalışanın masasından.** Görünürlüğü işten saymak zorundasınız — sevmeseniz de, "işim kendini anlatsın" demeyi tercih etseniz de. Ama tiyatroya katılmak ile görünür olmak farklı şeyler: yaptığınız işin **sonucunu** anlatmak görünürlüktür, meşgul olduğunuzu anlatmak tiyatrodur. İlki bir cümleyle biter, ikincisi hiç bitmez. Kısıtınız şu: hangi toplantıya çağrıldığınıza siz karar vermiyorsunuz. Elinizdeki tek gerçek yetki, girdiğiniz toplantının hangi tür olduğunu anlayıp ona göre davranmak. **Yöneticinin masasından.** Statü toplantısını yazılı ve asenkron hâle getirebilirsiniz. Yapabilirsiniz, ekibiniz size teşekkür eder, iki saat geri kazanırsınız. Peki neden yapmıyorsunuz? Çünkü o toplantı sadece ekibin size görünme kanalı değildi — sizin de **yukarıya görünme kanalınızdı.** Onu kaldırdığınızda ekibin odağını kazanır, kendi görünürlüğünüzü kaybedersiniz. Bunu itiraf etmek gerek: bu tembellik değil, çıkar çatışmasıdır. Ve adını koymadan çözülmez. **Patronun masasından.** Her "N'aptın?" sorusu size bedava geliyor; ekibe gelmiyor. Merakınız meşru — para sizin, risk sizin, gecikmenin faturasını siz ödüyorsunuz. Kimse bunu size tartıştıramaz. Ama merakınızın faturasını odak olarak kesiyorsunuz ve bu faturayı hiçbir yerde görmüyorsunuz. Kısıtınız da gerçek: bazı işlerde bir haftada bir öğrenmek fazlasıyla geç olabilir. O yüzden asıl mesele soruyu sorup sormadığınız değil, **ne zaman** sorduğunuz. Günde dört kez sorulan soru bilgi getirmez; sadece cevabı hazırlayan kişiyi işten alıkoyar. Üçünün de haklı olduğu yer burası. Ve üçü de aynı şeyi ödüyor: dikkat. ## Kapanış Toplantıyı savunmak da, toplantıya sövmek de kolay. Zor olan, her toplantının kimin içini rahatlatmak için orada olduğunu sormak. Bunu sorduğunuzda bazı toplantıların gerçekten gerekli olduğunu görürsünüz — bir kararın sahibi çıkar, bir soru netleşir. Bazılarının ise sadece bir kaygıyı yönettiğini fark edersiniz. İkincisini yok edemeyebilirsiniz; kaygı gerçek ve kaygının yönetilmesi de bir iştir. Ama en azından onu ucuzlatabilirsiniz: bir belgeye, bir mesaja, haftada bir yazılı bir özete. Kalan zamanı da asıl işe verirsiniz. Zaten görünür olmaya değecek tek şey o. ## Performans, terfi ve zam: sistem olsa da olmasa da bir pazarlık var - Kaynak: https://muhammetsafak.com/tr/blog/performans-terfi-ve-zam-sistem-olsa-da-olmasa-da-bir-pazarlik-var/ Kariyerimin iki ayrı döneminde, iki ayrı şirket tipinde, aynı konuşmayı yaptım. Birincisi kurumsaldaydı. Yöneticim karşıma oturdu, elinde bir dosya vardı. Bandımı gösterdi, kalibrasyon toplantısında notumun nasıl oluştuğunu anlattı, sonra o cümleyi kurdu: "Bu çeyrek bütçe kapalı." Nazikti, hazırlıklıydı, kırıcı değildi. Sistemin dilini iyi konuşuyordu. İkincisi patron şirketindeydi. Kapısını çaldım, konuyu açtım. Bana baktı, gülümsedi, tek cümle söyledi: "Sen iyisin, hallederiz." İkisi de aynı sonucu verdi. İkisinde de zam beklediğimden az geldi. Yıllar sonra fark ettim ki buradaki asıl bilgi, hangi şirketin daha cömert olduğu değildi. Sistemin varlığı sonucu değiştirmiyordu; **sadece pazarlığın dilini değiştiriyordu.** Dizinin [ilk yazısında](/tr/blog/kurumsal-mi-patron-sirketi-mi/) kurumsallığı "kararın kişiden ayrılabilmesi" diye tanımlamıştım. Para söz konusu olduğunda bu tanım en çıplak halini alıyor: bir tarafta kararın arkasında bir tablo var, diğer tarafta bir hatıra. ## Kurumsal: sistem ortalamayı korur, ucu cezalandırır Kurumsal zam süreci üç parçadan oluşur: bir **bant** (rolünüzün maaş aralığı), bir **not** (performans değerlendirmeniz) ve bir **kalibrasyon**. Kalibrasyon, farklı yöneticilerin verdiği notların aynı ölçeğe oturtulduğu toplantıdır — yani sizin notunuz, sizi hiç görmemiş insanların bulunduğu bir odada son halini alır. Bu sistemin amacı hakkında yıllarca yanıldım. Adalet ürettiğini sanıyordum. Üretmiyor. Bu sistemin ürettiği şey **savunulabilirliktir.** Her kararın arkasında bir tablo olsun ki kimse kimseye "beni kayırmadın" diyemesin, kimse dava açamasın, kimse ayrımcılık iddia edemesin. Bu kötü bir amaç değil; büyük bir organizasyonda gerçekten gerekli. Ama bir yan etkisi var ve o yan etki tam olarak sizi ilgilendiriyor: **Olağanüstü iş, olağanüstü ödüllendirilemez — çünkü tablo buna izin vermiyor.** Zorunlu dağılım varsa, en üst dilime kaç kişi girebileceği önceden bellidir. Bandın tavanı varsa, ne yaparsanız yapın o tavanın üstüne çıkamazsınız. Sistem ortalamayı korumak üzere kurulmuştur; uçları da aynı özenle törpüler. Kötü çalışanı kovmaz, olağanüstü olanı da uçurmaz. Buradan çıkacak sonuç sızlanma değil, taktik. Kalibrasyon toplantısında sizi savunacak kişi, sizin olmadığınız bir odada konuşuyor. Ve elinde ne varsa onunla konuşuyor. Hafızasıyla değil, elindeki malzemeyle. O malzemeyi ona vermek sizin işiniz. İşiniz sadece iyi iş yapmak değil; **iyi işi sistemin okuyabildiği dile çevirmek.** "Şu servisi stabil hale getirdim" cümlesi tabloya girmez. Neyin kaç kişiyi etkilediği, hangi kararın hangi maliyeti önlediği, kimin sizin yüzünüzden daha hızlı çalıştığı — bunlar girer. ## Patron şirketi: sistem yok, dolayısıyla anlatı her şey Patron şirketinde bant yoktur, kalibrasyon yoktur, tablo yoktur. Zam üç şeyin toplamıdır: **son üç ayın hatırası, sadakat algısı ve o günkü nakit.** Bunu ilk fark ettiğimde kızdım. Çünkü bu düzende yakınlık bir kaldıraçtır: patronun görüş alanında olan, ona son çeyrekte bir yangını söndürdüğünü hatırlatan kişi, sessizce üç yıldır sistemi ayakta tutan kişiden daha iyi konumdadır. Bu adaletsizdir. Ve "adaletsiz" demekle ortadan kalkmaz. Kızgınlığın işe yaramadığını anlayınca yaklaşımımı değiştirdim. Burada kimse sizin katkınızın kaydını tutmuyor — o halde **hatırlatmak sizin işiniz.** Recency bias'ı, yani insanın en son olanı en ağır tartma eğilimini, düşman olarak görmeyi bırakıp araç olarak kullanmak gerekiyor: bir zam görüşmesinde, görüşmeden önceki üç ay, görüşmenin kendisi kadar belirleyicidir. Bu manipülasyon değil. Manipülasyon, yapmadığınız işi yapmış gibi göstermektir. Yaptığınız işin görünür olmasını sağlamak ise, kayıt tutmayan bir sistemde hayatta kalmanın adıdır. ## Unvan enflasyonu neden şirket tipine göre değişir İki şirket tipinde de "senior" yazan iki kartvizit gördüm ve ikisi aynı şeyi anlatmıyordu. Sebebi basit: **unvanın fiyatı farklı.** | | **Patron şirketi** | **Kurumsal** | | --- | --- | --- | | Unvanın maliyeti | Sıfır | Bir banda ve maaş aralığına bağlı | | Ne zaman verilir | Para verilemediğinde | Bütçe ve onay zinciri elverdiğinde | | Neyi anlatır | Patronun size dair hissini | Şirketin sizi hangi kutuya koyduğunu | | Kolay mı | Bir cümlelik karar | Çok imzalı bir süreç | Patron şirketinde unvan bedavadır. Para veremediğiniz insana unvan verirsiniz; bu, kısa vadede işe de yarar. Kurumsalda unvan pahalıdır, çünkü bir banda, bir maaş aralığına ve bir onay zincirine bağlıdır — o yüzden zor verilir, ama verildiğinde bir karşılığı vardır. Bunun pratik sonucu şu: unvanınızın ne anlattığını bilmeden övünmeyin, ne anlatmadığını bilmeden de üzülmeyin. Zaten kıdemin unvanla gelmediğini [daha önce yazmıştım](/tr/blog/kidem-unvanla-gelmeyen-sorumluluk/); şirket tipi bu farkı yalnızca büyütüyor. ## Dürüst kısım: kimsenin size söylemediği Şimdi bu yazının en rahatsız edici cümlesini yazacağım, ve bunu bir tavsiye olarak değil bir gözlem olarak yazıyorum: **Her iki şirket tipinde de en yüksek getirili hamle, çoğu zaman iç zam değildir. Şirket değiştirmektir.** Sebebi kötü niyet değil, yapı. Kurumsalda içeriden zam bir banda ve bir bütçe dönemine bağlıdır; dışarıdan gelen teklif ise piyasaya bağlıdır. Patron şirketinde içeriden zam nakde ve hatıraya bağlıdır; dışarıdan gelen teklif yine piyasaya bağlıdır. İki tarafta da içerideki fiyatınız, sizi işe aldıkları günkü fiyatınıza demirlenmiştir. Ama bu gözlemin bedelini yazmadan bırakırsam yalan söylemiş olurum. Şirket değiştirdiğinizde kaybettikleriniz de var ve bunların hiçbiri maaş bordrosunda görünmüyor: - **Bağlam.** Hangi kodun neden öyle yazıldığını, hangi kararın hangi yangından doğduğunu biliyorsunuz. Bu bilgi taşınmaz; yeni yerde sıfırdan birikir. - **Güven.** "Bu adam söylediyse yapar" cümlesi yıllarda kurulur, ilk gün kurulmaz. - **Tanınmışlık.** Bir toplantıda sözünüzün ağırlığı, o odadaki geçmişinizden gelir. Yeni odada o geçmiş yok. Bunlar para değil, ama bedeldir. Ve bu bedeli ödemeye değip değmeyeceği ayrı bir sorudur — o soruyu [kalmak mı, gitmek mi](/tr/blog/is-degistirme-karari-kalmak-mi-gitmek-mi/) yazısında ayrıca konuşmuştum. Burada söylediğim daha dar bir şey: **zam pazarlığını, gitme ihtimaliniz olmadan yaptığınızı sanmayın.** Karşı taraf o ihtimali zaten hesaba katıyor. **Soru:** Kurumsalda mı patron şirketinde mi zam almak daha kolay? **Cevap:** Hiçbiri. Kurumsalda zam bir **kanıtlama** işidir, patron şirketinde bir **hatırlatma** işidir. Sistemin varlığı zammı kolaylaştırmaz; sadece hangi dili konuşacağınızı belirler. ## Üç masadan bakış **Kıdemli çalışanın masasından.** Katkınızı kayıt altına almalısınız. Hafızaya değil kayda güvenin — çünkü sizin hafızanız da yöneticinizinki de yanılıyor, ve ikisi farklı yönlerde yanılıyor. Kısıtınız: kayıt tutmak, kendini pazarlamak gibi hissettirir ve gördüğüm iyi mühendislerin çoğu bundan tiksinir. Anlıyorum; ben de tiksindim. Ama şunu da gördüm: bu tiksintinin bedelini yine siz ödüyorsunuz. Kimse sizin yerinize tutmuyor o kaydı. **Yöneticinin masasından.** Sabit bir bütçede **sıfır toplamlı** bir oyun oynuyorsunuz: birine verdiğiniz, bir başkasından aldığınızdır. Ekibinizi savunmak için veriye muhtaçsınız ve elinizde çoğu zaman veri yok — çünkü herkesin ne yaptığını gerçekten göremiyorsunuz. Kısıtınız: kalibrasyon odasında "hak ediyor" demek yetmiyor, kanıtlamanız isteniyor. Ve o kanıtı size çalışanın kendisi vermeli. Vermezse, iyi niyetiniz onu kurtarmıyor. **Patronun masasından.** "Hak ediyor" ile "ödeyebilirim" **iki ayrı cümledir** ve ikisini karıştırmamak lazım. Bu masaya oturduğumda öğrendiğim en pahalı ders şu oldu: güveni bozan şey zammı verememek değil, **bunu söylememektir.** "Hak ediyorsun ama şu an veremem; şu tarihte, şu koşul gerçekleşirse veririm" cümlesi kurulmadığı için insanlar takdir edilmediklerini sanıyor ve gidiyor. Oysa çoğu takdir edilmemekten değil, belirsizlikten gidiyor. Kısıtınız: nakit gerçek, ve zam kalıcı bir gider taahhüdüdür — bir kez verdiğinizi geri alamazsınız. Bu yüzden tereddüt ediyorsunuz, ve tereddüdünüz sessizlik gibi görünüyor. ## Kapanış Bu yazıyı kızgın yazmadım, çünkü kızgınlığın burada bir getirisi yok. Sistemi olan şirkette sisteme küsmek, sistemi olmayan şirkette adalet beklemek — ikisi de aynı hatanın iki yüzü. Her iki masada da oturdum ve şunu gördüm: bulunduğunuz yerin dilini öğrenmeyi reddetmek, kariyerin en sessiz ve en pahalı israfı. Zam bir ödül değil. Bir pazarlık. Ve pazarlık, siz farkında olmasanız da başlamış oluyor. ## Sıfırdan hızlı, mevcut sistemde yavaş: agent çağında brownfield'in gerçek maliyeti - Kaynak: https://muhammetsafak.com/tr/blog/sifirdan-hizli-mevcut-sistemde-yavas/ Geçen hafta iki iş yaptım. Birincisi sıfırdan bir işti: bir agent'a ne istediğimi tarif ettim, çıkardığı planı okuyup düzelttim, adım adım yazdırdım. Birkaç gün sonra elimde çalışan, testleri olan, deploy edilmiş bir uygulama vardı. Aynı işi birkaç yıl önce yapsaydım haftalarımı alırdı; bundan şüphem yok. İkincisi, yıllardır production'da olan bir sistemde tek bir modülü — bir CRM modülünü — eski uygulamadan yeni panele taşımaktı. Kod olarak küçük bir işti: birkaç ekran, birkaç tablo, bir avuç rapor. Bir hafta sürdü. Bu iki süreyi yan yana koyduğumda çıkan gözlem, yazının konusu: **hızın nereden geldiğini yanlış yerde arıyoruz.** Agent'ın hızlandırdığı şey kod üretmek. Yıllardır çalışan bir sistemde kod üretmek işin küçük parçası; büyük parçası, üretilenin doğru olduğunu bilmek. O parça hızlanmadı. ## Greenfield ve brownfield darboğazları neden farklı? İki terimi net tanımlayarak başlayayım, çünkü yazının geri kalanı bu ayrımın üstünde duruyor. **Greenfield**, hiçbir mevcut davranışa uymak zorunda olmayan iştir: doğrunun ne olduğuna siz karar verirsiniz. Veri modeli boş bir sayfadır, kullanıcı henüz yoktur, verdiğiniz her karar birkaç saat içinde geri alınabilir. **Brownfield**, çalışan bir sistemin davranışını bozmadan değişiklik yapma işidir. Burada doğru zaten vardır; sizin tanımladığınız değil, sistemin yıllar içinde edindiği bir doğrudur. Üstelik bu doğrunun büyük kısmı hiçbir dökümanda yazmaz. Fark, zorluk seviyesi farkı değil; **bilginin nerede durduğu** farkı: | | Greenfield | Brownfield | | --- | --- | --- | | "Doğru" nedir? | Siz tanımlarsınız | Sistem çoktan tanımlamış | | Bilgi nerede? | Sizde ve talebi verende | Kodda, veride, insanların kafasında | | Hatanın bedeli | Geri alınabilir | Canlı veriye ve kullanıcıya değer | | Baskın maliyet | Üretim | Doğrulama | Agent bilginin sizde olduğu işte çarpan, bilginin sistemde olduğu işte asistan. Aynı araç, iki farklı verim. [Aynı aracın farklı ellerde farklı sonuç ürettiğini](/tr/blog/vibe-coding-mi-ai-destekli-gelistirme-mi/) daha önce yazmıştım; bu yazı aynı gözlemin araç ya da kişi yerine **işin türü** tarafı. ## Brownfield'de asıl taşınan şey kod değil, kuraldır Yıllardır çalışan bir sistemde bir parçayı yerinden oynattığınızda taşıdığınız şey satırlar değil, o satırların içine sinmiş iş kuralları oluyor. Bu kurallar üç yerde saklanır ve üçü de farklı bir kazı yöntemi ister. ### Kodun içindeki yazılmamış istisnalar Katmansız yazılmış eski kodda kural, akışın ortasında bir `if` olarak durur. Kabaca şuna benzer: ```php public function updateStatus(int $id, int $status): void { $customer = $this->find($id); // sözleşmesi bitmiş kayıtta durum değiştirilemez, bayi hesapları hariç if ($customer->contract_end < date('Y-m-d') && $customer->type != 3) { throw new DomainException('İşlem yapılamaz'); } // 7 = arşiv; arşive düşen kayıt temsilcisiz bırakılır if ($status === 7) { $this->clearOwner($id); } $this->save($id, $status); $this->log($id, $status); } ``` Bu on yedi satırda en az üç iş kuralı var: sözleşmesi biten kayıtta işlem yapılamaz, bir hesap tipi bu kuralın dışındadır, arşive düşen kaydın sahibi silinir. Üçü de gerçek. Üçü de yalnızca burada yazıyor. İşi uzatan şey bu kuralların **sayısı** değil; her birinin önünde durup "bu hâlâ isteniyor mu, yoksa yıllar önce yaşanmış bir olayın izi mi?" diye sormak zorunda olmanız. Benim örneğimde bunlardan biri bir sözleşme maddesiydi, biri eski bir hatanın üstünü kapatmak için konmuştu, biri de artık kimsenin kullanmadığı bir sürecin kalıntısıydı. Kod üçünü de aynı tonda, aynı kararlılıkla yazıyor. ### Verinin içindeki örtük kurallar İkinci saklanma yeri veritabanı. Şemada görünmeyen ama davranışı belirleyen kurallar: bir `status` kolonundaki sayıların ne anlama geldiği, `NULL` bir alanın "bilinmiyor" mu yoksa "uygulanmaz" mı demek olduğu, adı artık taşıdığı veriyi yansıtmayan kolonlar. Şemaya bakarak hiçbirini bilemezsiniz; yıllara yayılmış veriye bakarak çoğunu çıkarabilirsiniz. Pratikte yaptığım şey basit: belirsiz her kolon için üretim verisinde dağılım alıyorum. Bir kolonun gerçekte aldığı değerlerin listesi, o kolonun etki alanını her dökümandan daha dürüst anlatır — ve yeni tarafa koyacağım kısıtları (enum, nullable, foreign key) bu liste belirler. ### İnsanların kafasındaki gerekçe Üçüncüsü hiçbir yerde aranamaz. Kod bir kuralın **ne** olduğunu söyler; **neden** konduğunu çoğu zaman yalnızca insanlar bilir. Bu yüzden soruları tek tek değil toplu olarak biriktiriyorum ve birkaç kısa görüşmede soruyorum: "şu davranış bilerek mi böyle?" Aldığım cevaplardan biri "evet, sözleşmede var" olurken bir diğeri "hayır, o zaten yanlıştı" olabiliyor. İkinci cevap, taşıma sırasında düzeltilecek tek meşru kalemdir; gerisi korunur. ## Agent nerede çarpan, nerede asistan Bu bir "AI işe yaramıyor" yazısı değil; tam tersi. Brownfield işinde de ciddi biçimde işe yaradı — ama belirli üç kalemde. **Hızlandırdıkları:** - **Mekanik dönüşüm.** Bir kalıbı başka bir kalıba çevirmek — iskeletler, yönlendirmeler, doğrulama sınıfları, sorguların yeni karşılığı — neredeyse tamamen mekanik iş. Saatler değil dakikalar sürüyor. - **Kod okuma ve kural çıkarma.** Eski kodu dosya dosya taratıp "burada hangi kurallar var, hangi koşullar hangi alanlara bağlı" özeti çıkarmak. Elle bir gün sürecek okuma birkaç turda bitiyor. Sonuç bir cevap değil, bir **soru listesi** — ama doğru soruları toplu hâlde önünüze koyması tek başına değerli. - **Doğrulama araçları.** Eski ve yeni tarafın aynı girdiye aynı çıktıyı verdiğini karşılaştıran tek kullanımlık script'ler, kritik akışlar için karakterizasyon testleri, rapor toplamlarını iki taraftan çekip diff'leyen küçük araçlar. Kendi diff'imi denetleyen düzenin [neyi hızlandırıp nerede yetmediğini](/tr/blog/ai-ile-self-review-neyi-hizlandiriyor-nerede-yetmiyor/) anlatırken de aynı yere varmıştım: fayda üretimde değil, **denetimde**. **Hızlandırmadıkları:** - **Kuralın geçerliliğine karar vermek.** Agent bir kuralı bulabiliyor; o kuralın hâlâ istenip istenmediğini söyleyemiyor. Bu bilgi kodda değil, insanlarda. Kodda olan tek şey, birinin bir gün öyle yazmış olması. - **Doğrulamak.** Yeni tarafın "çalışıyor" görünmesi beş dakikalık iş; eskisiyle **aynı** sonucu ürettiğini göstermek günlük iş. Bir sapmanın "hata" mı yoksa "eskisi zaten yanlıştı" mı olduğuna karar vermek de işin en yavaş kısmı. - **Geçiş ve koordinasyon.** Hangi akşam geçileceği, o sırada kimin veri girmeyeceği, geri dönüş yolunun ne olacağı, kullanıcıya ne söyleneceği. Teknik zorluk değil; ama takvimde yer kaplıyor. Kalıcı ders şu: agent yaygın olanı çok iyi yazıyor, ama [problemin kendine özgü tarafını](/tr/blog/yapay-zeka-caginda-kod-yazmanin-degisen-el-becerisi/) görmüyor. Yıllardır çalışan bir sistemde iş, tam olarak o kendine özgü taraftan ibaret. ## Bir haftanın dürüst dökümü Baştaki örneğin yaklaşık dağılımı, beş iş günü üzerinden: | İş kalemi | Süre | Agent'ın payı | | --- | --- | --- | | Keşif, kural çıkarma, soruların cevaplanması | ~1,5 gün | Yüksek — taramayı o yaptı, kararı ben verdim | | Asıl kod yazımı | ~1 gün | Çok yüksek | | Parite doğrulama | ~1,5 gün | Orta — araçları üretti, yorumu bende | | Kenar durumlar, raporlar, entegrasyonlar | ~0,5 gün | Düşük | | Geçiş, izleme, düzeltmeler | ~0,5 gün | Yok | Tek cümleye indirirsem: **kod yazmak beş günün bir günü.** Kalan dört gün, kodun ne yapması gerektiğini öğrenmek ve yaptığını kanıtlamakla geçti. Agent'ın en güçlü olduğu kalem, toplamın beşte biri kadar. Sıfırdan yaptığım işte oran neredeyse tersineydi: orada zamanın çoğu üretime gidiyordu, çünkü uyulacak bir geçmiş yoktu. İki işin sürelerinin birbirine yaklaşmasının sebebi bu. ## Brownfield işini nasıl tahmin ediyorum? Bu gözlemin pratik karşılığı, tahmin yöntemimi değiştirmesi oldu. 1. **Satır değil, bilinmeyen kural sayın.** "Kaç satır kod" bir brownfield işinin büyüklüğünü ölçmez. Ölçen soru şu: kaç tane, gerekçesi bilinmeyen davranış var? Sayı belli değilse tahmin de belli değildir. 2. **İlk yarım günü keşfe ayırın, tahmini ondan sonra verin.** Keşif çıktısı bir kural envanteri olur: her satırda davranış, nerede yazılı, kim doğrulayacak. Tahmini bu envanterin uzunluğu belirler. 3. **Doğrulamaya en az üretim kadar bütçe ayırın.** Pratikte oran genelde 1:1,5 civarında çıkıyor. Bu bütçeyi ayırmayan plan, farkı production'da öder. 4. **Geçişi ayrı bir kalem yazın.** Kod bittiğinde iş bitmiyor; geçiş, izleme ve ilk gün düzeltmeleri takvimin görünür bir parçası olmalı. [Hangi işte hangi metodolojiyi seçtiğimi](/tr/blog/hangi-projede-hangi-metodoloji-karar-matrisi/) anlatırken de aynı yere gelmiştim: belirsizliği yüksek işte plan, adım sayısını değil, öğrenme sırasını kurar. ## Ne çıkarıyorum **Greenfield hızını brownfield tahminine taşımayın.** "Agent'la üç günde bitirdik" cümlesi, mevcut bir sistemdeki iş için bir ölçü değil. İki işin darboğazı farklı yerde: birinde üretim, diğerinde doğrulama. **"AI ile iki günde bitmez mi?"** sorusunun dürüst cevabı: kod yazımı zaten iki gün değil, bir gün. Ama işin adı kod yazmak değil; yıllarca birikmiş davranışı bozmadan yerini değiştirmek. Hızlanan kısım işin beşte biri, kalanı hâlâ insan işi. Son olarak tersinden bir not: bugün agent'la üç günde çıkardığımız sistemler, birkaç yıl sonra birinin bir hafta uğraşacağı sistemler olacak. Bu yüzden o hızlı işte de kuralı kodun ortasına gömmemeye, en azından gerekçesini commit mesajına ve teste yazmaya çalışıyorum. Yarının brownfield'ini bugün üretiyoruz. *Kod yazmak hızlandı. Yılların ne anlama geldiğini anlamak hızlanmadı.* ## Bilgi nasıl akar: koridor mu, doküman mı? - Kaynak: https://muhammetsafak.com/tr/blog/bilgi-nasil-akar-koridor-mu-dokuman-mi/ Bir patron şirketinde çalışırken, ekipteki en kıdemli isim istifa etti. Son gününde herkes güzel şeyler söyledi, pasta kesildi, gitti. Aradan üç hafta geçti ve bir ödeme entegrasyonunda tuhaf bir davranış çıktı: sistem, belirli bir durumda isteği bilinçli olarak ikinci kez gönderiyordu. Kod oradaydı, çalışıyordu, yıllardır da öyleydi. Ama neden öyle yazıldığını bilen kimse kalmamıştı. İki gün tartıştık. "Bug bu, kaldıralım" diyen de vardı, "bir bildiği vardır" diyen de. Sonunda kaldırmadık — çünkü kimse riski alamadı. O gün fark ettim: adam giderken kodu bırakmıştı, **gerekçeyi** götürmüştü. Yıllar sonra kurumsalda, tam tersi görünen bir sahne. Bir toplantıda bir konuyu üçüncü kez konuşuyorduk. Toplantının ortasında biri, "bu zaten wiki'de yazıyor" dedi ve linki attı. Gerçekten yazıyordu. 2023'te yazılmış, kırk sayfalık bir sayfanın on dördüncü başlığındaydı. Kimse okumamıştı. Toplantı yine de bir saat sürdü. İki farklı arıza gibi görünüyor. Değil. İkisinin de kökü aynı: **kararın gerekçesi, ihtiyaç duyulduğu anda erişilebilir bir yerde değil.** Birinde gerekçe bir insanın kafasında, diğerinde ölü bir wiki'nin içinde. İkisinde de sonuç aynı: yok. Bu dizinin ilk yazısında şirketleri ayıran dört eksenden bahsetmiştim; ikincisi "ne kadarı yazılı?" idi ([kurumsal mı, patron şirketi mi?](/tr/blog/kurumsal-mi-patron-sirketi-mi/)). Bu yazı tam olarak orayı kurcalıyor — ama teknik tarafını değil, insan tarafını. Bilgi kimde durur, neden paylaşılmaz, paylaşılmayınca ne olur. ## Bilgi kimde durursa güç ondadır — ve bu şirket için risktir Önce dürüst olalım, çünkü kimse bunu yüksek sesle söylemiyor: **bilginin sizde durması size gerçekten bir güvence sağlıyor.** O entegrasyonu bir tek siz biliyorsanız, o cron'un neden gece üçte çalıştığını bir tek siz hatırlıyorsanız, o müşterinin neden özel muamele gördüğünü bir tek siz açıklayabiliyorsanız — işten çıkarılma listesinde en son sıradasınız. Bu bir kaldıraç. Vazgeçilmez olmak, hiç kimsenin size öğretmediği ama neredeyse herkesin sezgisel olarak keşfettiği bir hayatta kalma stratejisi. Ve kısa vadede işe yarıyor. Sorun uzun vadede. Vazgeçilmez olan kişi **terfi edemez.** Çünkü terfi, sizi bulunduğunuz yerden almak demektir — ve sizi oradan alırlarsa yerinize kimse geçemez. Yöneticinizin kafasındaki tablo şu: "Bu adamı çekersem o taraf çöker." Bu cümle bir övgü gibi duruyor ama pratikte bir çiviyle işaretlenmişsiniz demektir. Bilgiyi tutmak kısa vadede güvence, uzun vadede tavan. Sizi kovulmaz yapan şey, aynı anda sizi yükselmez yapıyor. ## Neden yazılmıyor? İki şirket tipinde iki farklı sebep var ve ikisi de kültürel. **Patron şirketinde yazmak "bürokrasi" sayılır.** Bir şeyi yazmaya oturduğunuzda birinin "sen onu yazana kadar iş biterdi" demesi neredeyse kesindir. Orada üretkenlik görünür çıktıdır ve yazı, görünür çıktı üretmez. Ama asıl sebep daha yukarıda: **patronun kendisi yazmaz.** Kararlarını sözlü verir, gerekçesini kafasında tutar, aklına gelen düzeltmeyi telefonla söyler. Kültür yukarıdan iner. Yazmayan bir patronun altında yazan bir ekip, ancak birinin inatçılığıyla oluşur ve o kişi gittiğinde biter. **Kurumsalda ise yazılır — ama yanlış şey yazılır.** Kurumsal şirket yazmayı sever; şablonu vardır, sayfası vardır, sorumlusu vardır. Fakat yazılan şey neredeyse her zaman **süreçtir:** ne yapıldı, kim onayladı, hangi adımlar izlendi. "Ne yaptık" var. "Neden yaptık" yok. Aradaki fark altı ay sonra ortaya çıkıyor. Süreç kaydı size bir kararın **izini** verir, **zeminini** vermez. Altı ay sonra biri "bunu neden böyle yapmışız?" diye sorduğunda elinizde yalnızca bir onay zinciri vardır — o kararı verdiren kısıtın ne olduğu, hangi alternatifin neden elendiği hiçbir yerde yazmaz. Sonuç: kararı kimse sorgulayamaz. Sorgulamak için zemin gerekir, zemin de yoktur. Böylece kötü kararlar, kimse savunmadığı hâlde hayatta kalır. ## "Toplantıda konuşuruz" çoğu zaman "yazmayacağım" demektir Toplantı, teoride bir bilgi aktarma aracı. Pratikte, kolayca bir bilgi **saklama** aracına dönüşüyor. Şu cümleyi çok duydum: "Bunu toplantıda konuşuruz." Kulağa iş birliği gibi geliyor. Çoğu zaman anlamı şudur: *bunu yazmayacağım, sözlü söyleyeceğim, sözlü söylediğim şey de o odada bulunanların bilgisi olacak.* Yazılı olmayan bilgi, o odada bulunanların ayrıcalığıdır. Ve odaya kimin çağrıldığı bir karardır — çoğu zaman bilinçli bile olmayan, sadece "hep böyle yapıyoruz" refleksiyle verilen bir karar. Bir süre sonra şirkette iki sınıf oluşur: toplantıda olanlar ve olmayanlar. Olmayanlar işin *ne* olduğunu bilir, *neden* olduğunu bilmez. Sonra da onlara "inisiyatif almıyorsunuz" denir. Bir toplantının çıktısı üç cümlelik bir not değilse, o toplantı olmamış sayılır. Bunu bir kural olarak söylemiyorum, bir gözlem olarak söylüyorum: aradan altı ay geçtiğinde notu olmayan toplantı ile hiç yapılmamış toplantı arasında ölçülebilir hiçbir fark kalmıyor. ## İçeriye yazmak da bir kariyer yatırımıdır Daha önce [dışarıya yazmanın kariyer getirisini](/tr/blog/gorunur-olmak-yazmak-neden-bir-kariyer-yatirimidir/) anlatmıştım: blog, konuşma, açık kaynak — görünür olmak, yeteneğinizin sizin adınıza konuşmasını sağlar. Aynı mekanizma şirketin **içinde** de çalışıyor ve neredeyse hiç konuşulmuyor. İçeride düzenli yazan kişi, zamanla kararların referans noktası olur. Yeni biri geldiğinde okuduğu şey sizin yazdığınızdır. Bir tartışma çıktığında "bunu daha önce konuşmuştuk, şurada yazıyor" diyen sizsinizdir. Altı ay sonra bir kararı savunmak gerektiğinde, elinde zemin olan tek kişi sizsinizdir. Bu, ünvanla gelen bir otorite değil; **birikimle** gelen bir otorite. Kimse size vermez, geri de alamaz. [Mentorluk yazısında](/tr/blog/mentorluk-ve-bilgi-paylasimi-baskasini-buyuturken-buyumek/) benzer bir şeyden bahsetmiştim: başkasını büyütürken büyümek, bir fedakârlık değil bir çarpan. Yazmak da öyle. Yazdığınız şey siz odada yokken de çalışmaya devam eder — ve kariyerde en değerli şey, siz uyurken çalışan şeydir. ## Ne yazmalı: kararı değil, gerekçeyi Buraya kadar okuyup "peki nasıl?" diye soruyorsanız, cevabım sıkıcı derecede basit. Kararı yazmayın. **Gerekçeyi** yazın. "X'i seçtik" bir kayıt değildir; kodun kendisi zaten bunu söylüyor. Kayıt şudur: > X'i seçtik, çünkü Y kısıtı vardı. Z'yi elemiştik, çünkü W. Üç cümle. Beş dakika. Nereye yazdığınızın hiç önemi yok — pull request açıklaması, task'ın altındaki yorum, ekip kanalına atılmış bir mesaj. Biçim değil, **varlık** önemli. Bunun neden yeterli olduğunu şöyle anlatayım: altı ay sonra gelen kişinin ihtiyacı olan şey sizin kararınız değil, sizin **kısıtınız.** Kısıt hâlâ geçerliyse karar da geçerlidir; kısıt kalktıysa karar tartışmaya açılır. Kısıtı yazmadığınızda, o kararı ya körü körüne korurlar ya da körü körüne bozarlar. İkisi de pahalı. Daha derin bir karar-kaydı disiplini kurmak isterseniz — şablonlar, versiyonlama, gözden geçirme akışı — orası ayrı bir mesele ve bu yazının kapsamı değil. Burada iddiam şu: **o disiplini kurmadan önce bile, üç cümle yazmak hiç yazmamaktan katbekat iyidir.** İnsanlar genellikle mükemmel sistemi kuramadıkları için hiçbir şey yazmıyorlar. Bu, en yaygın ve en gereksiz kayıp. **Soru:** Bir şirkette bilgi akışının sağlıklı olup olmadığını nasıl anlarım? **Cevap:** Rastgele bir kararı seçin ve "bu neden böyle?" diye sorun. Sağlıklı bir yerde cevap bir **belgeye** işaret eder. Sağlıksız bir yerde cevap bir **kişiye** işaret eder — ya "Ahmet bilir" denir, ya da "toplantıda konuşmuştuk" denir. İkisi de aynı şeyi söyler: bilgi kurumun değil, birilerinin. ## Üç masadan bakış **Kıdemli çalışanın masasından.** Bilgiyi paylaşırsanız kendi değerinizi düşüreceğinizi sanıyorsunuz. Tersine çalışıyor: paylaşan kişi **çarpan** olur, saklayan kişi **darboğaz** olur. Ve darboğazlar terfi ettirilmez — yerinde tutulur, çünkü oradan alınamazlar. Kısıtınız: bu ancak paylaşımın **görüldüğü** yerlerde doğrudur. Yazdığınızı kimsenin fark etmediği, bilgiyi devrettikten sonra sizi rahatlıkla harcayabilecek bir şirkette bu gerçekten sömürüdür — ve bunu söylemek gerekiyor. Önce şirketin hangi tip olduğuna bakın, sonra açılın. **Yöneticinin masasından.** Tek bir doğruluk kaynağı istiyorsunuz. Ama ekibi yazmaya zorlayamıyorsunuz; yazmak, teslim tarihinin yanında hep ikinci sırada kalıyor ve siz de içten içe haklı olduklarını biliyorsunuz. Kısıtınız: yazmaya ayrılan saat, **görünür çıktı üretmeyen tek saattir** — ve sizden görünür çıktı isteniyor. Bu saati savunmak istiyorsanız, onu bir iyilik olarak değil bir risk maliyeti olarak anlatmanız gerekiyor: "Bu üç kişi yarın giderse ne kaybederiz?" **Patronun masasından.** Kafanızdakini boşaltmak, şirketin **değerini** artırır. Devredilebilir, satılabilir, tatile çıkılabilir hâle getirir. Kafanızda duran şirket, sizden büyük olamaz. Çoğu patron bunu ancak şirketi satmaya kalktığında fark eder: karşı taraf ilk olarak "kurucu giderse ne kalır?" diye sorar ve cevap "pek bir şey" ise fiyat oradan düşer. Kısıtınız: kafanızdakini boşaltmak, bir miktar **kontrolü** de boşaltmaktır. Yazdığınız her gerekçe, sizinle tartışılabilir hâle gelir. Bu gerçekten korkutucu — ve korkunun kaynağını görmezden gelerek "neden kimse yazmıyor" diye sormanın bir anlamı yok. ## Kapanış Bilginin koridorda mı yoksa dokümanda mı aktığı bir araç tartışması değil, bir güç tartışması. Kafada tutulan bilgi, tutan kişiye kısa vadeli bir güvence verir ve şirkete uzun vadeli bir borç yazar. Ölü bir wiki ise ikisini birden yapar: ne kişiyi korur, ne şirketi. Yarın için tek bir şey öneriyorum. Bugün verdiğiniz bir kararın gerekçesini üç cümleyle bir yere yazın. Şablon aramayın, izin istemeyin, doğru yeri bulmaya çalışmayın. O üç cümle, altı ay sonra birinin — belki sizin — bir saatini kurtaracak. ## Aynı mesaj, farklı sonuç: event-driven mimaride determinizm - Kaynak: https://muhammetsafak.com/tr/blog/event-driven-mimaride-determinizm/ Bu sabah bir fatura "gönderilemedi" ile geri döndü. Log'da tek satır vardı: faturanın öneki izin verilen listede değil. Aynı biçimdeki bir mesaj birkaç saat önce sorunsuz geçmişti. Kod değişmemişti. Mesajın içeriği değişmemişti. Değişen tek şey, aynı öneklerin listesinin iki ayrı serviste ayrı ayrı tutuluyor olması ve birinin güncellenmiş olmasıydı. Bir tarafta geçerli olan bir değer, diğer tarafta sahte payload muamelesi gördü. [Haziran'da outbox ve idempotent tüketimi yazarken](/tr/blog/outbox-idempotent-tuketim-ve-alan-sifrelemesi/) bir not düşmüştüm: bir deseni çözmek bir sonrakini doğuruyor. Bu yazı o sonraki. Outbox, olayın **teslim edileceğini** garanti etti. Teslim edilen olayın **aynı sonucu üreteceğini** garanti etmedi. Aradaki fark küçük görünüyor; pratikte iki ayrı problem sınıfı. ## Determinizm nedir, event-driven sistemler onu nerede bıraktı? Determinizm sıkıcı bir söz: aynı girdi, aynı çıktı. Sıkıcı olduğu için de mühendislikte en çok üstü örtülen varsayım. Oysa üzerine kurduğumuz şeylerin çoğu ona yaslanıyor. **Retry** determinizm ister — "tekrar dene" ancak ikinci denemenin birinciyle aynı şeyi yapacağını biliyorsanız bir çözümdür, aksi hâlde kumardır. **İdempotency** determinizm ister — aynı mesajı iki kez işlemenin zararsız olduğunu söylemek, ikinci işlemenin ilkiyle aynı kararı vereceğini varsaymaktır. **Test** determinizm ister — [kararsız testleri sade.dev'de ayrı yazdım](https://sade.dev/tr/journal/kararsiz-testler-ve-determinizm). **Olay sonrası analiz** determinizm ister: "neden bu sonuç çıktı" sorusunun cevabı yoksa, düzelttiğinizi sandığınız şeyin gerçekten düzelip düzelmediğini de bilemezsiniz. Senkron dünyada bunu bedava alıyorduk. Kullanıcı bir düğmeye basıyordu; okuma, karar ve yazma aynı transaction'ın, aynı process'in, aynı anın içindeydi. Veriyi okuduğunuz an ile karar verdiğiniz an arasında dünyanın değişme ihtimali yoktu, çünkü **an tekti**. Event-driven mimari bu tekliği bilerek kırıyor. Olayın *üretildiği an* ile *tüketildiği an* arasına zaman giriyor. Milisaniye de olabilir, kuyruk birikince yirmi dakika da, hatalı mesaj yeniden sürülünce üç gün de. Ve o aralıkta dünya değişebilir: bir ayar güncellenir, bir firma kaydı düzenlenir, yıl değişir, bir servis yeniden dağıtılır. Determinizm bedava olmaktan çıktı. Artık **bilinçli olarak ödemeniz gereken bir maliyet.** ## Mesaj, girdinin tamamı değil Kuyruğa bakınca insan şunu düşünüyor: girdi mesaj. Değil. > **Bir tüketicinin okuduğu her şey girdidir. Mesajda yazmayan her girdi gizlidir; gizli girdi, sonucu sonradan açıklayamadığınız yerdir.** Yani gerçek denklem şu: **sonuç = f(mesaj, gizli girdiler)** Gizli girdiler tüketicinin kodunda göze batmaz, çünkü hepsi masum görünür. Birkaç tanesi, akışımızda gerçekten karşıma çıkmış hâlleriyle: | Gizli girdi | Somut hâli | Ürettiği sapma | |---|---|---| | **Zaman** | Tüketicinin belge tarihi için o anki saati okuması | Yıl dönümünde bir retry: belge numarası bir yıla, belge tarihi diğerine ait olur; karşı taraf reddeder | | **Veritabanının o anki hâli** | Belge oluşturulurken firmanın öneki X'ti, gönderilirken Y | Numara bambaşka bir seriden üretilir | | **Konfigürasyon** | Aynı izin listesi iki ayrı serviste yaşıyor | Bir taraf kabul eder, diğeri reddeder | | **Dış sistemin cevabı** | "Bu zaten var" — ama *neyin* var olduğu belirsiz | Aynı istisnadan zıt iki eylem türetilebilir | | **Çalıştıran ortam** | Aynı kütüphane; bir framework uyarıyı yutuyor, diğeri exception'a çeviriyor | Aynı kod, aynı girdi, farklı sonuç | | **Hangi worker kazandı** | Paralel replica, kilit, tekrar teslim edilen mesaj | Sonuç, işlem sırasına bağlı hâle gelir | Şu son iki satırı ayrıca not etmek istiyorum, çünkü fark ettiğimde beni en çok rahatsız edenler onlardı. Aynı entegrasyon kütüphanesini hem eski senkron uygulamamız hem yeni kuyruk servisimiz kullanıyor. Kütüphane belirli bir durumda bir PHP uyarısı üretiyor. Eski uygulama bu uyarıyı yutup devam ediyordu; yeni servis onu exception'a çevirip akışı durduruyordu. Kütüphane aynı, çağrı aynı, girdi aynı. Sonucu belirleyen şey, kodu **kimin host ettiğiydi**. Böyle bir farkı hiçbir birim testi yakalamaz, çünkü fark test edilen kodun içinde değil. İkincisi daha sinsiydi: kritik olmayan bir log kaydının yazılamaması, tüm gönderim akışını durduruyordu. Yani karşı tarafa belge başarıyla iletilmiş olsa bile, bizim tarafımızdaki hiçbir kayıt yazılmıyordu. Bir **yan etkinin başarısızlığı, ana sonucu değiştiriyordu**. Log kaybı kabul edilebilir; gönderim kaydının kaybı kabul edilemez — ama bunu ancak ikisinin ayrı ele alınması gerektiğini fark ettiğinizde yazabiliyorsunuz. Buradaki kritik nokta şu: **bu maddelerin hiçbiri hata değil.** Her biri tek tek makul kararlar. [Rol sınırlarını yazarken](/tr/blog/rol-sinirlari-is-tanimi-olmayan-yerde-herkes-her-seydir/) de aynı şeyi söylemiştim — her adım tek başına savunulabilirdi, sorunlu olan toplamdı. Mimaride de böyle: determinizmi tek bir kötü karar bozmuyor, birbirinden habersiz on tane makul karar bozuyor. ## Çatal çoğalması: bir koşulun gerçek maliyeti Bir `if` yazdığınızda ne ödediğinizi biliyoruz sanıyoruz: biraz okunabilirlik, biraz karmaşıklık. Event-driven bir tüketicide ödediğiniz şey bu değil. Ödediğiniz şey, **kanıtlanması gereken yeni bir sonuç.** Ve bu maliyet doğrusal değil. Klasik cevap "N koşul, 2ᴺ yol" demek olurdu ama asıl sorun bu değil. Asıl sorun koşulların **bağımsız olmaması**: aynı gizli girdileri paylaşıyorlar. Zamanı bir yerde okuyup başka bir yerde tekrar okuduğunuzda, iki farklı koşul aynı anda ve birbirine bağlı biçimde kayıyor. En net örneğim şu. Entegratörden tek bir hata dönüyor: "bu belge zaten var." Bu cümle tek başına hiçbir şey ifade etmiyor. İki tamamen zıt dünyaya karşılık geliyor: | Ayırt eden şey | Gerçekte olan | Doğru eylem | |---|---|---| | Karşı tarafın bildirdiği belge kimliği **bizimkiyle aynı** | Belge daha önce gitmiş | Durumu kurtar, **tekrar gönderme** | | Kimlik **farklı** | O numara başka bir belgeye ait; bizimki hiç ulaşmamış | Yeni numara üret, **tekrar gönder** | İki kolun eylemi birbirinin tersi. Ve ikisini ayırt eden bilgi **mesajda yok** — karşı tarafın cevabının içindeki bir alanda. Yani bu çatalın girdisi, bizim kontrolümüzde olmayan bir dış sistemin ne kadar konuşkan olduğu. Yanlış kolu seçmenin bedeli de simetrik değil. Birini karıştırırsanız mükerrer belge kesersiniz; diğerini karıştırırsanız hiç gitmemiş bir belgeyi "kayıp" sanıp yanlış alarm üretirsiniz. Birincisi mali bir olay, ikincisi gürültü. Aynı `if`, iki farklı ağırlıkta yanlış. Bir de üçüncü hâl var, ve bu yazının benim için en öğretici kısmı: karşı taraf gönderim çağrısında "bu belge zaten var" diyor, ama aynı kimlikle sorguladığınızda "belge bulunamadı" diyor. Kimlik onların tarafında rezerve edilmiş ama nihai kuruma iletilmemiş. Belge ne gönderilmiş ne gönderilmemiş. Bu **karar verilemeyen durum.** Determinizmin sınırı tam burası. Buraya bir varsayım koyup otomatik ilerlemek — "herhalde gitmiştir" ya da "herhalde gitmemiştir" — determinizm üretmez; sadece belirsizliği kodun içine gömüp görünmez kılar. Bazı çatalların doğru cevabı bir eylem değil: **karar verme, işaretle, insana devret.** ## İki yol, tek sonuç olmak zorunda Bir işin iki ayrı koddan yapılabildiği her yerde determinizm artık tek servisin içinde aranmaz. Determinizm **iki yolun anlam eşitliğinde** yaşar. Bizde durum tam olarak buydu: aynı belgeyi hem eski senkron yol hem yeni kuyruk yolu gönderebiliyor. Karşı taraftan gelen sert bir hata kodunu eski yol geri alıp "başarısız" sayıyordu. Yeni yol, aynı kodu alıp kayıt yazsaydı belge "gönderildi" olarak işaretlenecekti. Aynı hata, aynı belge, iki farklı gerçeklik. Kullanıcı ekranda yeşil bir tik görür, karşı tarafta belge yoktur — ve bunu ancak aylar sonra bir mutabakatta fark edersiniz. Aynı problem paylaşılan sayaçta da çıkıyor. İki yazıcı aynı seriye numara üretiyorsa, "sıradaki numara" artık tek bir kaynaktan okunamaz. Sayaç, kendi kendini onarmak zorunda kalan bir hesaba dönüşür: sayacın değeri, kesinleşmiş kayıtların en büyüğü ve rezerve edilmiş numaraların en büyüğü — üçünden hangisi büyükse ondan devam. Bu çirkin. Ama çirkinliği kodun değil, iki yazıcıya izin vermiş olmanın. Buradan çıkardığım ilke şu: **davranış paritesi, kod paylaşımından daha önemlidir — ama paritenin bir sahibi olmalı.** Ortak bir kütüphaneye çıkmadan da parite sağlanabilir; sağlanamayan şey, sahibi olmayan paritenin zamanla korunması. Rol sınırlarını yazarken kullandığım tanım burada da geçerli: rol boşluğu, yapılması gereken ama hiçbir rolün tanımında yer almayan iştir; biri sessizce kapattığı anda görünmez olur. İki yolun aynı davranması da öyle bir iş. ## Determinizm nasıl geri kazanılır? Koşulları silmek çözüm değil; iş zaten koşullardan ibaret. Yapılabilecek şey, koşulların **cevabını sabitlemek.** **1. Bağlamı üretim anında dondur.** Tüketici, kararı için gereken hiçbir şeyi kendi çözmesin. Olay kendi bağlamını taşısın: hangi önek kullanılacak, hangi tarih geçerli, hangi oran, hangi kur, hangi şablon. Biz bunu önekte yaptık — önek belge oluşturulurken sabitleniyor, gönderim anında firma kaydından yeniden okunmuyor. Aksi hâlde oluşturma ile gönderim arasında firmanın öneki değiştirilirse belge başka bir seriden numaralanır. Bedeli var: mesaj şişer, şema sürümlenmek zorunda kalır. Kazancı da net: aynı mesaj her zaman aynı sonucu üretir. **2. Zamanı bir girdi hâline getir.** Tüketicide o anki saati okumak, akışın ortasına isimsiz bir değişken sokmaktır. Karar anını mesajdan alın; alamıyorsanız ilk denemede sabitleyin ve **kalıcılaştırın**. Kritik cümle şu: **retry, zamanı değiştirmemeli.** Değiştirirse retry mekanizmasının kendisi yeni bir çatal olur — ki yıl dönümünde tam olarak bu oluyor. Belge numarasını koruyup tarihi kaydıran bir yeniden deneme, ikisinin yılını ayrıştırır ve reddedilir. Yılda bir gün ortaya çıkan hataların en pahalı yanı, bulunana kadar geçen süredir. **3. Çatalı isimlendir, gerekçeyi yaz.** Log "başarısız" demesin. *Hangi koşul, hangi girdiyle, hangi kola götürdü* onu yazsın. Bir kararın determinist olup olmadığını sonradan doğrulamanın tek yolu, kararın gerekçesinin kaydedilmiş olması. Önek reddi hatasına "iki taraftaki liste hâlâ aynı mı?" ipucunu koyduğumuzda, aynı sorunun ikinci kez teşhisi dakikalar sürdü. Bir sonraki okuyan siz olmayacaksınız; hata mesajına yön koymak lüks değil, spesifikasyonun parçası. **4. Belirsizliği sessizce çözme.** Anlamı belirsiz bir dış cevabı varsayımla kapatmak, düzeltilebilir bir hatayı düzeltilemez bir hataya çevirir. Tanımadığımız bir önek geldiğinde firmanın varsayılanına düşmeyi tercih edebilirdik — akış devam ederdi, kimse bir şey fark etmezdi, ve belge yanlış seriden kesilirdi. **Gürültülü başarısızlık, sessiz yanlış sonuca yeğdir.** Başarısız bir gönderim tekrar denenebilir; yanlış seriden kesilmiş bir belge düzeltme evrakı ister. Bir de beşinci, çoğu zaman en iyisi: **çatalı çözmek yerine ortadan kaldırmak.** Tüketicideki bazı koşullar aslında koşul değil, iki farklı olay tipidir. Ayrı mesaj tipine bölerseniz `if` tamamen kaybolur — ve olmayan bir çatalın yanlış kolu da olmaz. ## Peki bu determinizm mi, taklidi mi? Dürüst olmak lazım: dağıtık bir sistemde tam determinizm yok. Ağ sizin değil. Teslim sırası sizin değil. Mesajın kaç kez geleceği sizin değil: [RabbitMQ dokümantasyonu](https://www.rabbitmq.com/docs/confirms) "any delivery (message) that was not acked is automatically requeued when the channel (or connection) on which the delivery happened is closed" diyor. Bunları payload'a dondurarak çözemezsiniz. O yüzden hedefi doğru koymak gerekiyor. Peşinde olduğumuz şey **sonuç determinizmi değil, karar determinizmi**: aynı girdi setiyle her zaman aynı kararı vermek. Mesajın ne zaman geleceği bizim elimizde değil; geldiğinde ne yapacağımız bizim elimizde. "Öyleyse her şeyi payload'a koyalım" da bir tuzak, ve hızla ters teper. Mesaj şişer, şema kırılganlaşır; daha kötüsü, kuyrukta bekleyen eski mesajlar yeni kodla tüketildiğinde bambaşka bir determinizm problemi doğar. Çözdüğünüzü sandığınız şey, replay olarak geri gelir. Bende işe yarayan sınır kuralı şu: **karara giren şey dondurulur, karara girmeyen şey referansla taşınır.** Belgenin öneki karara giriyor — donar. Firmanın logosu girmiyor — referans yeter. Bu ayrım her zaman net değil, ama en azından doğru soruyu sordurur. ## Kapanış O sabahki fatura, sonunda tek satırlık bir konfigürasyon düzeltmesiyle geçti. Ama gerçek düzeltme o değildi. Gerçek düzeltme, o koşulun cevabını mesajın dışından almasına en baştan izin vermiş olduğumuzu kabul etmekti. Kodunuz determinist olabilir; sisteminiz olmaz. Determinizm bir kod özelliği değil, bir **bağlam disiplinidir**: kararın girdisi olan her şeyi, kararın alındığı ana değil, olayın doğduğu ana bağlamak. Bir koşul yazmadan önce sorulacak tek soru şu: **bu koşulun cevabını değiştiren şey mesajın içinde mi, yoksa dünyanın o anki hâlinde mi?** --- ### Kaynaklar - [RabbitMQ: Consumer Acknowledgements and Publisher Confirms, erişim 2026-10-07](https://www.rabbitmq.com/docs/confirms) ## Go ile yazdığım bir komut satırı aracını paketlemek ve dağıtmak - Kaynak: https://muhammetsafak.com/tr/blog/go-ile-komut-satiri-aracini-paketlemek-ve-dagitmak/ Yıllar önce Go'yu öğrenmek için küçük bir komut satırı aracı yazmıştım. O zaman ilgilendiğim şey aracın çalışmasıydı. Bu yıl aynı tür araçlardan birini ekip arkadaşlarımın da kullanmasını isteyince, daha önce hafife aldığım bir kısma odaklandım: bir aracı yazmak işin yarısı; onu insanların **zahmetsizce kurabileceği** hâle getirmek diğer yarısı. ## Tek dosya: Go'nun sessiz avantajı Go'nun bu iş için en büyük kozu, `go build`'in tek bir çalıştırılabilir dosya üretmesi. Çalışma zamanı yok, bağımlılık dizini yok. Bu dosyayı kopyalayan herkes aracı çalıştırabilir. PHP ya da Node dünyasından gelince bunun ne kadar büyük bir kolaylık olduğu hemen anlaşılmıyor. Bir aracı paylaşmak demek, karşı tarafta doğru sürümün kurulu olmasını ummak demekti. Go'da bu soru hiç sorulmuyor. ## Çapraz derleme Tek bir komutla farklı işletim sistemleri için ayrı ikili dosyalar üretebiliyorsunuz: ```bash GOOS=linux GOARCH=amd64 go build -o dist/arac-linux-amd64 . GOOS=darwin GOARCH=arm64 go build -o dist/arac-darwin-arm64 . GOOS=windows GOARCH=amd64 go build -o dist/arac-windows-amd64.exe . ``` Geliştirme makinem ne olursa olsun, kullanıcının makinesi için doğru ikiliyi üretebiliyorum. Bu adımı bir betiğe alıp her sürümde otomatik çalıştırıyorum. ## Sürüm bilgisini ikiliye gömmek Bir kullanıcı "hangi sürümü kullanıyorum?" diye sorabilmeli. Sürüm numarasını derleme anında, `ldflags` ile koda gömüyorum: ```bash go build -ldflags "-X main.surum=1.4.0" -o arac . ``` ```go package main var surum = "dev" func main() { // --version bayrağı bu değeri yazdırır } ``` Varsayılan `dev`; resmî bir sürüm derlendiğinde gerçek numara geçiyor. Bir [hata raporu](/tr/blog/goda-hata-yonetimi-error-degeriyle-yasamak/) geldiğinde, kullanıcının elindeki sürümü tahmin etmek zorunda kalmıyorum. ## Dağıtım ve kurulum İkili dosyaları her sürümde bir depo sürümüne (release) ekliyorum. Kurulum böylece üç adıma iniyor: doğru dosyayı indir, çalıştırılabilir yap, `PATH` üzerindeki bir dizine taşı. Bunu da kısa bir kurulum betiğiyle tek komuta indirgemek mümkün. ## `go install`: geliştiriciler için kısa yol İkili dosya dağıtımı, Go kurulu olmayan kullanıcılar için doğru yol. Ama aracı kullanacak kişiler de geliştiriciyse, daha kısa bir yol var: `go install`. ```bash go install github.com/kullanici/arac@latest ``` Bu komut, kaynaktan derleyip ikiliyi `GOBIN` dizinine (`$GOPATH/bin`, `GOPATH` tanımlı değilse `$HOME/go/bin`) koyuyor; bu dizinin kullanıcının `PATH`'inde olması gerekiyor. Tek satır, sürüm seçilebilir, ayrı bir indirme adımı yok. Ekip içi araçları çoğu zaman böyle dağıtıyorum; çünkü ekipte zaten herkeste Go kurulu. Yani aslında iki ayrı kitleye iki ayrı dağıtım yolu sunuyorum: Go bilmeyenlere hazır ikili dosya, geliştiricilere `go install`. Bir aracı yaygınlaştırmanın sırrı, tek bir kurulum yöntemini herkese dayatmak değil; her kitleye onun için en az sürtünmeli yolu sunmaktır. Bu ayrımı yapmak birkaç dakikalık bir iş; ama aracın gerçekten kullanılıp kullanılmayacağını belirleyen şey çoğu zaman tam da bu. Bir not daha: hangi yolu seçerseniz seçin, aracın dış bağımlılıklarını az tutun. [Go'nun standart kütüphanesi şaşırtıcı derecede geniş](/tr/blog/gonun-standart-kutuphanesiyle-ne-kadar-uzaga-gidilir/); küçük bir araç için çoğu zaman tek bir dış pakete bile ihtiyaç olmuyor. Bağımlılık ne kadar azsa, derleme o kadar hızlı, ikili o kadar küçük ve aracın yıllar sonra hâlâ sorunsuz derlenme olasılığı o kadar yüksek. ## Çıkardığım ders Bir aracı kullanılır kılan, içindeki zekice kod değil; onu kurmanın ne kadar kolay olduğudur. Yıllar içinde teknik olarak iyi ama kurulumu zahmetli olduğu için kimsenin dokunmadığı çok araç gördüm. Go'yu bu tür işler için tercih etmemin sebebi sözdizimi değil; yazdığım şeyi başkasının eline "çalışır" hâlde ulaştırmayı bu kadar kolaylaştırması. Bir araç ancak kurulduğu kadar vardır. ## Rol sınırları: iş tanımı olmayan yerde herkes her şeydir - Kaynak: https://muhammetsafak.com/tr/blog/rol-sinirlari-is-tanimi-olmayan-yerde-herkes-her-seydir/ Bir şirkete backend geliştirici olarak girmiştim. İlk ay kod yazdım. İkinci ay "sen bir bakıver" cümlesiyle sunucunun sorumluluğu bana geçti — zaten deploy'u ben yapıyordum, mantıklıydı. Dördüncü ay ofisin internet kesintisini ben çözüyordum; ne de olsa "bilgisayardan anlıyordum". Yedinci ay muhasebenin kullandığı programın Excel çıktısındaki bir formülü düzeltiyordum, çünkü o gün orada duran tek teknik insan bendim. Bu adımların hiçbiri tek başına saçma değildi. Her biri için makul bir gerekçe vardı, her birini ben de makul buldum. Saçma olan toplamıydı: bir yıl sonra unvanım hâlâ backend geliştiriciydi ama zamanımın yarısı iş tanımımın dışındaki işlerdeydi. Ve kimse bana kötü niyetle davranmamıştı. Dizinin [ilk yazısında](/tr/blog/kurumsal-mi-patron-sirketi-mi/) şirket tiplerini ayıran şeyin büyüklük değil, kararın nerede alındığı ve ne kadarının yazıya döküldüğü olduğunu söylemiştim. Rol sınırları tam da o "yazıya dökülme" ekseninin en çok canınızı yakan yeri. Bu yazı, o cümlenin — "sen bir bakıver" — nereye kadar gittiğini ve nerede durdurulacağını anlatıyor. ## Patron şirketi: rol bulanık Patron şirketinde iş tanımı çoğu zaman bir ilan metnidir; işe girdiğiniz gün geçerliliğini yitirir. Bunun iyi bir tarafı var: kimse size "bu senin işin değil" demez, dolayısıyla dokunabileceğiniz yüzey çok geniştir. Kariyerimde en hızlı öğrendiğim dönemler böyle yerlerde geçti. Kötü tarafı, esneklik ile sömürü arasındaki çizginin görünmez olması. O çizgi ancak bir sınır varsa çizilebilir — sınır yoksa çizgi de yok. Ve sınır yokken şu mekanizma çalışır: > **Yetkin kişi her boşluğa çekilir ve ödülü daha fazla boşluktur.** İyi iş çıkarmanın cezası daha fazla iştir. Bunun sinsi tarafı, kimsenin kötü niyetli olmaması. Patron sizi sömürmeye çalışmıyor; önündeki işi çözebilecek en hızlı kişiye veriyor. Yönetici sizi yakmaya çalışmıyor; ona verilen hedefi tutturmaya çalışıyor. Siz de hayır demiyorsunuz, çünkü yapabiliyorsunuz ve yapmak iyi hissettiriyor. Herkes rasyonel davranıyor, sonuç irrasyonel çıkıyor. Sistem böyle çalışır; bir suçlu aramak zaman kaybıdır. ## Kurumsal: rol keskin Kurumsalda tersi geçerlidir. İş tanımı vardır, yazılıdır, performans değerlendirmesi ona bakar. Bu sizi keyfî yükten korur ve bu koruma gerçektir; küçümsemeyin. "Benim işim değil" cümlesi orada meşru bir haktır. Aynı cümle, aynı yerde, bir hastalıktır da. İkisini ayırt etmek gerekir: | "Benim işim değil" | Meşru hak | Hastalık | | --- | --- | --- | | **Ne zaman** | İşin bir sahibi var, iş ona ait | İşin sahibi yok, kimse çıkmıyor | | **Sonuç** | Yük doğru yere gider | İş iki ekibin arasında ölür | | **Söyleyenin niyeti** | Sınırı korumak | Sorumluluktan kaçmak | İş tanımı sizi keyfî yükten korur; ama tam olarak aynı iş tanımı, iki ekibin arasında duran işi kimsesiz bırakır. Kurumsalın en pahalı işleri o boşluklarda ölür — kimsenin sahiplenmediği entegrasyonlar, iki takımın da "diğerinin" sandığı veri akışları, hiçbir OKR'a yazılmadığı için kimsenin dokunmadığı borçlar. Yani her iki tipte de sorun aynı yerden çıkıyor: **rol boşluğu.** Patron şirketinde boşluk sizi yutuyor, kurumsalda boşluk işi yutuyor. ## Rol boşluğu bir organizasyon arızasıdır Şu tanımı bırakayım, dizinin geri kalanında da işime yarayacak: > **Rol boşluğu, yapılması gereken ama hiçbir rolün tanımında olmayan iştir. Birey tarafından kapatıldığı anda görünmez olur; görünmeyen arıza ise asla düzelmez.** Kıdemli çalışanın en sık düştüğü tuzak bu. Boşluğu görürsünüz, canınız sıkılır, gider kapatırsınız. Şirketi kurtardınız — ve aynı hareketle arızayı gizlediniz. Yönetici artık o işin var olduğunu bilmiyor, çünkü iş çıkıyor. Bir sonraki bütçe planlamasında o iş için kadro açılmıyor, çünkü ihtiyaç görünmüyor. Siz ayrıldığınızda kimse o işin varlığından haberdar bile olmuyor. [Kıdem yazısında](/tr/blog/kidem-unvanla-gelmeyen-sorumluluk/) kıdemin unvanla değil sorumlulukla geldiğini söylemiştim. Buradaki incelik şu: sorumluluk almak, sessizce kapatmak demek değil. Doğru hareket ikili: 1. **Boşluğu kapatın.** İş yapılmalı; şirketi rehin almanın kimseye faydası yok. 2. **Kapattığınızı görünür kılın.** "Bunu ben yaptım, ama bu kimsenin işi değil. Bir sahibi olması lazım." İkinci cümle olmadan birincisi bir fedakârlık değil, bir örtbastır. Ve bunun bedelini genellikle siz ödersiniz. Bu arada bu, rol genişlemesinin **her** biçimini reddetmek anlamına gelmiyor. Kasıtlı genişleme — [T-tipi bir kariyerde](/tr/blog/uzman-mi-genelci-mi-t-tipi-bir-kariyer/) genişliği bilerek büyütmek — kıymetlidir. Farkı şudur: T'nin yatay çizgisini **siz** seçersiniz, boşluğu ise şirket size verir. Biri yatırım, öteki tahsilat. ## Üç satırlık netlik Peki ne yapmalı? RACI matrisi kurup bürokrasi üretmeyin; küçük bir ekipte o matris ilk krizde çöpe gider. Bunun yerine her kritik iş için üç satır yazın: - **Karar veren:** kim "evet/hayır" der? - **Yapan:** kimin takvimindeki saatler bu işe gidiyor? - **Bilgilendirilen:** kim haberdar olmazsa iş patlar? Üç satır. Bir Slack mesajı, bir wiki sayfası, bir README, hatta bir e-posta yeter. Biçim önemli değil; **yazılı** olması önemli. Çünkü sözlü sınır sınır değildir. Unutulur, inkâr edilir, "ben öyle dememiştim"e döner. Bunu bir kez kötü niyetle yaşamanız gerekmez — iyi niyetli insanlar da altı ay önceki bir koridor konuşmasını farklı hatırlar. Yazı, hafızanın yerine geçmek için değil, hafızanın tarafsız olmadığını bildiğiniz için vardır. Bu üç satırın da bir maliyeti ve bir sınırı var, onu da söyleyeyim. Maliyeti: yazdığınız anda tartışma başlar. Sözlü belirsizlik herkesi rahat ettirir; yazılı netlik birinin rahatını bozar, çünkü artık bir işin sahipsiz olduğu siyah beyaz ortadadır. Sınırı ise şu: üç satır, olmayan bir kadroyu var etmez. En fazla, o kadronun yokluğunu görünür kılar. Bazen bunu yaparsınız ve altı ay hiçbir şey değişmez — ama en azından altı ay sonra tartışma "sen niye yapmadın"dan "bunu kim yapacak"a taşınmış olur. İkisi aynı tartışma değil. **Soru:** Rol sınırı çizmek işten kaçmak mıdır? **Cevap:** Hayır — tam tersi. Sınır çizen kişi, aynı hareketle **neyi yapacağını da taahhüt etmiş** olur; sınır bir reddin değil, bir sözün kenarıdır. Asıl kaçış, sınırı belirsiz bırakıp hiçbir şeyden net biçimde sorumlu olmamaktır. Her şeye "bakarım" diyen kişi, hiçbir şeyin hesabını vermez. ## Üç masadan bakış **Kıdemli çalışanın masasından.** İşiniz sınırı yazılı hâle getirmek. Ama tonun önemi burada belirleyici: "yapmam" ile "yaparım, peki bunun sahibi kim olacak?" arasındaki fark, kariyerinizin gidişatını değiştirir. İlki bir duvar, ikincisi bir soru — ve şirketler duvarı cezalandırır, soruyu çoğu zaman cevaplamak zorunda kalır. Kısıtınız acı: hayır demenin bedelini siz ödersiniz, faydasını şirket görür. Sınır çizdiğiniz için "takım oyuncusu değil" damgası yiyebilirsiniz; sınır çizmediğiniz için tükendiğinizde ise kimse gelip özür dilemez. **Yöneticinin masasından.** Önünüzde bir boşluk var ve elinizde onu kapatabilecek bir kişi. O kişiye vermek en kolay, en verimli ve en yakıcı seçenek. Kısa vadede her ölçüt sizi haklı çıkarır: iş çıkıyor, kalite iyi, kimse şikâyet etmiyor. Sonra bir gün o kişi istifa dilekçesiyle kapınızda beliriyor ve o an — ancak o an — kaç aydır yaktığınızı fark ediyorsunuz. Kısıtınız gerçek: elinizde başka kimse yok. Ama o kişiyi yakma hızınızı siz belirliyorsunuz ve yanma sinyalleri istifadan çok önce geliyor; bakmadığınız için görmüyorsunuz. **Patronun masasından.** Her boşluğu aynı kişiye vererek onu **tek nokta arızası** hâline getirdiğinizi görmüyorsunuz. Sizin gördüğünüz şey bir çalışan; oysa o kişi ayrıldığında kaybettiğiniz şey bir çalışan değil, bir bölüm — üstelik kaybettiğiniz bölümün adı bile yok, çünkü hiç kurulmadı. Kısıtınız da gerçek: kadro maliyeti soyut bir tartışma değil. "Bir kişi daha alalım" cümlesi sizin için bir cümle değil, bir yıllık taahhüt; nakit akışına bakan tek kişi de sizsiniz. Ama şunu da hesaba katın: tek nokta arızasının maliyeti, o kadronun maliyetinden ucuz değil. Sadece ertelenmiş. ## Kapanış Rol sınırı, işten kaçmanın değil, işin sahibinin belli olmasının aracıdır. Yapabildiğiniz her işi yapmak zorunda değilsiniz. Ama yapmadığınız işin ortada kalmasına da razı olmayın: kapatın, sonra da yüksek sesle söyleyin ki bir daha sizin kapatmanız gerekmesin. Sözlü sınır sınır değildir. Yazın. ## Süreç ne zaman zırh, ne zaman pranga? - Kaynak: https://muhammetsafak.com/tr/blog/surec-ne-zaman-zirh-ne-zaman-pranga/ Çalıştığım bir kurumsalda, her sürüm öncesi doldurulması gereken bir form vardı. Yedi alan, üç imza, bir e-posta listesi. Formu dolduruyordunuz, listeye gidiyordu, kimse cevap yazmıyordu; bir gün sonra sürüm çıkıyordu. Birkaç ay sonra dayanamayıp sordum: "Bu formu kim okuyor?" Cevabı kimse bilmiyordu. Israr edince ekipteki en eski kişi omuz silkti: **"Eskiden bir olay olmuş."** Olayın ne olduğunu hatırlayan yoktu. Olayı yaşayan ekip dağılmıştı, o formu isteyen yönetici iki şirket önce ayrılmıştı. Ama form duruyordu ve otuz kişi her ay onu dolduruyordu. Sonradan işin asıl komik yanını fark ettim: forma bakıp o "olay"ı tahmin etmeye çalıştım ve şu sonuca vardım — bu form, hangi olaydan doğduysa, o olayı **engelleyemezdi**. Kimse okumuyordu ki. Bu dizinin [ilk yazısında](/tr/blog/kurumsal-mi-patron-sirketi-mi/) kurumsallığı "kararın kişiden ayrılabilmesi" diye tanımlamıştım. Süreç, o ayrılmanın somut aracı. Bu yazı onun iki yüzüyle ilgili: aynı süreç aynı anda hem sizi koruyan zırh, hem sizi yavaşlatan pranga olabiliyor. Hangisi olduğunu ayırt etmek, sandığınızdan daha az sezgi, daha çok muhasebe işi. Bir sınır çizeyim: burada Agile mı Waterfall mı tartışması yapmayacağım. O işi [metodoloji karar matrisi yazısında](/tr/blog/hangi-projede-hangi-metodoloji-karar-matrisi/) yaptım. Buradaki konu hangi metodolojiyi seçtiğiniz değil; seçtiğiniz şey ne olursa olsun ekibin üzerinde biriken **sürecin kendisi**: neden doğar, neden ölmez, ne zaman koruma ne zaman engeldir. ## Süreç bir erdem değil, bir maliyet kalemidir Süreç konuşulurken en sık yapılan hata, onu bir karakter özelliği gibi ele almak. "Disiplinli ekip", "ciddi şirket", "biz işi sağlama alırız." Bunların hepsi ahlaki bir dil ve tam da bu yüzden tehlikeli: ahlaki olan şey tartışılmaz, sadece savunulur. Oysa süreç bir bütçe kalemidir ve iki tarafı vardır. **Ödediğiniz:** koordinasyon vergisi. Zaman (form, toplantı, onay bekleme), dikkat (asıl işten kopma), ve en pahalısı moral — çünkü gerekçesini bilmediği bir adımı tekrarlayan insan, işine olan inancından biraz kaybeder. Bu vergi görünmez olduğu için de kimse bütçeye yazmaz. **Aldığınız:** tekrar edilebilirlik ve kişi bağımsızlığı. Aynı iş, aynı kalitede, farklı biri tarafından, üçüncü kez yapılabiliyor. Bir kişi tatile çıktığında iş durmuyor. Bir hata iki kez yaşanmıyor. > **Süreç, geçmişte yaşanmış bir olayın maliyetini geleceğe taksitle yaymaktır.** Taksit toplamı olayın maliyetini geçtiği anda süreç zırh olmaktan çıkar, prangaya döner. Bu bir slogan değil, gerçekten hesaplanabilir bir denge. Otuz kişinin ayda on dakikasını yiyen bir form, yılda altmış saat eder. O formun engellediği olay, yılda altmış saatten daha mı pahalı? Cevap "evet" ise form zırhtır. "Bilmiyorum" ise — ki çoğu zaman cevap budur — elinizde bir süreç değil, bir alışkanlık var. ## Süreç ne zaman eklenir? Sürecin doğru zamanı var. Erkeni de var, geci de. **1. Aynı hata üçüncü kez tekrarlandığında.** Bir kez tesadüf, iki kez şanssızlık, üç kez örüntü. İlk iki seferde süreç eklemek, henüz var olmayan bir problemi çözmektir — yani erken optimizasyon. Üçüncüde artık kişilerin dikkatiyle çözülmediği kanıtlanmıştır; sistemin çözmesi gerekir. **2. Kritik bir iş tek kişiye bağlıysa.** "O işi sadece Ahmet biliyor" cümlesi bir şaka değil, bir risk raporudur. Burada süreç, Ahmet'e güvensizlik değil; Ahmet'in izne çıkabilmesi, hastalanabilmesi, hatta istifa edebilmesidir. Kişi bağımlılığı şirketin değil, önce o kişinin sırtında bir yüktür. **3. Ekip birbirini kendiliğinden duyamayacak kadar büyüdüyse.** Beş kişilik ekipte süreç gereksizdir çünkü koordinasyon zaten masada oluyor. Yirmi beş kişide olmuyor. O eşiği geçtiğinizi, "haberim yoktu" cümlesinin sıklığından anlarsınız. Bu üç eşikten **önce** eklenen her süreç, çözdüğünden fazlasını maliyet olarak yazar. Bu üç eşikten **sonra** eklenmeyen her süreç ise, er ya da geç bir krizle kendini hatırlatır. ## Süreç mezarlığı Şimdi asıl arızaya geliyorum. Kurumsalın hastalığı süreç *eklemek* değil — süreç eklemek çoğu zaman doğru karardır. Hastalık, **kaldırma ritüelinin hiç olmamasıdır.** Her süreç bir olayın mezar taşıdır. Yıllar geçtikçe şirket bir mezarlığa dönüşür ve kimse temizlemez. Sebebi tembellik değil, temiz bir **risk asimetrisi**: | | Süreç eklemek | Süreç kaldırmak | | --- | --- | --- | | **Riski kim taşır?** | Kimse; risk ekibe dağılır | Kaldıran kişi, tek başına | | **Yanlışsa ne olur?** | Kimse fark etmez, yavaşlık genel bir şikâyet olur | "Bunu kim kaldırdı?" sorusunun bir cevabı olur | | **Kararın görünürlüğü** | Sorumluluk sahibi görünmez | Sorumluluk sahibi imzalı | Eklemenin riski dağıtıktır, kaldırmanın riski kişiseldir. Rasyonel her insan bu tabloya bakıp aynı kararı verir: dokunma. Böylece şirket, kimsenin istemediği ama herkesin koruduğu bir ağırlık biriktirir. Kurumsalların zamanla yavaşlamasının sebebi kötü niyet değil, bu asimetridir. Buna karşı iki somut şey öneriyorum ve ikisi de ucuz: **Sürecin doğduğu olayı, süreçle birlikte yazın.** Bir cümle yeter: *"Bu kontrol, 2024'te müşteri verisi yanlış ortama kopyalandığı için eklendi."* Bu tek satır, beş yıl sonra o süreci savunulabilir ya da kaldırılabilir kılar. Gerekçesiz süreç, tanımı gereği kaldırılamaz — çünkü kimse neyi göze aldığını bilemez. **Yılda bir kez "hangi süreç hâlâ geçerli?" gözden geçirmesi yapın.** Ekleme toplantısı herkeste var; kaldırma toplantısı hiç kimsede yok. Bu toplantının asıl işlevi süreç kaldırmak bile değil — kaldırma kararının **kişisel** değil **kurumsal** olmasını sağlamak. Asimetriyi kıran şey budur. **Soru:** Bir süreci ne zaman kaldırmalı? **Cevap:** Onu doğuran olay bir daha yaşanmayacaksa; ya da süreç o olayı zaten engellemiyorsa. İkincisi daha yaygındır ve daha az fark edilir — çünkü bir sürecin *var olması* ile *işe yaraması* birbirine karıştırılır. Benim doldurduğum form ikinci gruptaydı. ## Patron şirketinin süreç alerjisi Madalyonun öbür yüzü de var ve orada aynı ölçüde dürüst olmak lazım. Patron şirketinde en çok duyduğum cümle: *"Biz çevik çalışırız."* Bu cümle çoğu zaman "yazmayı sevmiyoruz"un kibar hâlidir. Karar sözlü alınır, gerekçe kaydedilmez, aynı tartışma altı ayda bir baştan yapılır ve kimse bunun bir maliyet olduğunu fark etmez — çünkü fatura tek kalemde gelmez, her ay biraz gelir. Ama bazen gerçekten çevikliktir. Ve o zaman o hız, gerçek bir rekabet avantajıdır: rakibiniz onay zincirini beklerken siz ürünü çıkarmışsınızdır. Bu şirketlerde süreç eklemek, sahip oldukları tek üstünlüğü tıraşlamak olur. İkisini ayıran test tek soru: **aynı hata tekrar ediyor mu?** Etmiyorsa, o gerçekten çevikliktir; süreç eklemeyin, dokunmayın. Ediyorsa, "çevik" dediğiniz şey kayıtsızlıktır ve size bir bedel ödetiyor — sadece faturayı henüz görmediniz. ## Üç masadan bakış **Kıdemli çalışanın masasından.** Süreç sizin için aynı anda iki şey: sizi keyfî suçlamadan koruyan zırh ve sizi yavaşlatan pranga. Dikkat edin — bunlar farklı şeyler değil, **aynı şey**. Bir şey patladığında "prosedüre uydum" diyebilmenizi sağlayan adım, tam da her gün on dakikanızı yiyen adımdır. Kısıtınız: sürecin doğduğu olayı çoğu zaman siz görmediniz. O yüzden gereksiz görünen şeylerin bir kısmı gerçekten gereksiz, bir kısmı ise sadece **size** gereksiz görünüyor. İkisini ayırmadan itiraz ederseniz, haklı olduğunuz yerde de kaybedersiniz. **Yöneticinin masasından.** Süreç sizin için denetlenebilirlik ve kendinizi savunabilme aracı. Bir şey ters gittiğinde yukarıya gösterebileceğiniz tek şey odur. Süreç kaldırmak ise sizin için doğrudan **kişisel risk**: kaldırdığınız kontrolün engellediği bir şey bir gün patlarsa, fatura ekibe değil size kesilir. Kısıtınız bu asimetri. Süreç kaldırmıyorsanız bunun sebebi tembellik değil, kaldırmanın getirisinin ekibe, riskinin size ait olması. Bunu değiştirmenin tek yolu, kaldırma kararını kişisel olmaktan çıkarıp kuruma mal etmek — yani yukarıda bahsettiğim gözden geçirmeyi bir **kural** hâline getirmek. **Patronun masasından.** Süreç sizin için maliyet ve yavaşlama demek; büyük ölçüde de haklısınız. Ama göremediğiniz şey şu: süreç aynı zamanda şirketin **sizden bağımsızlaşmasıdır.** Devredilebilir, yönetici atanabilir, tatile çıkılabilir, bir gün satılabilir hâle gelmesidir. Kısıtınız burada gizli: sürece alerjisi olan bir patron, farkında olmadan **satılamaz bir şirket** inşa ediyor. Çünkü satın alan kişi şirketi değil, sizin kafanızın içindekileri satın almak zorunda kalır — ve kimse bunu satın almaz. > **Tan:** Olayı hatırlayan kimse kalmadıysa süreç henüz pranga sayılmaz; olay kayıtlardan yeniden kurulabilir. Kaldırmadan önce o küçük araştırmayı yapmak, kaldırmanın bedelini görünür kılar. ## Kapanış Süreç ne iyidir ne kötü. Bir olayın faturasıdır ve taksitle ödersiniz. Sorulacak soru "burada çok mu süreç var" değil. Sorulacak soru şu: **bu sürecin doğduğu olayı biri hatırlıyor mu, ve o olay hâlâ mümkün mü?** İki cevap da "evet" ise, o süreç zırhtır; şikâyet etmeyin, taşıyın. Cevaplardan biri "hayır" ise, elinizde bir mezar taşı var — ve mezarlıkları temizlemek de birinin işi. Genellikle kimsenin işi değildir. Bu yüzden de yıllarca form doldururuz. ## Karar nasıl alınır: onay zinciri mi, tek imza mı? - Kaynak: https://muhammetsafak.com/tr/blog/karar-nasil-alinir-onay-zinciri-mi-tek-imza-mi/ Bir patron şirketinde, bir öğleden sonra, ayaküstü yapılan yirmi dakikalık bir konuşmada veri modelinin şekli belirlendi. "Şimdilik böyle koyalım, sonra düzeltiriz" dendi. Sonraki iki yılı o "sonra"nın içinde geçirdik. Yazdığımız her yeni özellik o ilk yirmi dakikanın etrafından dolaşmak zorunda kaldı; her dolanma bir sonrakini biraz daha pahalı hâle getirdi. Aynı yıllarda, bir kurumsalda, ekibin kullanacağı bir kütüphanenin seçimi için üç ay bekledik. Üç ayın sonunda karar çıkmadı — çünkü o arada projenin kapsamı değişti ve kütüphaneye ihtiyaç kalmadı. Kimse bunu bir başarısızlık saymadı. "Karar kendiliğinden çözüldü" dendi. İki hikâye, iki farklı arıza. Ama ikisi de aynı köke bakıyor: **şirketler kararı hızlandırmakta ya da yavaşlatmakta değil, hangi kararın hangi hızda alınacağını seçmekte başarısız oluyor.** Bu dizinin [ilk yazısında](/tr/blog/kurumsal-mi-patron-sirketi-mi/) kurduğum sözlükte ilk eksen "karar nerede alınıyor?" idi. Bu yazı o eksenin üstünde duruyor. ## Tersinirlik çerçevesi: hangi kapıdan geçiyorsunuz? Karar hızını tartışmanın en verimli yolu, kararı **tersinirliğine** göre ayırmak. > **Çift yönlü kapı, geri dönüldüğünde maliyeti düşük olan karardır. Tek yönlü kapı, geri dönüşün ya imkânsız ya da yeniden yapmaktan pahalı olduğu karardır.** Çift yönlü kapıdan hızlı geçilmeli: yanılırsanız geri dönersiniz, maliyet birkaç günlük iştir. Tek yönlü kapıdan yavaş geçilmeli: yanılırsanız yıllarca o kararın içinde yaşarsınız. Buraya kadarı kolay. Zor olan kısmı şu: **şirket tipleri bunu sistematik olarak ters karıştırıyor.** | | Tek yönlü kapı (yavaş alınmalı) | Çift yönlü kapı (hızlı alınmalı) | | --- | --- | --- | | **Örnekler** | Veri modelinin çekirdeği, müşteriye söz verilen bir sözleşme maddesi, işe alım, fiyatlandırma modeli, bir sistemin kime ait olduğu | Bir kütüphane seçimi, klasör yapısı, bir ekranın yerleşimi, iç araç tercihi, bir toplantının formatı | | **Patron şirketinin hatası** | Yirmi dakikada geçer, "sonra düzeltiriz" der | Genelde doğru davranır; hız burada gerçekten değerlidir | | **Kurumsalın hatası** | Genelde doğru davranır; yavaşlık burada gerçekten değerlidir | Komite kurar, üç ay bekletir, karar anlamsızlaşır | Patron şirketi tek yönlü kapıdan koşarak geçer, çünkü hız orada bir erdem sayılır ve kapının tek yönlü olduğunu fark edecek bir mekanizma yoktur. Kurumsal ise çift yönlü kapının önüne bir onay zinciri kurar, çünkü orada da her kararın aynı ağırlıkta olduğu varsayılır. Bir şirkete girdiğinizde ilk bakacağınız şey karar hızı değil, **hızın kapıya göre değişip değişmediği.** Değişmiyorsa — her karar aynı hızda alınıyorsa — o şirket tersinirliği hiç düşünmemiş demektir. İki uçta da bunun bir bedeli var. ## Onay zincirinin gerçek faydası verim değil Onay zinciri hakkında en yaygın savunma şudur: "Hata yapmayı engelliyor." Bu doğru değil. Onay zinciri hatayı engellemez; hatayı yavaşlatır ve dağıtır. Yeterince istekliyseniz, bir onay zincirinden geçirilmiş çok kötü kararlar üretebilirsiniz — çoğu kurumsalda üretilmiştir de. Onay zincirinin gerçek faydası başka: **kararı kişiden ayırmak.** Bir kararın üç imzadan geçmesinin anlamı, üç kişinin de o kararı anlamış olmasıdır. Anlamak zorundadırlar; imzalayacaklardır. Bu, kararı veren kişi yarın ayrıldığında kararın onunla birlikte gitmemesini sağlar. Kurumsalda "neden bu veritabanını seçtik?" sorusunun cevabının bir yerde durmasının sebebi, birilerinin titiz olması değil — kararın yapısı gereği en az iki kişinin daha ondan haberdar olması. Bunu böyle söyleyen pek yok, çünkü kulağa savunmacı geliyor. "Süreç var ki kimse vazgeçilmez olmasın" cümlesi, bir toplantıda söylendiğinde, insanların kendini değersiz hissetmesine yol açar. Oysa kastedilen bu değil. Kastedilen şu: **kurumun hafızası, karar veren kişinin hafızasından uzun ömürlü olmalı.** Bu faydanın da bir sınırı var ve dürüst olmak gerekirse sınırı erken geliyor. Onay zinciri, kararı anlamayan insanlar tarafından imzalanmaya başladığı anda hiçbir işe yaramaz — sadece takvimlere yayılmış bir gecikme olur. İmza, bir sorumluluk değil bir formalite hâline gelmişse, o zincir kararı kişiden ayırmıyor; sadece kimsenin sahiplenmediği bir karar üretiyor. Bunun nasıl anlaşıldığını dizinin ilerleyen bir yazısında ayrıca konuşacağım. ## Tek imzanın gerçek maliyeti hız değil Tek imzanın karşı tarafı kolayca romantikleştiriliyor: karar hızlı çıkar, sahibi bellidir, kimse arkasına saklanamaz. Hepsi doğru. Ama tek imzanın gerçek maliyeti hızın kendisinde değil, **hızın neye bağlandığında.** Tek imzalı bir şirkette karar, patronun o günkü ruh hâline, en son konuştuğu kişiye ve o sabahki nakit tablosuna bağlanır. Bunların hiçbiri kötü niyet değil; insan olmanın olağan hâli. Ama bir sistem girdisi olarak, bunlar gürültüdür. Aynı soruyu iki farklı gün sorduğunuzda iki farklı cevap alıyorsanız, kararı veren şey artık argümanınız değil, takviminizdir. Asıl hasar burada da değil. Asıl hasar ekipte birikiyor. Bir ekipte insanlar karar verir, karar geri alınır. Bir daha verir, bir daha geri alınır. Üçüncüsünden sonra artık karar vermezler. Bunu bilinçli bir protesto olarak değil, sessiz bir enerji tasarrufu olarak yaparlar: nasılsa değişecek. Zamanla bu bir alışkanlığa, sonra bir karaktere dönüşür — **öğrenilmiş çaresizlik.** Sorulmadan hiçbir şey yapılmaz, her ayrıntı yukarı sorulur, inisiyatif sözcüğü ekipte bir şaka hâline gelir. Patron bunu görür. Ama başka bir şey olarak okur: "Kimse inisiyatif almıyor." Ve çoğu zaman çözüm olarak daha çok karar verir. Bu, patron şirketinin en sinsi hasarıdır; çünkü döngüseldir ve kendini besler. Ben bunu üç ayrı şirkette, tam olarak aynı sırayla gördüm. **Soru:** Ekibim neden inisiyatif almıyor? **Cevap:** Son altı ayda ekibin aldığı kaç kararı geri aldığınıza bakın. Sayı sıfır değilse, cevabınız orada. İnsanlar geri alınacağını bildikleri kararı vermezler; vermemek daha ucuzdur. ## "Bu kararı kim geri alabilir?" İlk yazıyı bu soruyla kapatmıştım, çünkü bir şirketi anlamanın en hızlı yolu bu. Cevap bir **rol** ise ("mimari kurul", "teknik lider") kurumsal bir yapıdasınız. Kararın sahibi bir koltuk; koltuktaki kişi değişse de kararın statüsü değişmiyor. Cevap bir **isim** ise patron şirketindesiniz. Bu kötü değil — sağlıklı hâlinde, kararın sahibi belli olduğu için hesap sorulabilir bir yapıdır. Hasta hâlinde ise o ismin ruh hâli, şirketin tek gerçek yol haritasıdır. Cevap **"hiç kimse"** ise durun. Bu, bağımsız bir şirket tipi değil; bir arıza belirtisi. Geri alınamayan karar, çoğu zaman kimsenin sahiplenmediği karardır. Kimsenin sahiplenmediği kararı da kimse gözden geçirmez — çünkü gözden geçirmek, sahiplenmeyi gerektirir. ## Üç masadan bakış Aynı karar, hangi masada oturduğunuza göre bambaşka bir şeye benziyor. Ve her masanın meşru bir kısıtı var. **Kıdemli çalışanın masasından.** Sizin işiniz kararı vermek değil, **çerçevelemek**. Cümle şudur: "Bu iş iki şekilde yapılabilir. Birincisi bu hafta biter ama veri modelini şuraya kilitler. İkincisi üç hafta sürer, kilitlemez. Ben ikincisini öneririm — çünkü bu bir tek yönlü kapı. Karar sizin." Bu cümleyi kurabiliyorsanız kararı da etkilemişsinizdir, kararı sahiplenmiş de olmazsınız. Kıdemin ünvanla gelmeyen kısmı büyük ölçüde budur; daha önce [bu konuyu ayrıca yazmıştım](/tr/blog/kidem-unvanla-gelmeyen-sorumluluk/). Kısıtınız: kararın tüm girdilerini görmüyorsunuz. Şirketin o ay ne kadar nakit yaktığını, hangi müşterinin ayrılmak üzere olduğunu bilmeden verilmiş "yanlış" kararların bir kısmı aslında yanlış değil. **Yöneticinin masasından.** Sizin işiniz hangi kararı **yukarı taşıyacağınızı** seçmek. Bu bir denge işi ve iki ucu da kötü. Her kararı yukarı taşırsanız kendi imzanız değersizleşir; yukarısı sizi bir karar noktası olarak değil, bir posta kutusu olarak görmeye başlar. Hiçbirini taşımazsanız, bir gün taşımanız gereken karar geldiğinde yukarısı sizi es geçer — çünkü sizin masanızın bir karar üretmediğini öğrenmiştir. Kısıtınız: bu dengeyi kurmanın kitabı yok ve yanlış tarafa düştüğünüzü ancak aylar sonra anlıyorsunuz. **Patronun masasından.** Sizin işiniz hangi kararı **bırakacağınızı** seçmek. Bu, listedeki en zor iş; çünkü bırakmak kaybetmek gibi hissettiriyor ve şirketin riski gerçekten sizin cebinizde. Ama bırakılan kararın tek bir kuralı var ve pazarlığa kapalı: **bıraktığınız kararı bir kez geri alırsanız devir biter.** Ekip bir daha karar vermez; nezaketen dener belki, ama içten içe beklemeye geçer. Bu yüzden bırakacağınız kararı seçerken asıl soru "bunu iyi verirler mi?" değil, "**kötü verirlerse buna katlanabilir miyim?**" Katlanamayacağınız kararı hiç bırakmayın; bırakmış gibi yapmak, hiç bırakmamaktan daha çok zarar veriyor. Kısıtınız: her şeyi bilemezsiniz ama her şeye karar vermek zorunda hissedersiniz — ve bu his, şirket büyüdükçe güçleniyor, oysa tam tersi gerekiyor. Üç masa da haklı. Üçü de eksik. Kıdemli çalışan kararın maliyetini görmüyor, yönetici kararın kapısını göremiyor, patron kararın ekipte bıraktığı izi göremiyor. ## Kapanış Karar hızı bir erdem değil, bir ayar. Yanlış kapıda hızlı olmak da, doğru kapıda yavaş olmak kadar pahalı. Bir şirkete girdiğinizde ilk sorunuz "burada kararlar hızlı mı alınıyor?" olmasın. Şu olsun: **hangi kararlar hızlı alınıyor?** Cevap "hepsi" ya da "hiçbiri" ise, orada kararla ilgili bir sorun var — ve o sorun sizin işinizin ne kadarının size ait olacağını, maaştan çok daha fazla belirleyecek. Bu hesap tutmuyorsa yapılacak konuşma başka bir konuşma; onu da [kalmak mı, gitmek mi](/tr/blog/is-degistirme-karari-kalmak-mi-gitmek-mi/) sorusunun altında ayrıca yazdım. ## Kurumsal mı, patron şirketi mi? Ayıran şey büyüklük değil - Kaynak: https://muhammetsafak.com/tr/blog/kurumsal-mi-patron-sirketi-mi/ Danışmanlık yaptığım bir dönemde, aynı hafta içinde iki farklı şirkete girip çıkmıştım. İlkinde projeye küçük bir kütüphane eklemek istedim; üç ayrı onay, bir güvenlik değerlendirmesi ve on gün sürdü. İkincisinde aynı ihtiyacı patrona söyledim, "ekle gitsin" dedi, öğleden sonra production'daydı. O hafta aklımda kalan şey hangisinin daha iyi olduğu değildi. İkisinin de bir bedeli olduğuydu. İlk şirkette on günü kaybettim; ikincisinde ise altı ay sonra o kütüphanenin bakımsız kaldığını ve kimsenin neden eklendiğini hatırlamadığını gördüm. Bu dizi orayı kurcalıyor: **çalıştığınız şirketin tipi, yaptığınız işin ne kadarının size ait olduğunu belirliyor** — ama bunu kimse iş ilanında yazmıyor. İlk yazıda dizinin sözlüğünü kuruyorum: bu iki tip gerçekte neyle ayrılıyor, neyle ayrılmıyor. ## Ayırmayan şey: çalışan sayısı En yaygın yanılgı bu. "Kurumsal" deyince akla büyük, "patron şirketi" deyince küçük geliyor. Dört yüz kişilik, cirosu yüksek, üç ülkede ofisi olan patron şirketleri gördüm: her kritik karar hâlâ tek bir odadan çıkıyordu. Yirmi beş kişilik, cerrahi disiplinde çalışan, kararlarını yazıya döken ekipler de gördüm: kurucu ortadan kaybolsa şirket iki yıl aksamadan yürürdü. Büyüklük bir sonuç, bir sebep değil. Büyüdükçe kurumsallaşma **eğilimi** artıyor, çünkü patronun kafası ölçeklenmiyor — ama bu bir yasa değil, sadece bir basınç. ## Ayıran dört eksen Bir şirketi ilk haftasında okumak istiyorsanız, bakacağınız şey organizasyon şeması değil. Şu dört soru: **1. Karar nerede alınıyor?** Kararı bir rol mü veriyor, bir kişi mi? "Bunu kim onaylar?" sorusuna gelen cevap bir unvan mı ("teknik lider"), yoksa bir isim mi ("Ahmet Bey'e sormak lazım")? Fark ince görünür, belirleyicidir. **2. Ne kadarı yazılı?** Sadece kod değil: kararın **gerekçesi** yazılı mı? "Neden bu veritabanını seçtik?" sorusunun cevabı bir yerde duruyor mu, yoksa cevabı bilen kişinin takvimine mi bakmak gerekiyor? **3. Hatanın maliyeti kime yazılıyor?** Bir şey patladığında ilk aranan şey **sebep** mi, **kişi** mi? Bu, tek başına şirketin karakterini ele verir; dizinin ilerleyen yazılarından birini tamamen buna ayırdım. **4. Zaman ufku ne kadar?** Şirket bu çeyreği mi düşünüyor, üç yılı mı? Patron şirketinde ufuk çoğu zaman nakit akışının ufkudur; kurumsalda bütçe döneminin. > **Kurumsallık, kararın kişiden ayrılabilmesidir. Patron şirketi, kararın kişide kalmasıdır.** Süreçler, toplantılar, onay zincirleri, performans sistemleri — hepsi bu tek farkın türevidir. ## İki tip değil, dört hâl Buraya kadarını okuyup "kurumsal iyi, patron şirketi kötü" sonucunu çıkarmayın. Ben yıllar içinde her iki tipin de hem sağlıklı hem hasta hâlini gördüm; asıl ayrım tipte değil, tipin hâlinde. | | **Sağlıklı hâli** | **Hasta hâli** | | --- | --- | --- | | **Kurumsal** | Karar kişiden bağımsız, süreç gerçek bir olaydan doğmuş, sorumluluk net | Süreç kendini üretiyor, kimse kararı sahiplenmiyor, "prosedür böyle" bir cevap sayılıyor | | **Patron şirketi** | Hızlı, uçtan uca görüş, kararın sahibi belli | Karar patronun o günkü ruh hâline bağlı, sınırlar yok, kayıt yok | Sağlıklı bir patron şirketinde geçirdiğim yıllar, hasta bir kurumsalda geçirdiğim aylardan daha çok şey öğretti. Tersi de doğru. Tipin adı size hiçbir şey söylemez; hâli her şeyi söyler. **Soru:** Bir şirketin hangi hâlde olduğunu nasıl anlarım? **Cevap:** Tek soruyla: *"Son bir yılda kaldırdığınız bir süreç var mı?"* Kurumsalın sağlıklı olanı buna somut bir örnek verir. Hasta olanı soruyu anlamaz — çünkü orada süreçler eklenir, kaldırılmaz. Patron şirketinde ise cevap genellikle şudur: "Bizde zaten süreç yok." Bu da bir cevaptır. ## Üç masadan bakış Bu dizinin her yazısında böyle bir bölüm olacak. Sebebi şu: bir şirkette yaşanan hiçbir şeyin tek bir doğru okuması yok. Aynı olay, hangi masada oturduğunuza göre bambaşka görünüyor — ve her masanın **meşru bir kısıtı** var. **Kıdemli çalışanın masasından.** İstediğiniz şey öngörülebilirlik. Kararın gerekçesini bilmek, işinizin nerede başlayıp bittiğini bilmek, iyi işin fark edileceğinden emin olmak. Kısıtınız: şirketin nakit durumunu, müşteri baskısını, patronun neyi göze alamadığını çoğu zaman **bilmiyorsunuz**. Bilmediğiniz için de bazı kararlar size keyfî görünüyor — bir kısmı gerçekten keyfî, bir kısmı değil. **Yöneticinin masasından.** İstediğiniz şey manevra alanı. Yukarıdan gelen hedefle aşağıdaki ekibin kapasitesi arasında bir yerde duruyorsunuz ve iki tarafa da "hayır" demeniz gerekiyor. Kısıtınız: bütçe sabit, süre sabit, ekip sabit. Elinizdeki tek esnek değişken çoğu zaman ekibin sabrı — ve onu harcarsanız geri gelmiyor. **Patronun masasından.** İstediğiniz şey hız ve hayatta kalmak. Riski **kendi cebinizden** taşıyorsunuz; yanlış bir karar sizin için "kötü bir çeyrek" değil, kendi paranız. Kısıtınız: her şeyi bilemezsiniz ama her şeye karar vermek zorunda hissedersiniz. Şirket büyüdükçe [kafanızdaki bilgi](/tr/blog/mentorluk-ve-bilgi-paylasimi-baskasini-buyuturken-buyumek/) şirketin darboğazına dönüşür — ve bunu en son fark eden kişi genellikle sizsiniz. Bu üç kısıt birbiriyle çarpışır. Çalışanın "neden bu kadar yavaşız" sorusuyla patronun "neden bu kadar pahalıyız" sorusu aynı olayın iki ucudur. İkisi de haklı; ikisi de eksik. ## Peki bu neye yarıyor? Şirket tipini okumak bir şikâyet aracı değil, bir **kalibrasyon** aracı. Patron şirketinde "burada süreç yok" diye söylenmek zaman kaybı. Orada değerli olan şey hızın kendisi; sizin işiniz o hızın geri alınamaz kararlara bulaştığı yeri fark edip **oraya** fren koymak. Her yere değil. Kurumsalda "burada her şey çok yavaş" diye söylenmek de aynı ölçüde boş. Orada değerli olan şey kararın kişiden bağımsız olması; sizin işiniz iyi işi **sistemin okuyabildiği dile çevirmek**. Sisteme küsmek değil. İkisinde de aynı hatayı yapan çok geliştirici gördüm: bulunduğu tipin güçlü yanını kullanmayıp zayıf yanına kızmak. Bu, kariyerin en pahalı ve en sessiz israfı. ## Dizide sırada ne var Bu ilk yazı sözlüğü kurdu. Bundan sonrası, bu dört eksenin gündelik hayatta nereye çarptığı: - Karar nasıl alınıyor — onay zinciri mi, tek imza mı? - Süreç ne zaman koruyor, ne zaman prangaya dönüşüyor? - Rol sınırları, bilgi akışı, terfi ve zam, toplantılar, kriz anı. - Ve şirket büyürken tam olarak neyin kırıldığı. Şimdilik şunu bırakıyorum: bir sonraki mülakatınızda maaştan önce şunu sorun — **"Bu kararı kim geri alabilir?"** Cevap size şirketin tipini, hâlini ve orada geçireceğiniz iki yılı, iş ilanının tamamından daha dürüst anlatır. ## TypeScript'in yeni derleyicisi ve build hızının önemi - Kaynak: https://muhammetsafak.com/tr/blog/typescriptin-yeni-derleyicisi-ve-build-hizi/ TypeScript ekibi derleyiciyi Go'ya port etti. İlk duyduğumda önce şaşırdım, sonra bunun ne kadar yerinde bir karar olduğunu görmenin rahatlığını hissettim. Bir dilin aracının bambaşka bir dille yazılması — polyglot çalışan biri için bu kulağa tuhaf değil, son derece tanıdık geliyor. ## Neden Go? Eski derleyici [TypeScript'in](/tr/blog/typescripte-gecis-javascriptte-tip-guvenligi/) kendisiyle yazılmıştı. Kendi kendini derleyen bir dil zariftir; ama JavaScript çalışma zamanının bir tavanı var: tek iş parçacığı ve yorumlanan kodun getirdiği yük. Derleyici gibi bir araç — binlerce dosyayı gezen, ayrıştıran, [tip denetimi yapan](/tr/blog/typescriptte-genericler-ve-tip-cikarimi/) bir program — bu tavandan en çok zarar gören iş türüdür. Go ise yerel makine koduna derleniyor, gerçek çoklu iş parçacığına sahip ve tam da bu tür sistem araçları için tasarlanmış. TypeScript ekibi en zarif olanı değil, işe en uygun olanı seçti. Duyurulan hedef yaklaşık on kat hızdı; kendi orta ölçekli projemde de geçiş, "derleniyor" beklemesini bir alışkanlık olmaktan çıkardı. > **Güncelleme · 2026-10-05** (yukarıdaki paragraf için) — Bu paragrafı yazdığımda TypeScript 7.0 henüz RC aşamasındaydı; [kararlı sürüm 8 Temmuz 2026'da duyuruldu](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/). Ekip, tam derlemelerde tipik olarak 8x ile 12x arasında bir hızlanma bildiriyor. Bir de not düşeyim: 7.0 bir API ile gelmedi; ekip 7.1'in yeni (ve farklı) bir API getirmesini bekliyor. ## Build hızı neden bu kadar önemli "On kat hızlı" kulağa hoş gelen bir rakam; ama asıl mesele rakam değil, **geri bildirim döngüsü**. Bir tip hatası yaptığınızı düşünün. Derleyici bunu iki saniyede söylerse, hâlâ o kodu düşünüyorsunuzdur; düzeltirsiniz, devam edersiniz. On beş saniyede söylerse, o arada başka bir sekmeye geçer, dikkatiniz dağılır ve geri döndüğünüzde "neredeydim?" diye düşünürsünüz. Yavaş bir derleyicinin gerçek maliyeti, beklediğiniz saniyeler değil; **kopan odaklanmadır.** Bu döngü editörde de yaşıyor. Otomatik tamamlama, tanıma gitme, yeniden adlandırma — hepsi aynı tip motorundan besleniyor. Motor hızlanınca editör de hızlanıyor; kod yazmak akışkan bir şey hâline geliyor. ## Dil değişmedi, yalnızca derleyici Bu geçişle ilgili en çok yanlış anlaşılan nokta şu: değişen şey TypeScript dili değil, onu derleyen program. Yazdığınız tipler, sözdizimi ve tip kuralları birebir aynı. Yani bu, öğrenilecek yeni bir şey değil; aynı işin çok daha hızlı yapılması. Bu, geçişi alışılmadık biçimde düşük riskli kılıyor. Çoğu araç değişiminde yeni bir zihinsel model öğrenmeniz, ekibi eğitmeniz, ince ayarları yeniden yapmanız gerekir. Burada öğrenilecek hiçbir şey yok — kod tabanınız aynı kalıyor, yalnızca tip denetimi daha çabuk dönüyor. Kendi projemde geçiş, yapılandırmada birkaç satırlık bir değişiklikten ibaretti. Bir aracın bu kadar büyük bir kazanımı bu kadar küçük bir maliyetle sunması nadirdir. Çoğu "büyük" iyileştirme, beraberinde bir öğrenme eğrisi ve bir geçiş acısı getirir. Buradaki kazanç ise bedavaya yakın — ve bu nadirlik, kararın ne kadar iyi düşünülmüş olduğunu gösteriyor. ## Polyglot bir ders Bu olaydan benim çıkardığım ders, TypeScript'ten bağımsız. Bir ekip, yıllardır kullandığı dilin aracını, daha uygun olduğu için başka bir dile taşıdı. Dile değil probleme bağlı kaldılar. Bu, yıllardır savunduğum bir yaklaşımın büyük ölçekli bir örneği: uygulama mantığını bir dille yazmak, o uygulamanın aracını başka bir dille yazmak gayet makul. Diller birer kimlik değil, birer araçtır. Doğru aracı seçmek için önce birden çok araca aşina olmak gerekiyor — ve bu aşinalığın karşılığını, tam da böyle kararların doğru verildiği anlarda alıyorsunuz. ## Dual-write'tan outbox'a: idempotent tüketim ve alan-seviyesi şifreleme günlüğü - Kaynak: https://muhammetsafak.com/tr/blog/outbox-idempotent-tuketim-ve-alan-sifrelemesi/ [Daha önce aynı e-fatura entegrasyonunda numara çakışmasını çözerken](/tr/blog/e-fatura-entegrasyonunda-numara-cakismasi/) senkron bir akışı RabbitMQ üzerinden asenkrona taşıdığımı anlatmıştım. O göçün ardından, gözden kaçırdığım daha sessiz bir hata kaldı: bazı faturalar veritabanına yazılıyor ama event'i kuyruğa hiç düşmüyordu. Consumer onları hiç görmüyor, kullanıcı da "kestim" diyor ama karşı tarafta hiçbir iz yok. Günde bir-iki tane, hep gece, hep deploy ya da network dalgalanması anında. Tabloyu netleştirince sebep ortaya çıktı: veritabanına `INSERT`, kuyruğa `publish` — bunlar **iki ayrı sistemdi** ve aralarında hiçbir garanti yoktu. Yazma başarılı oluyor, hemen ardından `publish` ağ takılmasıyla düşüyordu. Yerel transaction commit olmuştu, mesaj ise hiç yola çıkmamıştı. Buna **dual-write problemi** deniyor: tek bir iş adımının iki ayrı veri deposuna atomik olmayan biçimde yazması. Bu yazı o sorunu **transactional outbox** ile nasıl çözdüğümün, çözünce ortaya çıkan **at-least-once** (en az bir kez) tekrarları idempotent tüketimle nasıl bastırdığımın ve son olarak event'in içinden geçen kişisel veriyi **alan seviyesinde** nasıl şifrelediğimin günlüğü. Derin mimari tarafını ayrı yazdım (aşağıda link var); burada *gerçek bir projede bu kararları hangi sırayla aldım* tarafını anlatmak istiyorum. ## Dual-write problemi: iki yazma, sıfır garanti Sorunun özü tek cümleyle şu: **bir transaction'ı iki farklı sisteme yayamazsınız.** MySQL'e yazıp RabbitMQ'ya `publish` eden klasik kod şuna benzer: ```php DB::transaction(function () use ($invoice) { $invoice->save(); // 1) MySQL'e yaz $this->publishToRabbit($invoice); // 2) kuyruğa publish }); ``` Bu kod *çoğu zaman* çalıştığı için tehlikeli. Ama iki ayrı arıza modu var: - `save()` başarılı, `publishToRabbit()` ağ hatasıyla düşer → veritabanında fatura var, event yok. **Kayıp event.** - `publishToRabbit()` başarılı, ardından transaction başka bir sebeple `rollback` olur → event gitti ama veritabanında karşılığı yok. **Hayalet event.** `DB::transaction` içine almak da kurtarmıyor, çünkü RabbitMQ o transaction'ın parçası değil; commit/rollback yalnızca MySQL tarafını sarıyor. İki sistemi tek bir atomik adımda tutmanın pratik yolu, yazmayı **tek bir sisteme** indirgemek. ## Çözüm: önce veritabanına yaz, sonra ayrı bir süreç yayımlasın Transactional outbox deseninin fikri sade. [Chris Richardson'ın microservices.io'daki tanımıyla](https://microservices.io/patterns/data/transactional-outbox.html) problem "veritabanını atomik olarak güncellerken mesaj broker'ına da mesaj göndermek", çözüm ise "mesajı gönderen servisin, mesajı önce iş varlıklarını güncelleyen transaction'ın parçası olarak veritabanına yazması; ayrı bir sürecin sonra bu mesajları broker'a göndermesi". Pratikte: mesajı kuyruğa doğrudan basmak yerine, **aynı veritabanı transaction'ı içinde** bir `outbox` tablosuna satır olarak yaz. Fatura ile event aynı commit'te ya birlikte var olur ya da birlikte yok olur — dual-write tek write'a iner. ```php DB::transaction(function () use ($invoice) { $invoice->save(); OutboxMessage::create([ 'id' => Str::uuid(), // event'in idempotency anahtarı 'topic' => 'invoice.issued', 'payload' => $this->buildPayload($invoice), 'status' => 'pending', 'created_at' => now(), ]); }); ``` Kuyruğa basma işini ayrı bir **relay** (yayımlayıcı) yapıyor: `pending` satırları okuyup RabbitMQ'ya `publish` ediyor, başarınca `published` olarak işaretliyor. RabbitMQ kullanımının temeline [daha önce RabbitMQ'yu PHP ile anlattığım yazıda](/tr/blog/php-rabbitmq/) girmiştim; buradaki tek fark, `publish` çağrısının artık iş kodunda değil, bu relay'de olması. Önemli nokta: relay "publish ettim ama `published` damgasını vurmadan çöktüm" durumunda aynı satırı tekrar yayımlayabilir. Yani outbox dual-write'ı çözerken size **bedava bir garanti** vermiyor; **at-least-once** veriyor. Mesaj kaybolmuyor ama **tekrar edebiliyor.** Sıradaki problem bu. ## At-least-once gelince: tüketici idempotent olmalı At-least-once teslimde her event'in en az bir kez, bazen birden çok kez geleceğini varsaymak gerekir. Çözüm event'i tekil yapmaya çalışmak değil — **tüketiciyi** aynı event'i iki kez işlese de tek kez işlemiş gibi davranacak şekilde kurmak. Bu, [API'de idempotency üzerine yazarken](/tr/blog/api-de-idempotency-ayni-istegi-guvenle-tekrar-etmek/) anlattığım disiplinin kuyruk tarafındaki yüzü: orada istemci bir `Idempotency-Key` üretiyordu, burada event'in kendi `id`'si o anahtar. Tüketici, işlediği her event `id`'sini bir `processed_messages` tablosuna yazıyor ve işi **aynı transaction içinde** yapıyor: ```php public function handle(array $event): void { DB::transaction(function () use ($event) { // Daha önce işlendiyse sessizce çık (unique kısıt bunu garanti eder) $inserted = DB::table('processed_messages')->insertOrIgnore([ 'message_id' => $event['id'], 'processed_at' => now(), ]); if ($inserted === 0) { return; // tekrar gelmiş; hiçbir yan etki üretme } $this->applyBusinessEffect($event); // asıl iş — yalnızca bir kez }); } ``` `processed_messages.message_id` üstündeki **unique** kısıt işin kalbi: ikinci kez gelen aynı `id`, [`insertOrIgnore`](https://laravel.com/docs/12.x/queries#insert-statements) ile sessizce eleniyor ve iş etkisi hiç çalışmıyor. İş etkisini de aynı transaction'a almak şart — yoksa "işledim ama damgalamadan çöktüm" boşluğu açılır ki tam kaçtığımız şey budur. ## Bu kalıbın derinliği sade.dev'de Outbox'ın çalışan bir günlük kaydından dayanıklı bir sisteme dönüşmesi, bu yazının kapsamı dışında bıraktığım birkaç karara bağlı: relay'i `polling` ile mi yoksa **CDC** (change data capture) ile mi besleyeceğiniz, `pending` satırlarını çoklu relay'de [`SELECT ... FOR UPDATE SKIP LOCKED`](https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html) ile nasıl çakışmasız dağıtacağınız, teslim sırasının (ordering) nereye kadar garanti edilebileceği, `processed_messages` tablosunun nasıl budanacağı ve exactly-once'ın neden çoğu zaman bir yanılsama olduğu. Bunları kod ve diyagramlarıyla ayrı topladım: > **[Transactional Outbox: Dual-Write Problemi, At-Least-Once ve İdempotent Tüketim →](https://sade.dev/tr/systems/transactional-outbox-dual-write-ve-idempotent-tuketim)** (sade.dev) Bu blogun şeridi uygulamalı kalıyor; örüntünün sistem-seviyesi tasarımını sade.dev'e bırakıyorum. ## Bir de GDPR: event'in içinden geçen kişisel veri Outbox yerine oturunca yeni bir soru çıktı: bu event'lerin payload'ında müşteri adı, vergi numarası, adres gibi kişisel veriler var ve bu payload artık **kalıcı** bir tabloda (`outbox`) duruyor, üstelik RabbitMQ broker'ından geçiyor. Yani veriyi yerinde bırakırsam, GDPR/KVKK kapsamındaki alanları şifresiz biçimde birden çok yere kopyalamış oluyorum. Bütün payload'ı şifrelemek istemedim — çünkü `topic`, `invoice_id` gibi alanları relay ve gözlemleyebilmek için açık görmem gerekiyordu. İhtiyacım olan şey **alan seviyesi** şifrelemeydi: yalnızca hassas alanlar şifreli, gerisi açık. Payload şemasında hassas alanları `x-gdpr-sensitive` ile işaretledim ve serileştirme anında yalnızca onları şifreledim: ```php // Şema, hangi alanların hassas olduğunu beyan eder $schema = [ 'invoice_id' => ['x-gdpr-sensitive' => false], 'customer_name'=> ['x-gdpr-sensitive' => true], 'tax_id' => ['x-gdpr-sensitive' => true], 'total' => ['x-gdpr-sensitive' => false], ]; $payload = collect($raw)->map(function ($value, $field) use ($schema) { return ($schema[$field]['x-gdpr-sensitive'] ?? false) ? Crypt::encryptString((string) $value) // yalnızca hassas alan şifreli : $value; })->all(); ``` `Crypt`, Laravel'in `APP_KEY` ile yaptığı şifreleme; tüketici tarafında `Crypt::decryptString` ile aynı anahtarla açılıyor. [Dokümanın ifadesiyle](https://laravel.com/docs/12.x/encryption) Laravel şifrelemeyi OpenSSL üzerinden AES-256 ve AES-128 ile yapıyor ve şifrelenmiş her değeri bir MAC (message authentication code) ile imzalıyor — yani şifrelendikten sonra değerin içeriği değiştirilemiyor. Bu ikinci kısım burada önemli: outbox satırına ya da broker'a erişebilen biri, şifreli `tax_id`'yi okuyamadığı gibi sessizce başka bir değerle de değiştiremiyor. Bunun pratikteki üç faydası şuydu: - **Outbox ve broker'da düz metin kişisel veri durmuyor.** Bir tabloyu ya da kuyruğu yanlışlıkla log'lasam bile `tax_id` şifreli kalıyor. - **Açık alanlar açık kalıyor.** `invoice_id` ile event'i takip edebiliyor, `total` ile metrik üretebiliyorum; şifreleme gözlemlenebilirliği öldürmüyor. - **Şema kendini belgeliyor.** `x-gdpr-sensitive` bayrağı, "hangi alan kişisel veri?" sorusunun cevabını koda gömüyor; sonradan bir denetimde tahmin etmek gerekmiyor. Anahtar yönetimi, rotasyon ve "şifreli alanda nasıl arama yaparsın" gibi sorular bu günlüğün sınırının ötesinde — onlar da sade.dev'in alanı. ## Günlüğe not ettiğim üç şey **Bir deseni çözmek bir sonrakini açar.** Outbox dual-write'ı kapattı ama yerine at-least-once tekrarlarını koydu; onu idempotent tüketim kapattı. Her garanti bir maliyetle geliyor ve "çözdüm" demeden önce *yerine ne koydum* diye sormak gerekiyor. Tek seferde biten bir şey değil, kararların zinciri. **Teslim garantisini değil, tüketiciyi tasarlayın.** Uzun süre "mesaj tam bir kez gelsin" diye uğraşmak istedim; oysa dağıtık sistemde ucuz ve dürüst garanti at-least-once. Asıl iş, [idempotency yazısında](/tr/blog/api-de-idempotency-ayni-istegi-guvenle-tekrar-etmek/) olduğu gibi, tekrarın *yan etkisiz* olmasını tasarlamak. Tüketici idempotentse, teslim garantisinin gevşekliği sorun olmuyor. **Uyumluluğu sonradan değil, payload'ı tasarlarken düşünün.** Kişisel veriyi en baştan `x-gdpr-sensitive` ile işaretleyince, şifreleme bir "ek özellik" değil serileştirmenin doğal bir adımı oldu. Bunu en sona bıraksaydım, çoktan birden çok yere kopyalanmış düz metin veriyi geri toplamaya çalışırdım — ki en pahalı iş odur. Sonuçta bu üç parça tek bir cümlede birleşiyor: **veriyi tek bir sisteme yaz, tekrarı tüketicide bastır, hassas alanı yola çıkmadan mühürle.** E-fatura entegrasyonunda numara çakışmasından çıkardığım dersle aynı kapıya çıktı — state'i dış dünyaya açılmadan tam zamanında ve doğru biçimde mühürlemek. --- ### Kaynaklar - [Chris Richardson: Pattern — Transactional outbox](https://microservices.io/patterns/data/transactional-outbox.html) - [Laravel 12.x: Encryption](https://laravel.com/docs/12.x/encryption) - [Laravel 12.x: Database — Insert Statements](https://laravel.com/docs/12.x/queries#insert-statements) - [MySQL 8.4 Reference Manual: Locking Reads](https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html) ## archlint: mimari sınırları CI'da deterministik olarak zorlamak - Kaynak: https://muhammetsafak.com/tr/blog/archlint-mimari-sinirlari-ci-da-zorlamak/ [Mimari kararların neden çürüdüğünü](https://sade.dev/tr/journal/mimari-kararlar-curur) ayrı yazdım: bir ADR "domain altyapıyı import etmesin" der, iki yıl sonra bir bakarsın yüzlerce yerde import etmiş — çünkü kuralı izleyen hiçbir şey yoktu. Bu yazı o sorunun araç tarafı: küçük, bağımsız bir Go CLI — **`archlint`**. ## architecture.json: kuralı koda yaz Katmanları ve aralarındaki izinli bağımlılıkları tek bir dosyada beyan ediyorsun: ```json { "module": "github.com/acme/app", "layers": { "domain": ["internal/domain"], "db": ["internal/db"], "http": ["internal/http"] }, "rules": { "domain": [], "db": ["domain"], "http": ["domain", "db"] } } ``` `domain` içeride hiçbir şey import edemez, `db` yalnızca `domain`'i, `http` ikisini de. Başka her iç kenar bir ihlal. Aynı-katman import'u her zaman serbest; `[]` "hiçbir katmanı import edemez" demek. ## Nasıl çalışıyor `archlint check`, her Go, TypeScript ve Python dosyasının import'larını çıkarıyor. Go import'ları **stdlib'in `go/parser`'ıyla** okunuyor — regex tahmini değil, derlemeye gerek yok, kesin. Her dosyayı ve her import'u (modül yolunu sıyırıp) bir katmana çözüyor; kuralı çiğneyen kenarı dosya:satır ile raporluyor: ```text $ archlint check examples/sample Scanned examples/sample against examples/sample/architecture.json — 2 layer(s). 1 boundary violation(s): internal/domain/bad.go:6 domain → db is not allowed (import "github.com/acme/app/internal/db") ``` İhlal varsa exit 1 → CI kırmızı. Çiğneyen import `main`'e ulaşmadan yakalanıyor; iki yıl sonra bir arkeoloji seansında değil. ## Tasarım kararları - **Deterministik, modelsiz.** Bütün amaç CI'da kapı kurabileceğin bir guardrail: aynı diff, aynı verdict, döngüde model yok. Bir LLM'e "mimariyi gözden geçir" demekten farkı bu. - **Sıfır bağımlılık** (Go stdlib). Config şimdilik JSON — YAML bir bağımlılık ekliyor, o yüzden follow-up. - **Go kesin, TypeScript ve Python en iyi çaba.** `go/parser` Go import'larını kesin veriyor. TypeScript/JavaScript ve Python import'ları, standart biçimleri kapsayan regex tarayıcılarıyla çıkarılıyor; yani tam birer parser değiller. ## CI'da Buradan sonrası, [GitHub Actions ile kurduğum akışların](/tr/blog/biri-github-actions-mi-dedi/) alışılmış iki satırı; `archlint`'in tek beklentisi, ihlalde sıfırdan farklı bir kod dönerek job'ı düşürebilmek. ```yaml - run: go install github.com/muhammetsafak/archlint/cmd/archlint@latest - run: archlint check ``` Ya da paketlenmiş [GitHub Action](https://github.com/muhammetsafak/archlint) ile: ```yaml - uses: muhammetsafak/archlint@v0.3.0 ``` ## Sınırlar — dürüst liste - **TS/JS ve Python için regex tarayıcıları.** Go import'ları `go/parser` ile okunuyor (kesin). TS/JS ve Python import'ları regex tarayıcılarından geliyor; bu yüzden yorum ya da string literal içindeki import benzeri bir metin yanlış pozitife yol açabilir. - **Statik import grafiği.** `check` katmanlar arası bağımlılık grafiğini yönetir; `archlint metrics` (v0.3.0'da eklendi) aynı grafik üzerinde bağımlılık (coupling), bounded context ve Conway sinyalleri ekler. "Senkron olması gereken yerde async çağrı" ya da domain sınırını aşan doğrudan bir DB sorgusu, runtime trace'leri ilişkilendirmeyi gerektirir — sonraki faz. - **JSON config** (YAML follow-up). Deterministik tasarım kasıtlı: gate'lenebilir olsun diye. ## Denemek için - [github.com/muhammetsafak/archlint](https://github.com/muhammetsafak/archlint) `architecture.json`'unu yaz, `archlint check` çalıştır — ya da `archlint check examples/sample` ile kasıtlı bir ihlalin yakalandığını gör. Hangi sınırı bir sonraki sürümde görmek istediğini yazarsan, sıraya koyarım. ## Tek mimari kuralı, üç dil: archlint artık Go + TypeScript + Python - Kaynak: https://muhammetsafak.com/tr/blog/archlint-tek-kural-uc-dil/ [archlint'i ilk yazdığımda](/tr/blog/archlint-mimari-sinirlari-ci-da-zorlamak/) sadece Go biliyordu. Ama tipik bir sistem tek dil değil: Go API, TypeScript frontend, Python worker'ları. Üçünde de aynı mimari niyet var — "domain katmanı altyapıyı import etmesin" — ama o kuralı üç dilde birden zorlayan tek bir araç yoktu. Şimdi archlint üçünü de yönetiyor, **tek bir `architecture.json` ile**. ## Sınır dile bağlı değil; sözdizimi bağlı Katman düzeyinde kural her dilde aynıdır: `domain → db` yasak. Diller arasında değişen şey kuralın kendisi değil, **import'un nasıl yazıldığı ve hangi dosyaya çözüldüğü**: - **Go:** mutlak paket yolu — `github.com/acme/app/internal/db`. Çözüm: modül-prefix'ini sıyır → `internal/db`. - **TypeScript:** göreli (`../db/repo`) ya da alias (`@/db/repo`). Çözüm: göreliyi dosyanın konumuna, alias'ı `architecture.json`'daki `aliases` eşlemesine göre çöz. - **Python:** noktalı + göreli — `from ..db.repo import find_order`. Çözüm: göreli noktaları dosyanın paketine göre yukarı çık, sonra modülü yola çevir. archlint hepsini **tek bir şeye** indirger: repo-göreli bir yol → bir katman. O noktadan sonra motor aynı: "bu dosya hangi katmanda, bu import hangi katmana gidiyor, kural izin veriyor mu?" ## Tek config, karışık repo İşin güzel yanı: aynı dosya üç dilli bir monorepo'yu birden yönetebiliyor. Katman adları ortak, yollar dile özel: ```json { "module": "github.com/acme/app", "aliases": { "@/": "web/src/" }, "layers": { "domain": ["internal/domain", "web/src/domain", "workers/app/domain"], "infra": ["internal/infra", "web/src/infra", "workers/app/infra"] }, "rules": { "domain": [], "infra": ["domain"] } } ``` `domain` katmanı üç serviste de var (Go `internal/`, TS `web/src/`, Python `workers/app/`) ve hiçbirinde altyapıyı import edemez. Tek kural, üç dil, tek CI kapısı. ## Nasıl çalışıyor: dil-eklenebilir mimari İçeride bir `Language` arayüzü var: her dil yalnızca iki şeyi bilir — bir dosyanın import'larını **çıkarmak** ve bir import'u **repo-göreli yola çözmek**. Linter'ın geri kalanı dil-agnostik. Yeni bir dil eklemek = yeni bir `Language` implementasyonu. Disiplin aynen korundu: tree-sitter (CGo, ağır bağımlılık) yerine, Go'yu stdlib `go/parser` ile (kesin), TS ve Python'ı **regex tarayıcılarla** okuyorum. Dürüst sınır: bunlar tam parser değil — yorum ya da string içindeki import-benzeri bir satır false-positive olabilir. README'de açıkça yazıyor. ## Denemek için: üç örnek Her biri kasıtlı bir ihlal içeriyor: ```sh archlint check examples/sample # Go → internal/domain/bad.go archlint check examples/ts-sample # TS → src/domain/bad.ts ("@/" alias çözülür) archlint check examples/py-sample # Python → app/domain/bad.py ("..db.repo" çözülür) ``` Üçü de aynı çıktıyı verir: `domain → db is not allowed`, exit 1, CI kırmızı. --- Bunun neden bir wiki sayfasından daha iyi olduğunu [sade.dev'de ayrı yazdım](https://sade.dev/tr/journal/mimari-kararlar-curur): yazılı bir mimari karar, onu her commit'te kontrol eden bir şey olmadan çürür. Şimdi o "şey" üç dilde birden çalışıyor. Sıradaki sınır hangi dil olsun istersen — yaz, sıraya koyayım. - [github.com/muhammetsafak/archlint](https://github.com/muhammetsafak/archlint) ## CommitBrief'e flaky-test dedektörü: AI'ın yazdığı kararsız testleri commit'te durdurmak - Kaynak: https://muhammetsafak.com/tr/blog/commitbrief-flaky-test-dedektoru/ CommitBrief bugüne kadar tek bir şey yapıyordu: kendi diff'imi, kimse görmeden, bir LLM'e inceletmek. [İlk yazıda](/tr/blog/ai-ile-self-review-neyi-hizlandiriyor-nerede-yetmiyor/) niye işe yaradığını, [summary ve remote yazısında](/tr/blog/commitbrief-summary-ve-remote/) ekibe nasıl taşındığını anlatmıştım. Hepsinin ortak noktası: bulguyu **model üretiyordu.** Bu yazıdaki ekleme o noktayı bilerek kırıyor. CommitBrief artık modeli çağırmadan önce, değişen test dosyalarını **deterministik bir statik süzgeçten** geçiriyor — kararsızlığa (flaky) en sık yol açan kalıpları yakalamak için. Niye bu ekleme LLM'e bir madde daha eklemekle yapılmadı; asıl yazı o. > Kararsızlığın *neden* AI çağında patladığını ve neden retry/quarantine'in bir koşu bandı olduğunu sade.dev'de ayrı yazdım: [Kararsız Testler: Retry Çözüm Değil](https://sade.dev/tr/journal/kararsiz-testler-ve-determinizm). Bu yazı o tezin araç tarafı. ## Kolay yol LLM'di — onu seçmedim CommitBrief baştan sona LLM-güdümlü bir araç. En kolay yol belliydi: prompt'a "test dosyalarında sabit sleep, kırılgan selector, seed'siz random ararsan işaretle" diye bir rubrik daha eklemek. Bir akşamlık iş. Sorun, bu çözümün **olasılıksal** olması. Modele bağlı, çalıştırmadan çalıştırmaya kayabilir ve — daha önemlisi — *farklılaşmıyor*: herhangi bir LLM review aracı aynı prompt'la aynı şeyi yapabilir. Oysa kararsızlığın asıl can sıkıcı tarafı, çözümün **deterministik** olmasını istemen: aynı diff her zaman aynı sonucu versin, model olmadan da çalışsın, commit aşamasında bir saniyede dönsün. O yüzden statik yolu seçtim. Yeni bir paket (`internal/flaky`), değişen test dosyalarının **yalnızca eklenen satırlarını** tarıyor, bilinen anti-pattern'leri muhafazakâr regex'lerle yakalıyor ve standart bir bulgu üretiyor. Aynı diff → aynı bulgu. Model yok, network yok. ## Ne yakalıyor İlk sürümde iki yüksek-isabetli kural var. İsabet, recall'dan önce geliyor: commit aşamasındaki gürültülü bir kapı yok sayılır, yok sayılan kapı da hiç olmamasından kötüdür. - **Sabit bekleme / sleep** (`medium`): `time.Sleep`, `Thread.sleep`, `Task.Delay`, `asyncio.sleep`, `*.waitForTimeout`, sayısal `cy.wait(2000)`, `usleep`, `sleep()` — Go, Java/Kotlin, C#, Python, JS/TS, Cypress, PHP. - **Seed'siz rastgelelik** (`low`): `Math.random`, Python `random.*`, Go `math/rand` globalleri (`rand.Intn`, …). Test dosyasını yoldan tanıyor (`*_test.go`, `*.test.*`/`*.spec.*`, `test_*.py`, `*Test.java`, `*_spec.rb`, `__tests__`/`e2e`/`cypress` dizinleri…). Bulgu metni en/tr yerelleşiyor. Çıktı, modelin bulgularıyla aynı kart: ```text ● medium worker/job_test.go:41 Test senkronizasyon için sabit bir bekleme (sleep) kullanıyor Sabit beklemeler testi zamana bağımlı kılar: hızlı makinede geçer, CI yükü altında patlar; süite olan güveni aşındıran flaky sonuçlar üretir. → Sabit gecikmeyi koşula dayalı bir beklemeyle değiştir… ``` ## Modelin bulgularıyla nasıl birleşiyor Statik bulgular, filtreden sonra hesaplanıp LLM bulgularıyla **merge** ediliyor — render, `--fail-on` ve `--copy`'den önce. İki kural: - **Her LLM bulgusu korunur.** Statik süzgeç hiçbir şeyi bastırmıyor. - **Aynı `file:line`'da çakışma olursa** statik bulgu eklenmiyor — modelin o satır için zaten söyleyecek sözü varsa, ikinci kez söylemiyoruz. Güzel bir yan etki: model bozuk JSON döndürüp degrade olsa bile statik bulgular hayatta kalıyor. Deterministik kısım, olasılıksal kısma bağımlı değil. Ve **JSON şeması v1 değişmedi.** Statik bulgular mevcut `render.Finding` şeklini kullanıyor; yeni alan yok, yeni exit-code yüzeyi yok. `--fail-on=medium` artık sabit bir sleep'te de kapıyı kapatıyor — ekstra bir flag öğrenmene gerek kalmadan. ## Kullanımı API/mock sağlayıcılarda varsayılan **açık**. Kapatmak istersen: ```bash commitbrief # flaky pre-pass açık (varsayılan) commitbrief --no-flaky # bu çalıştırmada atla commitbrief config set review.flaky false # kalıcı kapat ``` Öncelik `--no-flaky` > `review.flaky` > yerleşik (`true`). CLI-tabanlı (claude-cli/gemini-cli) sağlayıcılar düz metin ürettiği için bu yolda şimdilik devre dışı — onu ayrı bir adımda ele alacağım. ## "Yerini almak" değil, yine "önünden gitmek" Bu ekleme de serinin aynı cümlesinin etrafında: *üretim ucuzladıysa, üretileni denetlemek pahalı kalmamalı.* AI test yazmayı bedavalaştırdı; o testlerin bariz kararsızlığını yakalamak da bedava olmalı — bir model çağrısı, bir CI dakikası harcamadan, commit'in olduğu yerde. Burada model bir lükse dönüşüyor, zorunluluğa değil: bariz olanı deterministik kısım topluyor, modelin dikkatini gerçekten muhakeme isteyen şeylere saklıyorum. ## Sınırlar — yine dürüst liste - **İsabet-öncelikli.** Bariz anti-pattern'leri yakalar; kendi kodunun içine gömülü bir race condition'ı değil. Akıllı kararsızlığı kaçırır ve tahmin etmesine izin vermiyorum. - **CLI sağlayıcı yolu kapsam dışı.** Düz metin üreten sağlayıcılarda merge henüz yok; takip adımı. - **Statik, AST değil (henüz).** Regex tabanlı; kapsamı/semantiği görmüyor. Brittle selector, over-mock ve zaman-bağımlı assertion kuralları sıradaki additive eklemeler. - **Tespit emniyet ağı, çözüm değil.** Asıl çözüm testi en baştan deterministik yazmak — onu [sade.dev yazısında](https://sade.dev/tr/journal/kararsiz-testler-ve-determinizm) anlattım. ## Denemek için - [github.com/CommitBrief/commitbrief](https://github.com/CommitBrief/commitbrief) - [commitbrief.com](https://commitbrief.com) Bir test dosyasına sabit bir `sleep` koyup `commitbrief`'i çalıştırın — modelden önce, deterministik olarak yakalandığını görün. Hangi anti-pattern'i bir sonraki sürümde görmek istediğinizi yazarsanız, sıraya koyarım. ## CommitBrief'e summary ve remote: değişikliğin brifini almak, PR'ı terminalden incelemek - Kaynak: https://muhammetsafak.com/tr/blog/commitbrief-summary-ve-remote/ [İlk yazıda](/tr/blog/kendi-diffimi-bir-tool-a-soruyorum-commitbrief/) CommitBrief'i niye yazdığımı, [ikincisinde](/tr/blog/ai-ile-self-review-neyi-hizlandiriyor-nerede-yetmiyor/) üç ayın sonunda neyi hızlandırıp nerede yetmediğini anlatmıştım. İkisinin de birimi aynıydı: **kendi diff'im**, henüz kimse görmeden. Soru hep "bu değişiklik bir soruna yol açıyor mu" idi. Son haftalarda araca eklediğim iki komut bu birimi genişletiyor. Biri sorunun *yönünü* değiştiriyor — "soruna yol açıyor mu" değil, "ne değişti ve neden": `commitbrief summary`. Diğeri ise aynı incelemeyi benim terminalimden çıkarıp takımın PR akışına taşıyor: `commitbrief remote pr`. Bu yazı bu iki komutu, niye eklediğimi ve ikisini de aynı çizgide tutan ilkeyi anlatıyor: **bu araç insan reviewer'ın yerini almıyor; önünden gidip bariz olanı topluyor.** ## summary — diff değil, değişikliğin hikâyesi Bir review değişikliği *eleştirir*. Bir summary değişikliği *anlatır*. İkisi farklı işler. `commitbrief summary`, bir değişiklik kümesini sade bir dille özetler: ne değiştiğini ve — commit mesajları belli ediyorsa — *neden* değiştiğini, dosya dosya değil **mantıksal alanlara göre** gruplayarak. Tamamen salt-okunur; severity üretmez, JSON üretmez, CI'da kapı kuracak bir bulgu üretmez. Sağlayıcının döndürdüğü düz metni olduğu gibi basar. Asıl yeni olan şey şu: kapsam bir aralık olduğunda, summary o aralıktaki **commit mesajlarını da okuyor** (`git log`, salt-okunur). Yani artık değişikliğin sadece son hâline değil, *geçmişine* de bakıyor. Her satırı, o alandan sorumlu kısa commit hash'iyle ilişkilendiriyor: ```text $ commitbrief summary main...develop --lang tr Fatura Servisi: Ücret hesabındaki yuvarlama hatası giderildi. (a1b2c3d) Auth: Token yenileme akışı eklendi. (d4e5f6a) DB: invoices tablosuna index eklendi. (f7a8b9c) ``` Parantez içindeki etiket bir branch adı değil, **commit hash'i** — çünkü squash ya da rebase ile birleşmiş bir geçmiş branch adını korumaz, hash'i korur. Staged/unstaged değişikliklerin henüz commit'i olmadığı için onlar atıfsız çıkar. Kapsam, review'la birebir aynı yüzeyi kullanıyor: argümansız çağırınca staged diff, `--unstaged` ile çalışma ağacı, ya da `diff` komutundaki gibi serbest bir `git diff` aralığı: ```bash commitbrief summary # staged commitbrief summary HEAD~3 HEAD # son üç commit commitbrief summary main...develop # PR tarzı aralık commitbrief summary HEAD~3 HEAD -o NOTES.md # release notu olarak dosyaya ``` `--lang tr` özeti Türkçe verir; `-o`/`--output` dosyaya yazar. Pre-send guard, secret tarama, maliyet preflight ve cache — hepsi review'daki gibi devrede. Prose ürettiği için bulgu odaklı flag'leri (`--json`, `--markdown`, `--suggest-commit`, `--fail-on`) sessizce yutmaz, açık bir mesajla reddeder. Ve git'e **asla** yazmaz. İki yerde işime yarıyor: - **Bireysel.** Bir hafta önce bıraktığım bir branch'e geri döndüğümde, diff'i baştan okumak yerine `commitbrief summary main...feature/x` ile beş satırlık bir hatırlatma alıyorum. Kendi geçmişime mesafe almak da self-review körlüğünün bir başka yüzü. - **Ekip içi.** Bir takım arkadaşının branch'ini inceleyeceğim zaman, satır satır diff'e girmeden önce *neyin nerede değiştiğinin* haritasını çıkarıyorum. Review'a kör girmiyorum; nereye bakacağımı bilerek giriyorum. Bir de yan fayda: `HEAD~3 HEAD -o NOTES.md` ile bir sürümün release notunu, commit mesajlarına dayanarak, birkaç saniyede taslak hâline getiriyorum. ## remote pr — ikinci göz, takımın PR akışında `commitbrief remote pr ` bir GitHub pull request'ini inceler ve sonucu GitHub'a geri yazar: her bulgu satır içi (inline) bir review yorumu olur, review da bir verdict ile gönderilir. Kendi HTTPS çağrısını yapmaz; yereldeki `gh` CLI'ını sürer — yani `gh`'ın auth'u, host çözümü, fork yönetimi olduğu gibi yeniden kullanılır. Önemli ayrım: bu **kullanıcının sürdüğü bir araç**, kuruluma asılı kalan bir bot değil. ```bash commitbrief remote pr 42 # mevcut repodaki PR #42 commitbrief remote pr CommitBrief/web#10 # başka repo commitbrief remote pr 42 --no-post # GitHub'a yazmadan, yerelde ``` Burada en çok üstünde durduğum tasarım kararı şu: **varsayılan verdict asla `request-changes` değil.** Bulgu yoksa `approve`, info dışında bulgu varsa `comment` — ama değişiklik isteyip PR'ı bloklamak, ancak `--request-changes-on=` ile *bilinçli olarak* açtığınızda olur. Yani araç kutudan çıktığı hâliyle bir bekçi gibi davranmıyor; satır içi yorumları bırakıp çekiliyor. Bu kasıtlı. Araç PR'a önce giriyor, gözle kolay kaçan bariz şeyleri ilgili satırın altına yorum olarak koyuyor — sonra insan reviewer geliyor. İnsanın dikkatini "kolay bulgular" yerine "zor bulgular"a odaklamak istiyorum; onun yerine karar vermesini değil. Birkaç inceliği var: - **Yorumlar doğru tarafa düşer.** Eklenen ve bağlam satırları için `RIGHT` (yeni dosya), silinen satırlar için `LEFT` (eski dosya). Silinmiş bir koda dair bulgu, silinen satırın üstüne oturur, reddedilmez. - **Diff'in dışına düşen bulgu kaybolmaz.** Satırı diff'te yoksa ya da GitHub POST'u reddederse, o bulgu review özetine "belirli bir satıra iliştirilemeyen bulgular" başlığı altında eklenir. - **`--no-post` yerel triyaj içindir.** PR'ın diff'ini salt inceleme kaynağı olarak kullanır, GitHub'a hiçbir şey yazmaz; çıktıyı tıpkı yerel review gibi terminale basar. Burada `--cli` ile API key'siz sağlayıcı, `--fail-on` ile exit-code kapısı, hatta kendi PR'ınızı inceleme bile serbesttir. - **Gönderilen metin İngilizcedir.** PR'ları karışık dilli ekipler okur; sadece yereldeki ilerleme/hata satırları TR/EN yerelleşir. - **Yarış güvenli.** Review sürerken PR'ın başı kayarsa bir kez yeniden dener; tekrar kayarsa review göndermeden durur. Posting modunda yalnızca API sağlayıcılar çalışır — `claude-cli`/`gemini-cli` gibi CLI sağlayıcıları yapısal bulgu üretmediği için reddedilir (`--no-post` altında bu kısıt kalkar). ## "Yerini almak" değil, "önünden gitmek" İki komut da ilk yazıda yazdığım cümlenin etrafında dönüyor: *üretim ucuzladıysa, üretileni denetlemek pahalı kalmamalı.* [Daha önce de anlattığım gibi](/tr/blog/yapay-zeka-caginda-kod-yazmanin-degisen-el-becerisi/) bu araçların değer ürettiği yer "AI yazsın" değil; yazılana muhakeme uygulamanın maliyetini düşürmek. - **summary** anlamanın maliyetini düşürüyor. Bir değişikliği — kendiminkini ya da başkasının — kavramak için harcanan ilk on dakikayı beş satıra indiriyor. - **remote pr** ilk geçişin maliyetini düşürüyor. Bariz olanı insan reviewer'a ulaşmadan toplayıp, pahalı olan insan dikkatini gerçekten pahalı sorulara saklıyor. İkisi de aynı şeyi *yapmıyor*: yargıyı yerine koymuyor. Verdict'i varsayılan olarak yumuşak tuttum, summary'yi bilerek yargısız bıraktım. Çünkü bu araç bir reviewer değil; reviewer'ın önünü temizleyen bir katman. ## Sınırlar — yine dürüst liste - **summary anlatır, yargılamaz.** Intent'i ya da mimariyi sorgulamaz; "doğru özelliği mi yapıyorsun" diyemez. Squash/rebase ile dümdüz olmuş bir geçmişte commit atıfı kabalaşır — temiz iki uçlu bir aralık dışında atıf zaten verilmez. - **remote pr posting modunda API'ya bağımlı.** Aboneliğinizi yeniden kullanan CLI sağlayıcıları ancak `--no-post` altında çalışır. - **Hâlâ aynı çekirdek sınır.** CommitBrief lint'in yerine değil, **lint + test'in üstüne, insan review'un altına** bir katman. summary ve remote bu katmanı genişletiyor; sınırını kaldırmıyor. İlk iki yazının çıkarımı değişmedi, sadece yüzeyi büyüdü: AI'ın değer ürettiği nokta yargıyı kalibre etmek; yargıyı yerine koymak değil. summary kalibrasyonu kişiselden geçmişe, remote ise solodan ekibe taşıyor — kararı hâlâ insan veriyor. ## Denemek için - [github.com/CommitBrief/commitbrief](https://github.com/CommitBrief/commitbrief) - [commitbrief.com](https://commitbrief.com) - Portfolyo sayfası: [/portfolyo/commitbrief](/tr/portfolyo/commitbrief/) `COMMITBRIEF.md`'inizi hâlâ merak ediyorum — başka projelerin "iyi kod"u nasıl tanımladığını görmek bu işin en öğretici tarafı. summary'yi bir branch'inize, `remote pr --no-post`'u bir PR'ınıza deneyip ne çıktığını yazarsanız, sevinirim. ## Vibe coding mi, AI destekli geliştirme mi — ve neden hâlâ bir senior'a ihtiyaç var - Kaynak: https://muhammetsafak.com/tr/blog/vibe-coding-mi-ai-destekli-gelistirme-mi/ Son aylarda aynı cümleyi farklı ağızlardan çok duydum: *"Artık yazılımcıya ihtiyaç kalmadı, ben kendim çıkardım."* Genellikle ardından bir ekran görüntüsü geliyor — çalışan bir arayüz, deploy olmuş bir site, hatta ilk kullanıcılar. İlk bakışta itiraz edilecek bir şey yokmuş gibi. Bir şey gerçekten çalışıyorsa, çalışıyordur. Ama "çalışıyor" ile "doğru çalışıyor" arasındaki farkı on yedi yıldır görüyorum, ve bu fark bir demoda görünmüyor. İlk gerçek yükte, ilk gerçek saldırganda, ilk şema değişikliğinde görünüyor. Bu yazı vibe coding'e karşı bir yazı değil — hobiye, denemeye, öğrenmeye hiçbir itirazım yok. Bu yazı **kimin elinde** sorusuna dair. Çünkü aynı AI agent, üç farklı insanın elinde üç farklı şey üretiyor. ## Vibe coding gerçekte neyi kolaylaştırdı? Hakkını teslim edeyim: vibe coding bir ilk sürüm üretmenin maliyetini gerçekten düşürdü. Fikirden çalışan bir prototipe geçiş hiç bu kadar kısa olmamıştı. Bir formu, bir REST uç noktasını, bir CRUD ekranını tarif edip dakikalar içinde ayağa kaldırmak artık sıradan. Bu küçümsenecek bir şey değil; on yıl önce bir hafta süren işti. Düşürdüğü maliyet bu: **üretim.** Düşürmediği maliyet ise yanlış olmanın maliyeti — ve asıl mesele orada başlıyor. ## Üç seviye, üç farklı sonuç Aynı aracın elde ettiği sonucu belirleyen şey, yazma hızı değil; **sorulan sorunun ve yapılan denetimin kalitesi.** Üç tipik seviyeyi ayıralım. ### 1. Hiç ya da çok az yazılım bilen biri — saf vibe coding Burada akış şöyle: tarif et, üretilen kodu çalıştır, çalışıyorsa devam et. Sorun, bu döngüde *eksik olanın ne olduğunu görecek göz olmaması.* Üretilen kod kendinden emin bir tonla yanlış olabilir; onu okuyup kenar durumlarını sınamak bir birikim ister, o birikim de henüz yok. Pratikte ortaya çıkanlar hep aynı aileden: bir başkasının verisine erişilebilen bir uç nokta, dolu bir tabloda patlayan bir migration, log'a düşen bir parola, büyüyünce çöken bir sorgu. Bunlardan ilki adı konmuş bir sınıf: OWASP Top 10:2021, A01 Broken Access Control altında "[Permitting viewing or editing someone else's account, by providing its unique identifier (insecure direct object references)](https://owasp.org/Top10/2021/A01_2021-Broken_Access_Control/)" maddesini sayıyor. Hiçbiri demoda görünmez. Hepsi production'da, çoğu zaman gece, görünür. Bu hata sınıflarının neden tam da "standart çözüm"ün içinde saklandığını [sade.dev'deki yazıda](https://sade.dev/tr/journal/vibe-coding-ile-production) mimari tarafıyla ayrıca ele aldım. Çünkü araç en güvenli göründüğü yerde en tehlikeli: [daha önce yazdığım gibi](/tr/blog/yapay-zeka-caginda-kod-yazmanin-degisen-el-becerisi/) yaygın kalıpları sağlam yazıyor, ama problemin kendine özgü boyutunu görmeden "standart" bir çözüm üretiyor — ve standart çözüm, sizi sormadığınız bir problemin cevabıyla baş başa bırakıyor. ### 2. Az-orta seviye yazılım bilgisi + AI agent Bu seviyede tablo belirgin biçimde düzeliyor. Bazı soruları doğru çerçeveleyebiliyor, üretilen kodun bir kısmını eleştirel okuyabiliyor, bariz bir hatayı yakalayabiliyor. Tek tek özellikler genelde iyi çıkıyor. Tavan, **sistemin sınırlarında** geliyor. Bu parça yük altında nerede kırılır, kırılınca ne olur, bu soyutlama doğru soyutlama mı, bu veri modeli altı ay sonra hangi değişikliği kaldırır — bunlar tek bir dosyaya bakarak cevaplanmıyor. Sonuç çoğu zaman şu oluyor: iyi yazılmış özellikler, kırılgan bir sistem. Parçalar sağlam, ama aralarındaki boşluklarda biriken borç görünmüyor. ### 3. İyi seviye yazılım ve sistem mimarisi bilgisi + AI agent Burada agent bir ikame değil, bir **çarpan** oluyor. Bir senior'ın yerine geçmiyor; uzantısı oluyor. Fark, kodun kim tarafından yazıldığında değil; kimin tarafından *yönetildiğinde.* Kendi akışımda bu somut olarak şuna benziyor: agent'a önce plan çıkartıyorum, planı okuyup düzeltiyorum, sonra adım adım yazdırıyorum; çıktıyı bir junior'ın kodu gibi gözden geçiriyorum; üretime giden her satırı gerekirse savunabileceğimden emin olana kadar merge etmiyorum. Bu disiplinin tam hâlini [sade.dev'de yedi madde olarak yazmıştım](https://sade.dev/tr/journal/ai-assisted-engineering-workflow). Sonuç: hız artıyor ama kod kalitesi düşmüyor — çünkü muhakeme hâlâ insanda. Bu seviyede AI gerçekten geliştirme sürecini hızlandırıyor, hem de ciddi biçimde. İddia "AI işe yaramıyor" değil; tam tersi. İddia şu: **işe yaraması, onu yöneten birikime bağlı.** ## "Para kazanınca yazılımcı tutar düzeltiriz" neden absürt? En sık duyduğum savunma bu: *önce çıkaralım, tutarsa bir yazılımcıya düzelttiririz.* Kulağa makul geliyor, çünkü çoğu işi sonradan düzeltebilirsiniz. Ama düzeltilemeyecek kadar temelde olan birkaç şey var: veri modeli, güvenlik duruşu, testlerin yokluğu. Bunlar "sonra eklenecek özellik" değil; üstüne her şeyin oturduğu zemin. Yanlış zemini sonradan düzeltmek bir tamir değil, bir **yeniden inşa.** Üstelik o noktaya geldiğinizde artık boş bir tabloyu değil, gerçek kullanıcı verisini taşıyorsunuzdur — en pahalı anda, en riskli göçü yapmak zorunda kalırsınız. Para kazandıran ürün, aynı zamanda elinizi kolunuzu bağlayan üründür. Yani senior sonradan değil, **önceden** ucuz. Doğru veri modelini en baştan kurmak bir öğleden sonra; yanlışını canlı sistemde düzeltmek bir çeyrek. Birikimi ürünün başına koymak, en ucuz sigortadır. ## Hedef: yapay zekayı yöneten gerçek bir senior Toparlayayım. Yapay zeka ajanlarıyla proje geliştirmek mümkün, hatta hızlı — bunu inkâr eden bir yazı değil bu. Hobi projesi mi yapacaksınız, deneyecek, öğrenecek misiniz? Sonuna kadar. Orada vibe coding mükemmel bir hızlandırıcı. Ama gerçek bir kullanıcının, gerçek bir verinin, gerçek bir paranın söz konusu olduğu bir üründe denklem değişiyor. Orada hâlâ direksiyonu tutan birine ihtiyaç var: yapay zekayı yöneten, çıktısını denetleyen, parçayı bütüne yerleştiren gerçek bir senior. Araç güçlendi; sorumluluğu devralmadı. *Yapay zeka üretimi mümkün ve ucuz kıldı. Üretileni doğru kılmayı değil.* --- ### Kaynaklar - [OWASP Top 10:2021, A01:2021 Broken Access Control, erişim 2026-10-07](https://owasp.org/Top10/2021/A01_2021-Broken_Access_Control/) ## Modern CSS'i yeniden keşfetmek - Kaynak: https://muhammetsafak.com/tr/blog/modern-cssi-yeniden-kesfetmek/ CSS'i yıllar önce öğrendim ve uzun süre onu öğrendiğim haliyle bildiğimi sandım. Float ile düzen kurardık, dikey ortalamak küçük bir bilmeceydi, bir bileşenin stilini kapsayan koşulları çoğu zaman JavaScript'le ölçerdik. [Son birkaç yıl içinde CSS sessizce başka bir dile dönüştü](/tr/blog/tailwind-css-4-yeni-motor-ve-yapilandirma/) — ve ben bunu, eski alışkanlıklarımla yazmaya devam ettiğim için geç fark ettim. Bu yazı, modern CSS'in bana en çok zaman kazandıran üç eklemesini anlatıyor. ## Container query: bağlamı bileşenin kendisi belirler Yıllarca medya sorgularıyla çalıştık: "ekran şu genişlikteyse şunu yap." Ama bir bileşenin nasıl görüneceği çoğu zaman ekranın değil, **içine konduğu alanın** genişliğine bağlıdır. Aynı kart bileşeni hem geniş bir içerik alanında hem de dar bir kenar çubuğunda yaşayabilir. Container query tam bunu çözüyor: ```css .kart-listesi { container-type: inline-size; } @container (min-width: 400px) { .kart { grid-template-columns: auto 1fr; } } ``` Artık `.kart`, ekranın değil `.kart-listesi`'nin genişliğine göre yerleşiyor. Bu, gerçekten yeniden kullanılabilir bileşen yazmanın önündeki eski bir engeli kaldırdı. ## `:has()`: CSS'in bir "ebeveyn seçicisi" var Yıllarca CSS'in en çok özlenen özelliği "bir öğeyi içeriğine göre seçmek"ti. "İçinde görsel olan kartları farklı hizala" demek için JavaScript'e ya da fazladan bir sınıfa başvururduk. ```css .kart:has(img) { grid-template-rows: 200px 1fr; } .form-alani:has(input:invalid) { border-color: crimson; } ``` `:has()` yalnızca bir ebeveyn seçici değil; bir öğeyi herhangi bir alt durumuna göre seçmeyi mümkün kılıyor. Daha önce küçük bir JavaScript dinleyicisiyle yaptığım pek çok şey, artık tek satır CSS. ## Yerel nesting: önişlemciye olan bağımlılığın azalması [Sass'ı](/tr/blog/sass-ile-surdurulebilir-css-yazmak/) yıllarca neredeyse yalnızca iki şey için kullandım: değişkenler ve nesting. Değişkenleri CSS custom property'leri çoktan devraldı; nesting ise artık CSS'in kendisinde: ```css .kart { padding: 1rem; & .baslik { font-weight: 600; } &:hover { box-shadow: 0 4px 12px rgb(0 0 0 / 0.1); } } ``` Bu, küçük projelerde bir derleme adımını ve bir bağımlılığı tamamen ortadan kaldırabilir. Her bağımlılık bir bakım yüküdür; dilin kendisi o işi yapıyorsa, aradaki katmanı taşımanın gereği kalmıyor. ## Bu özellikleri kademeli kullanmak Bu üç özelliği kullanırken aklımda tuttuğum bir denge var: tarayıcı desteği. `:has()`, container query ve yerel nesting artık geniş ölçüde destekleniyor; ama her projenin kullanıcı kitlesi aynı değil. Bu yüzden bunları çoğu zaman *kademeli geliştirme* (progressive enhancement) mantığıyla kullanıyorum — temel deneyim bu özellikler olmadan da ayakta kalıyor, onlar üzerine yalnızca bir iyileştirme katmanı ekliyor. Somut bir örnek: container query desteklemeyen eski bir tarayıcıda kart bileşeni, makul bir tek sütunlu düzene düşüyor — kusursuz değil ama tamamen kullanılabilir. Kullanıcı bir şeyin bozuk olduğunu fark etmiyor; yalnızca biraz daha sade bir düzen görüyor. Yeni bir CSS özelliğini benimsemek, onu her yere serpiştirmek değildir; desteklenmediği yerde bile zarif biçimde geri çekildiğinden emin olmaktır. Bu disiplin sayesinde yeni özellikleri "ya hep ya hiç" kararına dönüştürmeden, çıktıkları gün kullanmaya başlayabiliyorum. ## Asıl ders Bu üç eklemenin ortak noktası şu: yıllarca JavaScript, önişlemci ya da fazladan işaretlemeyle çözdüğümüz şeyler artık platformun kendisinde. Buradaki asıl ders teknik değil: **bildiğinizi sandığınız bir aracı yeniden öğrenmek.** Bir teknolojiyi bir kez öğrenip "tamam, bunu biliyorum" diye rafa kaldırmak kolaydır; ama olgun araçlar bile sessizce gelişir. Yılda bir, çok iyi bildiğimi sandığım bir aracın sürüm notlarını baştan sona okumayı alışkanlık edindim. Çoğu zaman, yıllardır zahmetle yaptığım bir işin artık tek satıra indiğini orada öğreniyorum. Deneyimin bir tuzağı var: sizi, dünyanın siz öğrendiğiniz an donduğuna inandırır. ## Bir e-fatura entegrasyonunda numara çakışmasını çözmek - Kaynak: https://muhammetsafak.com/tr/blog/e-fatura-entegrasyonunda-numara-cakismasi/ Geçtiğimiz aylarda bir e-fatura entegrasyonunda, prodüksiyonda kısa ama can sıkıcı bir hata mesajıyla karşılaştım: resmi entegratör `Duplicate / Already Exists` döndürüyordu. Log'lara baktığımda tablo netti — iki ayrı worker, aynı saniyede aynı fatura numarasını üretmiş, ikisi de aynı numarayla göndermiş, biri reddedilmişti. O seride fatura kesimi tıkanmıştı ve arkadaki bütün işler bekliyordu. Bu hata aslında bir mimari göçün en sonunda çıktı. Aylar boyunca faturalandırmayı, kullanıcının butona basıp sonucu ekranda beklediği **senkron** bir yapıdan; farklı proje ve framework'lerin **ortak bir RabbitMQ kuyruğuna** yazdığı, **ayrı bir consumer/worker mikroservisinin** de kuyruğu sürekli dinleyip tükettiği **asenkron** bir yapıya taşımıştım. Göç tam istediğim gibi gitti — ölçeklenme ve dayanıklılık yerine oturdu. Bu numara çakışması ise o sürecin en sonunda, gözden kaçacak kadar küçük bir pürüz olarak ortaya çıktı: meğer senkron modelde kullanıcının beklemesi işi kazara sıraya sokuyormuş; consumer eşzamanlı çalışınca o sıra kalktı ve yıllardır orada olan ama hiç görünmeyen bir race condition yüzeye vurdu. Bu yazı o günün ve sonrasındaki kararların günlüğü. Derin mimari tarafını ayrı yazdım (aşağıda bağ var); burada *"gerçek bir projede bu nasıl bir his ve hangi tuzaklara bastım"* tarafını anlatmak istiyorum. ## Sorun aslında iki sorundu İlk bakışta klasik bir **race condition**'dı. Numara gönderim anında `SELECT MAX(invoice_no) + 1` ile üretiliyordu. Tek worker'la yıllarca sorunsuz çalışan bu yöntem, paralel worker'lar aynı seriyi aynı anda işlemeye başlayınca çöküyor: ikisi de `MAX()`'tan aynı değeri okuyor, ikisi de aynı numarayla dış API'ye gidiyor. İlk refleksim çoğu geliştiriciyle aynı oldu: "numarayı en başta, mesajı kuyruğa atarken üretelim, çakışma biter." Üretirken fark ettim ki bu, race'i çözerken **daha kötü** bir problem doğuruyor. Çünkü e-faturada numara yalnızca mükerrersiz değil, aynı zamanda **boşluksuz** olmak zorunda — yasal seride atlanan numara olamaz. Numarayı baştan üretirsem, kuyrukta bekleyen bir fatura entegratörde hata alıp düştüğünde ya da kullanıcı iptal ettiğinde, seride hesabı verilemeyen bir boşluk (gap) kalıyor. Yani elimde iki ucu keskin bir bıçak vardı: **numarayı geç üretirsem race, erken üretirsem gap.** Mesele bir yöntemi diğeriyle değiştirmek değil, ikisini birden tutmaktı. ## Çözümün özü: numarayı tam zamanında rezerve etmek Çıkış noktası şu oldu: numarayı ne çok erken ne çok geç, **dış API çağrısından tam bir an önce** üret ve kalıcılaştır. Faturaya bir `reserved_no` kolonu ekledim; numara bir kez rezerve edilince, dış çağrı başarısız olsa bile o numara silinmiyor, faturanın üstünde asılı kalıyor ve aynı numarayla yeniden deneniyor. Race tarafını ise merkezi, atomik bir sayaç hallediyor. Kilidi yalnızca rezervasyon için tutup hemen commit etmek — yani dış çağrıyı transaction'ın **dışında** bırakmak — performansın da kilidiydi. Bunu yanlış yapıp dış çağrıyı kilidin içinde tutsaydım, sistem bir bottleneck'e dönüşürdü. Bu kalıbın derinliğine — kilit penceresi, erken commit, hibrit tetikleme, dual-write problemi ve orphan recovery — bu günlükte girmiyorum; o tarafı kod ve diyagramlarıyla ayrı bir yazıda topladım: > **[Ardışık Numara Üretiminde Race Condition ve Gap: JIT Rezervasyon →](https://sade.dev/tr/systems/ardisik-numara-uretiminde-race-condition-ve-gap)** (sade.dev) > **Tan:** Numarayı önceden mühürlemek yeni bir soru doğurur: mühürlenmiş ama hiç gitmemiş bir faturayı kim, hangi kayıtla kapatacak? Bu bir kod sorusu olmadan önce bir iş kuralı sorusu. Onu tasarımda sormak, ilk yarım kalan faturadan sonra sormaktan ucuza gelir. ## Günlüğe not ettiğim üç şey Bu işten kalan ve bir sonraki benzer probleme taşıyacağım çıkarımlar, koddan çok karar tarafında: **Yasal bir kısıt, bir mimari kısıttır.** "Boşluksuz numara" başta bir muhasebe detayı gibi görünüyordu; oysa sistemin tüm tasarımını belirleyen asıl kuvvet oydu. Race condition'ı tek başına çözen onlarca yöntem var — ama "gap de olmayacak" kısıtını ekleyince çözüm kümesi bir anda daralıyor. Bir gereksinimi "iş kuralı" diye kenara almadan önce, mimariyi nasıl kısıtladığını sormak gerekiyor. **Dış dünyaya bağlı her adım başarısız olabilir.** Entegratör çağrısı başarılı olup yerel yazma anında network çökmesi gibi senaryolar "olmaz" değil, "ne zaman" meselesi. Tasarımı *"istek tekrar gelebilir, çağrı yarıda kalabilir"* varsayımı üzerine kurmak, daha önce [API'de idempotency üzerine yazarken](/tr/blog/api-de-idempotency-ayni-istegi-guvenle-tekrar-etmek/) anlattığım aynı disiplinin başka bir yüzü. Numarayı çağrıdan önce mühürlemek, sonradan "bu fatura gerçekten gitti mi?" diye sormayı mümkün kılan şeydi. **Önce ölç, sonra varsay.** "Numara çakışıyor, demek ki kilitleme lazım" kestirmesi yarı doğruydu. Asıl soru kilidin *nerede* ve *ne kadar* tutulacağıydı; bunu log ve davranışı ölçmeden tasarlasaydım, çalışan ama tıkanan bir sistem kurardım. Sonuçta sıralı, boşluksuz numara üretmek bir kilit sorunundan çok bir **zamanlama** sorunu çıktı: state'i dış dünyadan tam bir an önce mühürlemek. Geri kalan her şey o tek kararın etrafına dizildi. ## AI ile self-review — neyi hızlandırıyor, nerede yetmiyor - Kaynak: https://muhammetsafak.com/tr/blog/ai-ile-self-review-neyi-hizlandiriyor-nerede-yetmiyor/ [Önceki yazıda](/tr/blog/kendi-diffimi-bir-tool-a-soruyorum-commitbrief/) CommitBrief'i niye yazdığımı anlatmıştım: kendi diff'ime mesafe alamamak, mevcut AI araçlarının diff seviyesinde düşünmemesi, terminalden çıkmadan ikinci bir göz ihtiyacı. Bu yazı *"niye"* değil *"ne kadar"* yazısı. Üç ayın sonunda workflow'a hangi katmanlardan girdiğini, hangi rakamlarla hız kazandırdığını, hangi tip hataları tekrar tekrar yakaladığını ve nerede yetmediğini somutlaştırmak istiyorum. Önce bir hatırlatma: bu araçların değer ürettiği yer "AI yazsın" değil "AI ikinci bir göz olsun" — [daha önce yazdığım gibi](/tr/blog/yapay-zeka-caginda-kod-yazmanin-degisen-el-becerisi/) üretim ucuzladıkça muhakeme pahalılaşıyor. Bir tool'un değeri o pahalı muhakemeye ne kadar iyi yardım ettiği. Aşağıdakiler benim ölçtüğüm kadarı. ## Dosya yerine değişikliği neden incelemeli? İlk hafta yanlış soyutlamayla başladım: bir dosyayı AI'a verip *"bunu review et"* demek. Çıktı uzun, jenerik ve büyük ölçüde alakasızdı. Çünkü bir reviewer'ın bakacağı şey dosya değil, **o dosyaya gelen değişiklik**. CommitBrief'in tasarım birimi diff olunca üç şey aynı anda iyileşti: token bütçesi 10× düştü, severity ataması anlamlı hale geldi (artık *"bu fonksiyon iyi yazılmış mı"* değil, *"bu değişiklik soruna yol açıyor mu"* sorusuna cevap aranıyor), false positive bandı küçüldü. Pratikte bunun anlamı: bir PR'ın *"yapay zeka tarafı"* dört-beş saniyeye sığabilir. Bu da onu pre-push refleksi haline getirebilecek kadar hızlı yapıyor. ## Üç entegrasyon katmanı CommitBrief'i workflow'a sokmanın üç katmanı var; her birinin farklı bir hız profili. | Katman | Komut | Hız profili | Tipik kullanım | |---|---|---|---| | **Manuel** | `commitbrief` | 4-5 sn, $0.02-0.04 | Push'tan önce kendi gözünün üstüne bir göz | | **Pre-push hook** | `commitbrief install-hook --hook=pre-push --fail-on=critical` | Push'u kritik bulguda iptal eder | Solo geliştirici / küçük takım | | **CI step** | `commitbrief --fail-on=high` | PR pipeline'ında soft/hard gate | Takım PR'larında ortak çıta | Üçü birden değil, **birini seç ve aklında tut.** Hepsini aynı anda kurmak workflow disiplinini değil, bildirim gürültüsünü artırır. Ben solo akışta manuel komut + pre-push hook kullanıyorum; CI step'i takım projelerinde tutuyorum. Pre-push hook'un bir inceliği var: önce kurduğum sürüm gerçekte `--staged` çalıştırıyordu — yani push anında index genelde boş olduğu için sessizce no-op'a düşüyor, push geçiyordu. *"CI gate kurduğunu sanıp aslında hiçbir şey kurmamak"* — kendi audit'imde bu hatayı yakaladım, v1.0'da `pre-push` artık git'in stdin protokolünü okuyup `commitbrief diff ..` çalıştırıyor. Tool'un kendi sınırlarına çarptığı yerlerde dürüst olmak gerekiyor. Referans için [Git 2.54.0 githooks: pre-push](https://git-scm.com/docs/githooks/2.54.0) şunu söylüyor: "Information about what is to be pushed is provided on the hook's standard input with lines of the form" ve "If this hook exits with a non-zero status, `git push` will abort without pushing anything". ## Rakamlar — benim akışım Üç senaryo, üç farklı hız profili. ### Senaryo 1 — Pre-push refleksi (solo) Tipik bir orta boy PR'ım: ~120 satır eklenmiş, 40 silinmiş, dört-beş dosya. Eski akışım: - `git diff --staged` ile gözden geçirme: **4-7 dk** - İki kez daha okuma (anchor bias'a karşı): **+3-4 dk** - Push, PR aç Yeni akışım: - `commitbrief --staged`: **4-5 sn** → çıktıyı oku: **30-60 sn** - Critical/High varsa düzelt + tekrar çalıştır (cache hit: **13 µs**, **$0**) - Bilinçli derin okuma (artık ne aradığımı biliyorum): **2-3 dk** - Push, PR aç Toplam: **9-10 dk → 3-5 dk.** %55 civarı bir hız, evet — ama asıl kazanç dakikalarda değil; *gerçek hatayı reviewer'a ulaşmadan yakalamak* bu işin en pahalı kısmı. ### Senaryo 2 — Takım PR round-trip Bir kritik bulgu reviewer'a ulaşmadan yakalandığında PR round-trip iki günden bir güne iniyor. Bu kasıtlı bir mütevazılık: aslında daha da kısalıyor ama ortalamayı bozan *"reviewer'ın yine de fark ettiği başka bir şey"* döngüsü her zaman var. **AI insan reviewer'ı tamamen bypass etmiyor; reviewer'ın gözünü "kolay bulgular" yerine "hard bulgular"a odaklıyor.** Net etki bu. ### Senaryo 3 — Cache hit ekonomisi CommitBrief'in cache key'i: `SHA256(diff + system_prompt + provider + model + lang + schema_version)`. Diff değişmediği sürece aynı çağrı 13 µs sürüyor ve $0 maliyet üretiyor. Pratikte: - Rebase sonrası tekrar review: bedava - Push öncesi *"son kez bir bakayım"*: bedava - Sağlayıcı değiştirip karşılaştırma: bedava değil ama yeni cache key'i, paralel ikinci görüş için cazip Aylık fatura matematiği: Anthropic Sonnet 4.6 ile ortalama $0.03/review × ayda ~50 PR × cache miss oranı %40 ≈ **$0.60/ay**. Ollama ile sıfır, sadece elektrik. Bir saatlik insan code review zamanının opportunity cost'unun yanında ihmal edilebilir. ## Yakaladığım pattern'ler Üç ayda CommitBrief'in (ve aynı pipeline'ı paylaşan sağlayıcıların) tekrar tekrar yakaladığı beş paterni saydım. Bir kısmı günlük kullanımımdan, bir kısmı CommitBrief'in v1.0 öncesi self-audit'inden — üç farklı sağlayıcıya kendi proje kurallarımla kendi projemi review ettirdim, ortak bulgu setlerini topladım. Her biri *"diff okumayla yakalanabilir, gözle yakalanması zor"* kategorisinde. **1. Error wrap duplikasyonu.** `%w` format specifier zaten wrapped error'u içerirken prefix'te aynı mesajı tekrarlamak. Log'da `"auth failed: auth failed: invalid token"` üretir. Statik analiz yakalamıyor çünkü teknik olarak hata değil; semantik bir tekrar. Compiler memnun, log'lar berbat. **2. NOT NULL kolon migration default'suz.** Boş tabloda sorunsuz, dolu prod tablosunda fail. CommitBrief'in tipik çıktısı: *"Migration fails on any table that already has rows; either backfill in a prior migration or add a DEFAULT before the constraint."* Bu bug staging'de görünmeyip prod'da görünen klasik aileden. **3. i18n catalog'da key var, kullanılmamış.** Çoklu dil destekleyen projelerde sık: yeni bir prompt için EN/TR key ekliyorsun ama kodda `cat.T(...)` yerine hardcoded string bırakıyorsun. EN'de fark edilmez (string aynı), TR'de fonksiyonel kırılma. Benim kendi projemde bu pattern audit'in en tutarlı bulgularından biriydi — `guard.prompt` ve `setup.welcome` key'leri YAML'de duruyordu, ama gerçek prompt'lar Go kodunda İngilizce literal'di. Üstüne çok ironik bir vaka: Türkçe `[e/H]:` (evet/Hayır) prompt'u render ediliyordu ama parser sadece `y`/`yes` kabul ediyordu — Türk kullanıcı `e` yazınca sessizce *"no"* olarak işleniyordu. *"i18n var"* sandığım yer aslında *"i18n yarım"* idi. **4. Config knob accepted-but-ignored.** Config schema'da var, `config set` ile kabul ediyor, dokümante etmişsin — ama kod path'inde okunmuyor. Kullanıcı dokümana inanıp ayarlıyor, etki sıfır. CommitBrief'in kendi cache config'inde bunu yaptım: `cache.enabled: false` set'lemek hiçbir şey yapmıyordu çünkü pipeline cache'i sadece `Dir` ve `RepoRoot` parametresiyle açıyordu. Bu pattern özellikle iki ayrı PR'da bölünür — "config tanımla" PR'ında ve "config kullan" PR'ında. AI ikincisinin eksikliğini fark ediyor; insan reviewer iki PR arasındaki zaman aralığında bağlamı kaybediyor. **5. Boş katmanlar arasında kaybolan ref.** Bir kapı kurduğunu sandığın bir mekanizmanın aslında no-op olması. CommitBrief'in `pre-push` hook'u bunu yapıyordu (yukarıda anlattım). Bu tip bug AI olmadan ya production'da ya da güvenlik incident'inde yakalanır; pattern'i bilen bir reviewer diff'te de görür. Beşinin ortak özelliği: hiçbiri *"kod yanlış derleniyor"* tipi değil. Hepsi **intent ile gerçeklik arasındaki boşluk**. AI'ın gerçekten katma değer ürettiği yer tam burası — sentaks doğrulamak zaten compiler'ın işi. ## Proje-spesifik kuralın önemi CommitBrief'i generic AI review araçlarından ayıran en somut nokta: `COMMITBRIEF.md` projenin kök dizininde durur ve system prompt olarak LLM'e gider. CommitBrief'i geliştirirken kullandığım `COMMITBRIEF.md`'den üç madde: - *"Yeni dependency MIT / Apache-2.0 / BSD / ISC / MPL-2.0 / LGPL-3.0+ olmalı; AGPL veya proprietary kabul edilmez."* - *"Tüm user-facing string'ler `cat.T(key, ...)` üzerinden gitmeli; hardcoded English / Turkish reddedilir."* - *"i18n catalog'a yeni key eklendiyse EN/TR parity zorunlu; tek tarafta key olamaz."* Bunlar generic *"iyi kod"* tavsiyeleri değil; bu projenin kendi politikası. Generic bir AI asistanı *"bu dependency MIT mi GPL mi"* sormaz; benim kuralıma göre review eden bir AI sorar. Bu fark, review'un kullanışlılığını sentaks-seviyesinden mimari-niyet-seviyesine taşıyor — ve aynı tek dosyayı paylaşan bir takımda *"hangi standartlar geçerli"* tartışmasını koddan önce gerçekleştirmiş oluyor. ## Sağlayıcı kararı + maliyet Dört sağlayıcının (Anthropic, OpenAI, Gemini, Ollama) artı iki CLI-tabanlı sağlayıcının (`claude-cli`, `gemini-cli`) tek bir abstraction üstünde yaşaması iki şeyi mümkün kılıyor: **karşılaştırma ucuz**, **lock-in yok**. | Sağlayıcı | Tipik review | Güçlü yan | Zayıf yan | |---|---|---|---| | Anthropic Sonnet 4.6 | $0.03 | En tutarlı muhakeme, ephemeral prompt cache (5dk) | En pahalı orta seviye | | Gemini 2.5 Pro | $0.01 | 1M context, cömert ücretsiz tier | Zaman zaman overly verbose | | OpenAI GPT-4o | $0.02 | Hızlı; ≥1024 token prefix'lerde otomatik cache | Sonnet'ten kısa açıklamalar | | Ollama (yerel) | $0 | Zero exfiltration, kendi donanımın | Donanım gerekli, modele bağlı kalite | | `claude-cli` | $0\* | Mevcut Claude Code subscription | Plain text — structured findings yok | | `gemini-cli` | $0\* | Mevcut Gemini CLI auth | Aynı | \* Mevcut abonelik var sayılırsa marjinal $0. 5 dakikalık süreye gelince: [Anthropic: Prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching) "By default, the cache has a 5-minute lifetime." diyor. Pratikte: günlük akışta Anthropic Sonnet kullanıyorum (tutarlılık önemli). Yüksek hassasiyet gereken PR'larda aynı diff'i Gemini Pro'ya da gönderip iki bulgu setini karşılaştırıyorum — cache mantığı sağlayıcı per-key olduğu için ikisi ayrı entry, ama maliyet hâlâ yuvarlanmış birkaç kuruş. Yerelde kalması gereken kod için Ollama. CLI sağlayıcılarını henüz günlük akışta kullanmıyorum çünkü plain text çıktı `--fail-on` severity gate'ini bypass ediyor; aboneliğin zaten ödeniyorsa hızlı bir "ikinci bakış" için faydalı, CI integration'ı için değil. ## Nerede yetmiyor Üç ay sonra hâlâ tool'un sınırlarına çarpıyorum. Dürüst liste: - **Mimari sorgulama yok.** *"Bu özelliği yapmalı mıyım"*, *"bu abstraction doğru mu"* sorularına cevabı yok. Tek bir diff'in sistemin bütünüyle nasıl oturduğunu görmüyor. - **Intent doğrulama yok.** *"Doğru özelliği mi yapıyorsun"* diyemiyor. Yanlış spec'in mükemmel implementasyonu yeşil çıkar. - **Cross-file consistency sınırlı.** Tek bir PR'daki dosyalar arası tutarlılık tamam, ama PR dışındaki kod tabanıyla kontrast kuramıyor (*"bu pattern projenin başka yerinde X şeklinde yapılıyor, burada Y kullanmışsın"* gibi). Repo geneli vektörleme yapmıyor; PRD'de bilinçli scope kararı. - **False positive bandı var.** Genelde `info` seviyesinde ve görmezden gelmek kolay, ama yine de var. `OUTPUT.md` template'iyle info bulgularını filtrelemek mümkün. - **Prompt injection riski.** Diff'in içindeki saldırgan içerik LLM'i yanıltabilir. CommitBrief'in mitigation'ı: `...` XML wrapper + immutability guard + her review öncesi secret scan. Yine de sıfır risk değil; security-sensitive diff'lere `--no-cache` + dikkatli okuma tavsiye ederim. Bu sınırların hiçbiri *"araç işe yaramıyor"* sonucuna götürmüyor — sadece **yerini doğru koymak** lazım. CommitBrief lint'in yerine değil, **lint + test'in üstüne, insan review'un altına bir katman.** ## Kapanış İlk yazıda söylediğim cümleyi tekrar etmek istiyorum: *üretim ucuzladıysa, üretileni denetlemek pahalı kalmamalı.* Üç ayın somut çıkarımı şu: **AI'ın değer ürettiği nokta yargıyı kalibre etmek; yargıyı yerine koymak değil.** Bir tool'un doğru tasarımı bu farkı reddetmek değil; kabul edip iki tarafı birbirine bağlamak. CommitBrief benim için bu bağlantıyı kuran şey oldu. Sizin için işe yararsa, `COMMITBRIEF.md`'inizi paylaşmanızı isterim — başka projelerin *"iyi kod"* u nasıl tanımladığını görmek, bu işin en öğretici tarafı. --- ### Kaynaklar - [Git 2.54.0: githooks, pre-push, erişim 2026-10-07](https://git-scm.com/docs/githooks/2.54.0) - [Anthropic: Prompt caching, erişim 2026-10-07](https://platform.claude.com/docs/en/build-with-claude/prompt-caching) - [github.com/CommitBrief/commitbrief](https://github.com/CommitBrief/commitbrief) - [commitbrief.com](https://commitbrief.com) - Portfolyo sayfası: [/portfolyo/commitbrief](/tr/portfolyo/commitbrief/) ## Hangi Projede Hangi Metodoloji? — 18 Yıllık Bir Geliştiricinin Notları - Kaynak: https://muhammetsafak.com/tr/blog/hangi-projede-hangi-metodoloji-karar-matrisi/ Bir keresinde, aynı toplantıda iki proje yöneticisinin kavga ettiğini gördüm. Biri *"Agile'a geçmeliyiz, bu Waterfall bizi yıkıyor"* diye direniyordu. Diğeri *"Agile bizi mahvedecek, müşterimiz sabit fiyat istiyor"* diye direniyordu. İkisi de haklıydı. İkisi de aynı dilden konuşmuyordu çünkü ikisi de **soruyu yanlış soruyordu**. Yazılım metodolojisi tartışmalarının %80'i bu yanlış soruyu cevaplamaya çalışıyor: *"Hangisi daha iyi?"* Doğru soru hiçbir zaman bu değil. Doğru soru şu: *"Hangi proje, hangi takım, hangi müşteri için hangisi?"* Bu yazıda doğru soruyu kuralım, cevaplayalım, ve metodoloji dininden — savunduğun yöntemi kimliğin sayma huyundan — çıkalım. ## Metodoloji hakkında hangi soru sorulmalı? Bir metodoloji üç değişkenin **kesişiminde** yaşar: 1. **Proje tipi** — gereksinimleri ne kadar net? Erken yanlış kararın maliyeti ne? 2. **Takım büyüklüğü** — 3 kişi mi, 30 kişi mi, 300 kişi mi? 3. **Müşteri/paydaş tipi** — sürekli geri bildirim verebilen biri mi, yoksa "ben şunu istiyorum, üç ay sonra teslim et" diyen biri mi? Üçü hakkında düşünmeden bir metodoloji seçmek, takım elbisesini ölçü almadan biçmek gibi. Sonra "bu metodoloji çalışmıyor" denir — ama çalışmayan metodoloji değil, yanlış kullanım. Bu üç değişkenden **üçüncüsü en sık atlanan** ve **çoğu zaman en belirleyici** olan. Bir sonraki bölümde göreceksin. ## Üç Metodolojinin Asıl Niyetleri Tanımları herkes biliyor, atlıyorum. Asıl niyetlerine bakalım — çünkü orada hata yapılıyor. ### Waterfall, Royce'un Eleştirisiyle Başladı Çok az insanın bildiği bir nokta: [Winston W. Royce'un 1970'teki orijinal makalesi](https://www.praxisframework.org/files/royce1970.pdf) Waterfall'ı **savunmak için değil, eleştirmek için** yazıldı. Royce bu modeli tarif ederken aslında *"böyle yaparsanız başarısız olursunuz, iteratif olmalı"* diyordu. Sonraki yıllarda makaleden ilk diyagram alındı, eleştirisi unutuldu, ve "Royce'un Waterfall'ı" diye yarım yüzyıl yanlış aktarıldı. Bu önemli çünkü saf Waterfall'ı **kimse savunmuyor**. Savunulan şey **"plan yapma disiplini"** — ki bu Agile karşıtı değil. **Asıl niyet:** Gereksinim belirsizliği **sıfıra yakın** olduğunda, planlamanın getirisi maksimumdur. Savunma, havacılık, finans regülasyonu, NASA'nın bir uydu yazılımı — bu projelerde "isterleri sprint sonunda gözden geçirelim" denmez. İstenenler önceden bellidir, sapma cezası ölümcüldür. ### Agile, Küçük Takım İçin Tasarlandı [Agile Manifesto](https://agilemanifesto.org/iso/tr/manifesto.html) 2001'de 17 kişi tarafından yazıldı. Hepsi küçük takım/işbirliği savunucusuydu. Manifesto'nun ruhu: **"Şu an bildiğimizden fazlasını bildiğimizi varsaymayalım, kısa döngülerde öğrenelim."** Dört değer ifadesi arasında "Sözleşme pazarlıklarından ziyade müşteri ile işbirliğine" ve "Bir plana bağlı kalmaktan ziyade değişime karşılık vermeye" yer alıyor. Sonra Agile **enterprise**'a satıldı. SAFe, LeSS, DAD gibi enterprise scaling framework'leri çıktı — Agile'ın orijinal niyetinin neredeyse tersi: katmanlı, dokümantasyon ağır, üst-yönetim odaklı yapılar. "Scaled Agile" deyimi, "scaled küçük takım disiplini" gibi bir oksimoron. **Asıl niyet:** Gereksinim belirsizliği **yüksek** ve takım **küçük** olduğunda, iteratif öğrenmenin getirisi maksimumdur. Startup MVP, ürün-pazar uyumu aranan SaaS, müşterinin "tam ne istediğini bilmediği" projeler. ### Hibrit — Gerçek Dünyanın Adı Saf Waterfall hiç yapılmaz, saf Agile da çok nadir. Gerçek dünyada çoğu projenin metodolojisi **WaterScrumFall** denen şey: üstte Waterfall (sözleşme, gereksinim dokümanı, sabit fiyat), altta Scrum (sprint, daily stand-up, retro). Müşteriye Waterfall gösteriliyor, takıma Agile yaşatılıyor. Bu **kötü değil** — sadece **dürüstçe adlandırılması gerekiyor**. "Biz Agile'ız" demek, gerçekte WaterScrumFall yapıyorsan kendini kandırmak. **Asıl niyet:** Belirsizlik ve kontrat ihtiyacı **karışık** olduğunda, hibrit gerçekçi tek seçenek. Tek koşulu var: **bunu açıkça kabul etmek**. ## Hangi proje türüne hangi metodoloji uyar? | Senaryo | Önerilen Yaklaşım | Neden | |---|---|---| | Bir devlet ihalesi, yıllık 50 sayfa şartname | **Waterfall** | İstenenler donmuş, sapma cezalı | | Yeni bir ürün için MVP, 3 kişilik takım, kurucunun "şunu test etmek istiyorum" yaklaşımı | **Agile (sade Scrum/Kanban)** | Gereksinim sürekli değişecek, küçük takım | | Bir agency, sabit fiyatlı müşteri projesi, 6 ay teslim | **Hibrit (üst kontrat + alt sprint)** | İki dünyanın gerçeği | | 100+ kişilik bir kurumsal ürün ekibi | **Trunk-based + feature flag** (technical) + **Lightweight Agile** (process) | Scaling problemi | | Mobile uygulama, App Store yayın takvimi | **Release-train (Agile + Waterfall'ın release planı)** | Yayın takvimi sabit, ama sprint içeriği esnek | | Bir kütüphane / SDK, sürümlü dağıtım | **Semantic versioning + Waterfall benzeri release** | API stabilitesi kritik | | Bir hackathon, 48 saat | **Hiçbiri** — gerek yok, sadece çalış | Metodoloji overhead'i değer üretmez | **Anti-örnek:** Bunları **karıştırmak**. "Hem develop branch'ımız var, hem sprint yapıyoruz, hem her özellik release branch'le çıkıyor, hem feature flag'ımız var" diyen takım, bütün disiplinlerin yarısını uygular, hiçbirinin avantajını alamaz. Bu konu özelinde [Git workflow seçimi yazımda](/tr/blog/production-git-senior-pratik-rehberi/#1-workflow-seçimi-gitflow-vs-github-flow-vs-trunk-based) daha somut ele aldım. ## Yaşadığım Üç Vaka ### Vaka 1: Sabit Fiyatlı Projede Agile Dayatması Bir agency'deydim. Müşteri sözleşmede "şu 28 özellik, şu tarihte teslim, şu fiyat" yazmıştı. Proje yöneticisi yeni almıştı SAFe sertifikasını, *"Agile yapacağız"* dedi. İlk 2 sprint güzel gitti. Üçüncü sprint review'da müşteri *"şunu da görmek isterdim"* dedi. Yöneticimiz "tamam, backlog'a ekleyelim" dedi. Birkaç sprint sonra backlog 40 özelliğe çıktı. Sözleşme hâlâ 28 diyordu, bütçe sabit, takvim sabit. Sonuç: ya kapsam taşması ile bütçe aşıldı (müşteri kabul etmedi), ya kalite çöktü (her şeyi yetiştirelim derken bug yığını), ya da takım yandı (akşam-hafta sonu çalışma). Üçü de oldu. **Ders:** Agile'ın çalışması için **scope'un değişebilir olması** gerekir. Sabit fiyat + sabit kapsam + sabit takvim = Agile'ın temel önermesinin çiğnenmesi. Bu projede doğru yaklaşım Hibrit'ti — sözleşmeyle Waterfall sınırı çizilip, içeride sprint'lerle çalışılırdı. Ama "Agile yapıyoruz" iddiası, hibrit gerçeğinin kabul edilmesine engel oldu. ### Vaka 2: Startup'ta Waterfall Denemesi Erken aşama bir startup. Kurucu *"6 aylık net bir plan yapalım, sonra başlayalım"* dedi. Önce gereksinim analizi (3 hafta), sonra tasarım (2 hafta), sonra geliştirme başladı. 5. ayda lansmana yaklaşırken kurucu *"aslında müşterilerle konuştum, asıl istedikleri X değil Y"* dedi. 5 aylık iş, kurucunun kendi düşüncesindeki bir ürün için yapılmıştı; gerçek müşterinin istediği ürün için değil. **Ders:** Gereksinim belirsizliği **müşteri tarafında** ise Waterfall imkânsızdır. Çünkü Waterfall'ın işlemesi için "doğru gereksinim önceden biliniyor" varsayımı şart. Startup'ta bu varsayım yapısal olarak yanlış. Kurucu kendi ürününü ne kadar bilse de, **kullanıcının ne istediğini bilmiyor** — onu ancak iteratif öğrenmeyle keşfeder. ### Vaka 3: Hibrit'in İşe Yaradığı Bir Proje Bir ihale projesi: müşteri devletti, sözleşme istiyordu, ama biz teknik tarafta gereksinimlerin **yarısının** net olmadığını biliyorduk. Önerimiz: ilk 6 hafta **discovery sprint'leri** — gereksinimleri netleştirmek, prototip göstermek, müşteriyle birlikte iyileştirmek. Bu sürenin sonunda **tam scope ve fiyat** belirledik, sözleşmeyi imzaladık, sonra Scrum'la inşaata geçtik. İki sözleşme imzaladık aslında: birincisi "discovery" için T&M (time & materials), ikincisi "build" için fixed scope. Müşteri her ikisinde de güvende hissetti çünkü ne için ne ödediğini biliyordu. **Ders:** Hibrit "kararsızlık" değil. Hibrit, projenin **bilinen** ve **bilinmeyen** kısımlarını ayrı disiplinlerle yönetme kararı. Doğru kurulduğunda iki dünyanın da en iyi yanlarını alabilirsin — ama ön koşulu "biz Agile/Waterfall yapıyoruz" diyerek karar vermemek; **dürüstçe karar haritası çıkarmak**. ## Hiçbir Metodoloji Saf Hâlinde Uygulanmıyor Bir gerçeği kabul edelim: 2026'da "biz tam saf Scrum yapıyoruz" diyen şirketin %90'ı aslında **daily stand-up** + **sprint planning** yapıyor, üzerine de PO/PM kararıyla scope değişiyor. "Tam saf Waterfall" da yok. En katı regülasyon altında çalışan finansal kurumlarda bile, geliştirme sürecinde küçük iterasyonlar var. Metodolojiler birer **prensip koleksiyonu**. Sen her prensibi tek tek alıp projene uygun olanı uygulamakla yükümlüsün. "Scrum'ı tam uygulamıyoruz çünkü daily stand-up yerine haftalık yapıyoruz" demek bir başarısızlık değil — bu **adaptasyon**. Adaptasyonu kabul etmek ve adlandırmak başarısızlık değil; adapt etmediğini iddia etmek başarısızlık. Senior bir geliştiricinin işi metodoloji uygulamak değil, **sorun çözmektir**. Metodoloji bir kutu alettir. Sen elindeki kutu aletine göre marangoz olamazsın, marangoz olarak kutudan istediğini alırsın. ## Kapanış: Metodoloji Bir Araçtır, Kimlik Değil Genç developerlarda sık görüyorum: "ben Agile'cıyım" der, "Waterfall pis bir şey" der. Tartışmalarda da metodoloji savunması yapar — savunduğu metodoloji bir takım rengi gibi. :) Eminim 15 yıl sonra aynı geliştirici, *"bu projeye Waterfall daha uygun"* veya *"bu takımda Scrum çalışmaz, Kanban deneyelim"* diyecek. Çünkü artık bir aracı savunmaya değil, **bir sorunu çözmeye** odaklanmıştır. Soru "hangisi daha iyi" değil. Soru: **karşımdaki proje, takım ve müşteri için, üç metodolojiden hangisinin prensipleri daha çok işe yarar?** Bu soruyu cevaplayabiliyorsan, hangi etiketi kullandığının önemi yok. Cevaplayamıyorsan, en sevdiğin metodolojiyi kullansan da çıkmaz sokaktasın. Metodoloji yöntemdir. Sen yöntemi seçen kişisin. Tersini iddia eden herkes, sana bir şey satmaya çalışıyordur. --- ### Kaynaklar - [Çevik Yazılım Geliştirme Manifestosu, erişim 2026-10-07](https://agilemanifesto.org/iso/tr/manifesto.html) - [Winston W. Royce, Managing the Development of Large Software Systems, 1970, erişim 2026-10-07](https://www.praxisframework.org/files/royce1970.pdf) ## Kendi diff'imi bir tool'a soruyorum — CommitBrief'i niye yazdım - Kaynak: https://muhammetsafak.com/tr/blog/kendi-diffimi-bir-tool-a-soruyorum-commitbrief/ Geçen ay bir öğleden sonra, açmak üzere olduğum bir PR'ı üçüncü kez yukarıdan aşağı okuyordum. Diff küçüktü — 80 satır eklenmiş, 14 silinmiş, dört dosya. Üçüncü okumada bile gözüm bir şeye takılmıyordu. PR'ı açtıktan iki saat sonra bir takım arkadaşı yorum bıraktı: *"Burada bir hata wrap'i tekrar etmiş, `%w` zaten var, üstüne metin de eklemişsin — log'da iki kez 'auth failed: auth failed:' yazıyor şu an."* Üç kez okuduğum diff'te, gözümün dosyaya değdiği yerde olan bir hata. Bu olayın küçük olmasının asıl gücü buydu. Dikkatsiz bir okuma değildi — dikkatli üç okumaydı. Kodu yazan kişi, kodu okuyamıyordu çünkü zaten okumuştu, zaten yazmıştı, zaten "biliyordu." ## Kendi kodumun en kötü okuyucusu benim Self-review'un bir tuhaflığı var: yazdığın kodu okurken zihnin, *yazdığın versiyonu* değil, *yazdığını sandığın versiyonu* görüyor. Anchor bias diyorlar — "şu satırı şu sebeple yazdım" anısı satırın gerçek halini gizliyor. Bu yüzden bir geliştirici tek başına kendi diff'ini incelediğinde, ne kadar deneyimli olursa olsun, dış bir göze göre tutarlı biçimde daha az hata yakalıyor. Pratik çözümler hep aynıydı: - **Beklemek.** Bir gün bekle, yarın temiz gözle bak. Çoğu zaman bekleyemiyorum; deploy bugün. - **Bir takım arkadaşına sormak.** En iyisi, ama her zaman müsait değil; üstelik küçük bir typo için birinin gününü bölmek doğru değil. - **IDE'deki AI asistanlarına sormak.** Copilot review, Cursor review, JetBrains AI — hepsi çalışıyor, ama hepsinin ortak sorunu şu: dosya düzeyinde düşünüyorlar, *değişiklik* düzeyinde değil. Proje kurallarımı bilmiyorlar. IDE'den IDE'ye davranış farklı. - **ChatGPT'ye copy-paste.** Çalışıyor, ama her seferinde diff'i panoya atmak, proje bağlamını tekrar yazmak, sonuçların hiçbiri cache'lenmiyor — friction yüksek. [Daha önce yazdığım gibi](/tr/blog/yapay-zeka-caginda-kod-yazmanin-degisen-el-becerisi/) bu araçların değer ürettiği yer artık "kod yazmak" değil; yazdığın koda muhakeme uygulamak. İhtiyacım olan şey *"AI yazsın"* değil, *"AI ikinci bir göz olsun"* idi — ve mevcut hiçbir tool bunu **terminalden çıkmadan, diff seviyesinde, benim kurallarımla** yapmıyordu. Bende **CommitBrief** yazdım. ## Beş saniyede ne yapıyor `commitbrief` yazdığım anda olan şey kısaca şu: staged diff'imi alıyor, üç katmanlı bir filtreden geçiriyor (built-in gürültü → `.commitbriefignore` → `COMMITBRIEF.md`'deki semantik kurallarım), seçtiğim LLM sağlayıcısına gönderiyor, dönen yanıtı sabit bir JSON şemasında işleyip terminale renkli kartlar olarak basıyor. Her bulgu için: severity (critical/high/medium/low/info), dosya, satır, başlık, açıklama, opsiyonel kod parçası. ```text $ commitbrief --staged commitbrief v1.0.0 · provider: anthropic/claude-sonnet-4-6 · cache: miss analyzing 3 files · 42 added · 11 removed ┌─ HIGH ─ internal/api/handler.go:201 ──────────────────────────┐ │ Wrapped error duplicated in message │ │ "%w" zaten var; prefix wrapped error'u tekrar ediyor, │ │ log'da "auth failed: auth failed: …" üretiyor. │ └───────────────────────────────────────────────────────────────┘ ✓ Done in 4.2s · 1 finding · 8,421 tokens · Cost: $0.0319 ``` Aynı diff'i tekrar çalıştırırsam dört saniye değil, **13 mikrosaniye** sürüyor — yerel cache devreye giriyor, footer'da `Saved: $0.0319` yazıyor. Diff değişmediği sürece bedava. Çok özet bir tasarım listesi: - **Sağlayıcı-bağımsız.** Anthropic, OpenAI, Gemini, Ollama (yerel). Üstüne `claude-cli` ve `gemini-cli` üzerinden mevcut Claude Code / Gemini CLI aboneliğini de kullandırabiliyor — ekstra API key yok. - **Yerel.** Diff de inceleme çıktısı da makinede kalıyor. Tek dış istek seçtiğin sağlayıcıya. - **Proje-spesifik kurallar.** `COMMITBRIEF.md` projenin kök dizininde durur, system prompt olarak gider. *"Bu projede magic number kabul etmiyoruz"*, *"context dışında error wrap'lemeyin"*, *"logger'ı doğrudan import etmeyin"* — kuralları sen yazıyorsun. - **Pre-send güvenlik.** Diff içinde credential-şekilli substring varsa, sağlayıcıya gitmeden uyarıyor. `--allow-secrets` zorunlu bypass. - **CI kapısı.** `commitbrief --fail-on=critical` critical bulgu varsa exit code 1 ile çıkıyor — pre-push hook ya da CI step olarak takılabiliyor. Tek dosyalık Go binary; `brew install CommitBrief/tap/commitbrief`, `scoop install commitbrief` ya da `go install github.com/CommitBrief/commitbrief/cmd/commitbrief@latest` ile geliyor. ## Asıl içgörü: doğru birim "dosya" değil, "değişiklik" İlk haftalarda dosya seviyesinde düşünüyordum: *"şu dosyayı AI'a okutayım."* Yanlış soyutlamaymış. Bir review'un gerçek birimi dosya değil, **değişiklik**. Bir reviewer üç yıldır o dosyaya bakmıyordu; bakacağı şey diff'in 12 satırı. AI'a da aynı şeyi göstermek gerek. Bu küçük perspektif değişikliği işin yarısını çözüyor: token bütçesi düşüyor, gürültü düşüyor, severity ataması anlamlı hale geliyor — çünkü artık *"bu fonksiyon iyi yazılmış mı"* değil, *"bu değişiklik soruna yol açıyor mu"* sorusuna cevap aranıyor. İkinci yarısı akış: aracın seninle aynı yerde — terminalde — olması, *"ben bir tool'a soruyorum"* eylemini bir saniyelik bir refleks haline getiriyor. Tarayıcı sekmesi açmak, copy-paste yapmak, sonra geri dönmek — bunların hepsi context switch maliyeti. Bir geliştiricinin gününde bu maliyet "kaç saniye sürdüğü" ile değil, "kaç kez sürdüğü" ile büyüyor. ## Sınırları CommitBrief mimari kararı sorgulamıyor — *"bu özelliği yapmalı mıyım"* diye sormuyor. Intent'i bilmiyor — *"doğru özelliği mi yapıyorsun"* diyemiyor. False positive üretebiliyor; benim akışımda bunlar genelde "info" seviyesinde ve görmezden gelmek kolay, ama yine de var. Bu toolun yerini doğru koymak gerekiyor: **lint'in yerine değil, lint + test'in üstüne bir katman.** *"İnsan reviewer benim PR'ımı görmeden önce, kendi gözümün üstüne bir göz"* — orada faydalı, orada bitiyor. ## Denemek için - Site: [commitbrief.com](https://commitbrief.com) - Kaynak: [github.com/CommitBrief/commitbrief](https://github.com/CommitBrief/commitbrief) - Portfolyo sayfası: [/portfolyo/commitbrief](/tr/portfolyo/commitbrief/) Süreci hangi sayılarla hızlandırdığı, üç ayda hangi bulgu paternlerinin tekrar ettiği, nereye kadar güvendiğim ve nerede dikkatli olduğum — bunları takip eden bir yazıda ayrıca anlatacağım. Şimdilik benim için kazanım net: *üretim ucuzladıysa, üretileni denetlemek pahalı kalmamalı.* ## PHP Öldü mü? 2026 Tablosu, Veriyle - Kaynak: https://muhammetsafak.com/tr/blog/php-oldu-mu-2026-tablosu-veriyle/ 18 yıldır PHP yazıyorum. Bu süre içinde "PHP öldü" cümlesini en az 50 kez okudum — 2008'de de söyleniyordu, 2014'te de, 2020'de de. Cümle hep aynı, kanıt hiç gelmiyor. Bir noktada savunmaya geçmeyi bıraktım. Çünkü fark ettim ki verisiz tartışmak tamamen laf kalabalığı yapmaktan ibaret. Bu yazı tam olarak o veriyi gösterecek. "PHP öldü" ya da "PHP ölmedi" demeyeceğim. Sadece verileri koyacağım. Karar okuyucunun. ## "Ölü dil" Ne Demektir? — Önce Ölçüt Tartışmaya girmeden önce ne ölçtüğümüzü tanımlayalım. Bir dil ne zaman ölü sayılır? Bence altı sinyal var: 1. **Kullanım payı azalıyor** — gerçek dünyada çalışan sistemler bu dili bırakıyor mu? 2. **Developer popülerliği düşüyor** — yeni geliştiriciler giriyor mu, mevcutlar bırakıyor mu? 3. **Ekosistem aktivitesi durmuş** — paket/kütüphane üretimi devam ediyor mu? 4. **Dil kendisi gelişmiyor** — yeni sürüm geliyor mu, kaç yılda bir? 5. **İş pazarı azalıyor** — ilan sayısı düşüyor mu? 6. **Yeni başlayanlar öğreniyor mu** — junior havuzu kuruyor mu? Şimdi her birini PHP için tek tek ölçelim. ## 1. Kullanım Payı — W3Techs [W3Techs'in Mayıs 2026 verisine göre](https://w3techs.com/technologies/details/pl-php), **PHP, sunucu tarafı dili bilinen tüm web sitelerinin %71.2'sinde kullanılıyor.** Bu rakam yıllardır sınırlı bir bantta seyrediyor — küçük dalgalanmalarla %75'lerden %71'lere indi, ama hâlâ "ikinci en yaygın server-side dil"in on katından fazla kullanıma sahip. Sürüm dağılımı şöyle: | PHP Sürümü | Kullanım | |---|---| | 8.x | %59.9 | | 7.x | %31.6 | | 5.x | %8.4 | | 4.x | %0.1 | %60'a yakın site PHP 8.x üzerinde — yani [**modern PHP**](/tr/blog/2026da-php-ile-uygulama-gelistirmek-ekosistemin-hali/) üzerinde. %31'i hâlâ 7.x'te (buna birazdan döneceğiz; bu bir uyarı sinyali). Ama "ekosistem 5.x'ten ileri gitmedi" iddiası verilerle hiçbir şekilde uyuşmuyor. Karşılaştırma: aynı listede Ruby %6.7, JavaScript %6.2, Java %5.4, ASP.NET %4.4 paya sahip. PHP'nin payı azalsa bile rakipleriyle arasındaki uçurum çok büyük. ## 2. Developer Popülerliği — Stack Overflow & JetBrains [Stack Overflow'un 2025 Developer Survey'i](https://survey.stackoverflow.co/2025/technology), 49.000'den fazla geliştirici ile yapıldı. PHP, "en çok kullanılan dil" listesinde: - **Tüm katılımcılarda:** %18.9 (12. sıra) - **Profesyonel geliştiricilerde:** %19.1 (13. sıra) Bu rakam **Go (%16.4) ve Rust (%14.8)'tan daha yüksek** — yani PHP, geliştiricilerin "trend dilleri" diye konuştuğu Go ve Rust'ın üzerinde kullanım payına sahip. Üstüne, [JetBrains'in 2025 State of PHP raporuna göre](https://blog.jetbrains.com/phpstorm/2025/10/state-of-php-2025/) ankete katılan **1.720 aktif PHP geliştiricisinin %89'u PHP 8.x kullanıyor**, %88'inin 3+ yıl deneyimi var. Yine de bir nüansı atlayalım: yeni başlayanlar (Stack Overflow "learning to code" segmenti) PHP'yi %14.5 ile kullanıyor, 15. sırada. Yani yeni başlayanlar PHP'ye Python ve JavaScript kadar yoğun girmiyor — bu ileride junior pipeline'ı için bir uyarı sinyali, ama "ölü" düzeyinde değil. ## 3. Ekosistem Aktivitesi — Packagist [Packagist'in canlı istatistik sayfası](https://packagist.org/statistics) şu rakamları gösteriyor (Mayıs 2026): - **Toplam paket sayısı:** 453.911 - **Toplam sürüm sayısı:** 5.578.059 - **2012'den beri toplam indirme:** 181.729.296.774 (181 milyar) Nisan 2012'den beri 181 milyar paket indirilmesi, ortalamada her gün **on milyonlarca** paket indirmesi demek. Bu rakam **ölü** olabilecek bir ekosistemin değil, **endüstri standardı** bir ekosistemin rakamı. Karşılaştırma için: Perl'ün CPAN'ında ~220.000 modül var (PHP ekosisteminin yarısı), Ruby'nin RubyGems'inde ~180.000 gem (yine PHP'nin yarısından az). ## 4. Dil Kendi Kendini Geliştiriyor mu? — PHP Sürüm Takvimi PHP, son altı yıldır **her yıl yeni bir sürüm** çıkarıyor (8.2 Aralık'ta, diğerleri Kasım'da çıktı): | Sürüm | Yayın Tarihi | EOL | |---|---|---| | PHP 8.0 | Kasım 2020 | Kasım 2023 | | PHP 8.1 | Kasım 2021 | Aralık 2025 | | PHP 8.2 | Aralık 2022 | Aralık 2026 | | PHP 8.3 | Kasım 2023 | Aralık 2027 | | PHP 8.4 | Kasım 2024 | Aralık 2028 | | PHP 8.5 | Kasım 2025 | Aralık 2029 | Yıllık takvim, her sürümde **somut yeni özellikler**: 8.0'da JIT, union types, match; 8.1'de enum, readonly; 8.2'de readonly class, DNF types; 8.3'te typed class constants; 8.4'te asymmetric visibility, property hooks, lazy objects; 8.5'te pipe operatörü, clone-with. Bu, bir dil için sağlıklı kabul edilen gelişme hızının çok üzerinde. ("Ölü" sayılan bir dilde sürüm aralığı 3-5 yıla yayılır ve özellikler işlev görmez.) ## 5. İş Pazarı Bu kategori en zor ölçülen — küresel resmi bir veri seti yok. Ama gözleme dayalı sinyaller net: - LinkedIn'de "PHP" veya "Laravel" araması Türkiye'de aylık 500+ aktif ilan döndürüyor - WordPress (PHP üzerinde çalışan tek motor) hâlâ web'in **%42.2'sini** taşıyor — yani sadece bu ekosistemin sürdürülmesi için bile devasa bir işgücüne ihtiyaç var - Laravel ve Symfony çevresinde freelance/agency pazarı global olarak büyüyor — JetBrains raporunda PHP geliştiricilerinin %12'si bağımsız, %56'sı küçük ekiplerde çalışıyor Sayısal kesinlik veremem, ama "iş ilanı azalıyor" iddiasının somut bir veri kaynağı da yok. Aksine, mevcut sistemlerin bakımı ve genişletilmesi tek başına PHP geliştirici talebini yıllarca canlı tutacağa benziyor. ## 6. Yeni Başlayanlar Giriyor mu? JetBrains raporundan: **6 aydan az PHP deneyimi olanlar 2024'te ankette %2 iken 2025'te %4'e çıktı.** Çift katına çıkma. Junior havuz, en azından duraksamadı. Stack Overflow tarafında PHP, "learning to code" segmentinde 15. sırada — yani **giriş seviyesi tercihi olarak güçlü değil**. Bu, gerçek bir zayıflık. Yeni geliştiriciler PHP yerine genelde Python veya JavaScript ile başlıyor; PHP'ye sonradan, çoğunlukla iş gereği geçiyorlar. Pipeline yavaş ama akmaya devam ediyor. --- ## Altı Kriterin Özeti | Sinyal | Durum | |---|---| | Kullanım payı | ✅ Lider (%71.2 web siteleri) | | Developer popülerliği | ✅ Top 13 (%18.9 — Go ve Rust üstünde) | | Ekosistem aktivitesi | ✅ 453K paket, 181 milyar indirme | | Dil gelişimi | ✅ Her yıl yeni sürüm | | İş pazarı | ✅ Güçlü (özellikle Laravel + WordPress ekosistemi) | | Yeni başlayanlar | ⚠️ Yavaş ama akıyor (%4, eski yıla göre 2 kat) | Beş güçlü sinyal, bir uyarı. Bu tablo "ölü dil" tablosu değil — "olgun, baskın, biraz yorgun ama hâlâ büyüyen dil" tablosu. ## Peki Gerçekten Ölmekte Olan Diller Neye Benziyor? Karşılaştırma için iki vakaya bakalım. ### ColdFusion [endoflife.date'e göre](https://endoflife.date/coldfusion): ColdFusion 2021, Aralık 2025'te core support'tan çıktı (Adobe'nin kendi duyurusu 10 Kasım 2025 diyor). ColdFusion 2025 ise abonelik modeline geçti — yani perpetual lisans bitti. Aktif geliştirici topluluğu var, paket ekosistemi mevcut, ama: - TIOBE'da görünmüyor (hatta ilk 50'de yok) - Yeni framework çıkmıyor - İş ilanları çoğunlukla mevcut sistemlerin "geçirilmesi" üzerine ColdFusion **fiilen ölü değil**, ama "yeni başlayan biri öğrensin" denemeyecek hâlde. PHP'nin tablosu bunun çok uzağında. ### Perl ve TIOBE Tuhaflığı Burada bir parantez açmak gerek. PHP'nin "ölmesi" tartışmasında en sık kullanılan kaynak [TIOBE Index](https://www.tiobe.com/tiobe-index/). Mayıs 2026'da TIOBE, PHP'yi 14. sıraya koyuyor. Aynı listede **Perl 13. sırada**. Bu kayda değer — çünkü [Perl'ün TIOBE'daki sıçraması](https://developers.slashdot.org/story/25/09/14/0134239/is-perl-the-worlds-10th-most-popular-programming-language) bir yılda **17 basamak**: 27'den 10'a çıktı. TIOBE CEO'su Paul Jansen sıçramayı esas olarak kitap sayısına bağladı: Perl'ün listelenmiş kitap sayısı PHP'nin **4 katı**. Yani TIOBE bir **kullanım** metriği değil; Amazon'un da aralarında olduğu **arama motorlarındaki isabet sayılarını** sayıyor, kitap listeleri de böyle hesaba giriyor. Methodoloji, ekosistemdeki gerçek kullanımı ölçmüyor. TIOBE'u "PHP öldü" kanıtı olarak getiren biri, aynı metrikle Perl'ün PHP'den daha popüler olduğunu da kanıtlamış olur. Mantığı tutarlı tutmak gerekirse: ya iki iddia da doğrudur, ya da TIOBE bu soruyu cevaplamak için yanlış araçtır. W3Techs ve JetBrains gibi gerçek kullanımı ölçen kaynaklar PHP'nin baskın olduğunu söylüyor. TIOBE 14 diyor. İkisi farklı şeyi ölçüyor — biri kod üreten geliştiriciyi, diğeri kitap basan yayıncıyı. ## Dürüst Olalım: PHP'nin Gerçek Sorunları Buraya kadar veriler PHP lehine sayılabilir. Ama burada veriye dayalı bir analiz yapmaya çalışıyoruz. PHP'nin 2026'da **gerçekten zayıf** olduğu yerler: **1. Async hikâyesi hâlâ ikincil.** ReactPHP, Swoole, FrankenPHP gibi projeler async PHP'yi mümkün kıldı — ama bunlar dilin doğal akışı değil, üstüne geçirilen katmanlar. Aynı görevi Node.js veya Go ile yazmak çok daha doğal. Yüksek concurrency gerektiren bir sistem yazıyorsan, PHP kesinlikle **ilk tercih değil**. **2. Tip sistemi olgunlaşıyor ama generics yok.** PHP 8.x ile tip desteği büyük adımlarla iyileşti — union types, intersection types, never type, asymmetric visibility. Ama jenerik tipler hâlâ yok. PHPStan ile docblock üzerinden simüle ediliyor; dilin kendi sentaksında yok. TypeScript veya Rust'tan gelen biri için bu büyük bir eksiklik. **3. Topluluğun büyük bir kısmı 7.x'te takılı.** W3Techs'in %31.6'sı hâlâ PHP 7.x. PHP 7.4 [Kasım 2022'de](https://www.php.net/supported-versions.php) destekten çıktı — yani **üç yıldan uzun süredir güvenlik güncellemesi almayan** bir sürüm üzerinde web'in milyonlarca sitesi çalışıyor. Bu PHP'nin "öldüğünü" göstermiyor; ama PHP topluluğunun **neredeyse üçte birinin modernleşmeyi reddettiğini** gösteriyor. Bu durum, "PHP eski bir dildir" algısını canlı tutuyor. **4. Bazı domain'ler için doğru seçim değil.** Real-time messaging, high-frequency trading, machine learning, mobile backend — bu alanlarda Go, Java, Rust veya Python doğal seçim. PHP bu alanlarda da yazılabilir ama dilin kuvvetli olduğu yer değil. CRUD-heavy web uygulamaları, içerik yönetimi, e-ticaret — burada rahatlıkla hâlâ rakipsiz. "Her şey için her dil" yanılgısına düşmemek gerekiyor. Bunları gönül rahatlığı ile söylüyorum, çünkü buradaki amacım bir dili savunmak değil. Gerçek veriye dayanarak hem güçlü hem zayıf yanlarını gösterebilmek. Hangi tarafta olursa olsun gerçek verilere dayanmadan söylenen şeyler fanatizm kokuyor. ## Kapanış: Neden Bu Kadar Yapışkan Bir Mit? Veriler ortada. Peki "PHP öldü" iddiası neden 20 yıldır tekrar tekrar geliyor? Birkaç hipotezim var: **Yeni dillerin pazarlama dili "eski olanı öldürmek" üzerine kurulu.** Node.js çıkarken PHP'yi gömüyordu, Go çıkarken Python'u, Rust çıkarken C++'ı. Bu retorik teknik bir değerlendirme değil — pazar konumlandırması. **PHP'nin kötü öğretilen sürümleri hâlâ insanların aklında.** 2008-2014 arası PHP 4 ve 5.2-5.3 ile öğrenen, "spagetti kod, global state, güvenlik açıkları" deneyimi yaşayan binlerce geliştirici var. Onların PHP imajı oradan kaldı; dil 2015 sonrası tamamen değişti ama imaj değişmedi. **Trend takip eden bir kültürde PHP modaya uymuyor.** Yeni framework, yeni dil, yeni paradigma — bunların pazarlanması meslek. PHP "yıllardır aynı işi yapıyor, sadece daha iyi yapıyor" konumunda. Bu, içerik üretmek için ilginç bir hikâye değil; dolayısıyla "PHP yazıyorum" demek **trendy değil**. Trendy olmamak ile ölü olmak aynı şey değil. --- Verilere döneyim: Mayıs 2026 itibarıyla PHP web'in %71.2'sinde çalışıyor, geliştiricilerin %19'u tarafından kullanılıyor, ekosistemi 181 milyar paket indirmiş, dili her yıl yeni bir sürüm çıkarıyor. Aynı zamanda async tarafında zayıf, generic'i yok, topluluğun neredeyse üçte biri üç yıldan uzun süre önce destekten çıkmış 7.x'te takılı. Bu tablo "ölü" tablosu değil. "Olgun, baskın, eleştirilebilir bir dil" tablosu. Aralarındaki farkı görebiliyorsan, bu yazıyı yazma sebebim yok zaten. Göremiyorsan, en azından artık kaynaklar burada. Şahsen ne yapıyorum: 18 yıldır PHP yazıyorum, 2026'da hâlâ PHP yazıyorum, 2030'da da büyük ihtimalle PHP yazıyor olacağım — çünkü işime yarayan iyi bir araç. Ama aynı projede uygun olduğunda Go, Python veya NodeJS de kullanıyorum. Dil fanatizmi yapmıyorum, veri okuyorum. --- ### Kaynaklar - [W3Techs: PHP Usage Statistics, May 2026](https://w3techs.com/technologies/details/pl-php) - [W3Techs: Server-side Programming Languages Overview](https://w3techs.com/technologies/overview/programming_language/) - [Stack Overflow Developer Survey 2025 — Technology](https://survey.stackoverflow.co/2025/technology) - [JetBrains: The State of PHP 2025](https://blog.jetbrains.com/phpstorm/2025/10/state-of-php-2025/) - [Packagist Statistics](https://packagist.org/statistics) - [PHP Supported Versions](https://www.php.net/supported-versions.php) - [endoflife.date: PHP](https://endoflife.date/php) - [endoflife.date: ColdFusion](https://endoflife.date/coldfusion) - [TIOBE Index](https://www.tiobe.com/tiobe-index/) - [Slashdot: Is Perl the World's 10th Most Popular Programming Language?](https://developers.slashdot.org/story/25/09/14/0134239/is-perl-the-worlds-10th-most-popular-programming-language) ## PHP'de Sabit, Enum ve Readonly: 10 Aracın Tek Karar Ağacı - Kaynak: https://muhammetsafak.com/tr/blog/php-sabit-enum-readonly-sekiz-aracin-karar-agaci/ Bir PHP geliştiricisine "bir sabit tanımla" deyince hâlâ `define()` yazıyorsa, son altı yılda PHP'de neler olduğunu kaçırmış demektir. Aynı geliştirici "değişmez bir nesne yarat" denildiğinde constructor'da elle atama yapıp setter'ları siliyorsa, son üç yılı da kaçırmıştır. PHP'de bugün "bir şey tanımla, sonra değişmesin" demenin **on farklı yolu** var: - `define()` - `const` (top-level) - Class `const` (visibility ile) - Interface constants - `final` class constants - [Typed class constants (PHP 8.3)](/tr/blog/php-8-3-typed-class-constants-ve-gelen-eklemeler/) - [`enum` — pure + backed (PHP 8.1)](/tr/blog/php-8-1-yolda-enumlar-ve-readonly-ile-daha-guvenli-modeller/) - `readonly` property (PHP 8.1) - [`readonly class` (PHP 8.2)](/tr/blog/php-8-2-yolda-readonly-siniflar-ve-yeni-tipler/) - [Asymmetric visibility (PHP 8.4)](/tr/blog/php-8-4-yolda-property-hooklar-ve-asimetrik-gorunurluk/) Çoğu mid-senior PHP geliştiricisi bunların yarısını biliyor, üçte birini doğru kullanıyor. Suçlu geliştirici değil — bu araçların **tek bir mental model altında nasıl konumlandığını** anlatan bir Türkçe kaynak yok. Bu yazı o boşluğu doldurmak için. Sonunda ne hangisini ne zaman seçeceğine dair tek bir karar ağacımız olacak. ## Tüm Seçimleri Hangi Üç Eksen Belirler? "Hangisini ne zaman?" sorusunun cevabı her zaman üç eksende veriliyor. Bu üç ekseni içselleştirdiğinde, on aracın hangisinin nereye düştüğünü kendin bulabilirsin. **1. Ne zaman belirlenir? — Compile-time vs Runtime** Değer, kod yorumlanırken mi bellekte oluşacak, yoksa program çalışırken mi hesaplanacak? `const` compile-time'da bellekte; `define()` runtime'da. Bu fark hem performansı hem de kullanılabileceği yerleri belirler — örneğin `const` koşullu bir blok içinde tanımlanamaz; `define()` tanımlanabilir ([PHP Manual: Constants syntax](https://www.php.net/manual/en/language.constants.syntax.php)). **2. Kapsamı nedir? — Global / Namespace / Class / Instance** Değer nerede yaşayacak? Tüm uygulamaya mı yayılacak (global), bir namespace içinde mi (namespace-scoped), belirli bir sınıfa mı (class), yoksa o sınıfın bir örneğine mi (instance) ait olacak? Kapsam ne kadar dar olursa, kodun o kadar disiplinli olur. **3. Davranış taşıyabilir mi? — Sade Değer vs Metodlu Tip** Bu sabit sadece bir veri parçası mı, yoksa kendi davranışı (metodları, ilişkili sabitleri) olan bir tip mi olmalı? `class const` sade bir değerdir; `enum` ise tip + metod taşıyabilen bir yapıdır. Bu eksen, "kötü kullanılmış class const'lar"ın `enum` ile değişeceği noktayı işaret eder. İşte on aracın bu üç eksendeki konumu: | Araç | PHP | Zaman | Kapsam | Davranış | |---|---|---|---|---| | `define()` | her zaman | runtime | global | sade | | `const` (top-level) | her zaman | compile | namespace | sade | | Class `const` | 7.1+ | compile | class | sade | | Interface const | her zaman | compile | paylaşılan | sade | | `final` class const | 8.1+ | compile | class (override yok) | sade | | Typed class const | 8.3+ | compile | class (**tip güvenli**) | sade | | `enum` | 8.1+ | compile | class | **tip + metod** | | `readonly` prop | 8.1+ | runtime | instance | sade | | `readonly class` | 8.2+ | runtime | instance (tümü) | sade | | Asymmetric visibility | 8.4+ | runtime | instance | "yumuşak readonly" | Şimdi her birini ayrı ayrı, kısa örneklerle geçelim. ## 1. `define()` — Runtime, Global, Sade ```php define('APP_NAME', 'MuhammetSafak'); define('MAX_RETRIES', 3); ``` Runtime'da, global kapsamda, sade bir değer. PHP 4 günlerinden kalan ilk araç. Koşullu blok içinde tanımlanabilir: ```php if ($env === 'production') { define('LOG_LEVEL', 'error'); } else { define('LOG_LEVEL', 'debug'); } ``` **Ne zaman kullanılır:** Bugün, neredeyse hiç. Tek meşru kullanım — değerin koşullu olarak veya runtime hesaplamayla belirlenmesi gerektiği nadir durumlar. Onun dışında her yerde `const` veya bir config nesnesi tercih edilir. **Yaygın yanlış:** "Global ayarlar `define`'la tutulur" alışkanlığı. Bu, 2010 PHP'sinden kalma bir refleks. Bugünün doğrusu typed bir config sınıfı veya env-injection. ## 2. `const` (Top-Level) — Compile-Time, Namespace, Sade ```php namespace App\Config; const MAX_UPLOAD_SIZE = 10 * 1024 * 1024; const SUPPORTED_LOCALES = ['tr', 'en', 'de']; ``` Compile-time'da belirlenir, namespace'e ait, sade değer. Koşullu blok içinde **tanımlanamaz** çünkü compile-time'da çözülmesi gerekir. **Ne zaman kullanılır:** Bir namespace içinde, **gerçekten sabit** ve **sınıfa ait olmayan** bir değer için. Pratikte: dosya kapsamlı yardımcı sabitler. **Yaygın yanlış:** Top-level `const` ile class-level `const` karıştırılır. İkincisi daha yaygın ve daha doğru — sınıfla mantıksal olarak bağlı sabitler her zaman class içine gitmeli. ## 3. Class `const` (Visibility ile) — Compile-Time, Class, Sade ```php class HttpClient { public const DEFAULT_TIMEOUT = 30; protected const RETRY_BACKOFF_MS = 250; private const INTERNAL_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0'; } ``` PHP 7.1'den beri visibility (`public`, `protected`, `private`) destekleniyor ([PHP Manual: Class constants](https://www.php.net/manual/en/language.oop5.constants.php)). Sınıfla mantıksal olarak ilişkili, compile-time'da çözülen sade değer. **Ne zaman kullanılır:** Bir sınıfın *konfigürasyonu* niteliğindeki değerler. "Bu sınıfın iç işleyişini ayarlayan, dışarıdan değişmemesi gereken" şeyler. **Yaygın yanlış:** Status/role/type değerlerini class const olarak tanımlamak: ```php class Order { public const STATUS_PENDING = 1; public const STATUS_PAID = 2; public const STATUS_SHIPPED = 3; public const STATUS_CANCELLED = 4; } ``` Bu kodu hâlâ yazıyorsan, sıradaki maddeye dikkat et — `enum` tam olarak bu sorunu çözmek için var. ## 4. Interface Constants — Compile-Time, Paylaşılan, Sade ```php interface CacheableInterface { public const DEFAULT_TTL = 3600; public const MAX_TTL = 86400 * 30; } class RedisCache implements CacheableInterface { public function get(string $key, int $ttl = self::DEFAULT_TTL): mixed { // ... } } ``` Birden fazla sınıfın paylaşacağı sabitler için interface kullanılır. **Ne zaman kullanılır:** Aynı sabitin birden fazla implementasyonda kullanılacağı, sözleşmenin parçası olduğu durumlar. **Yaygın yanlış:** Interface'i sadece sabit dağıtmak için kullanmak. Interface davranış sözleşmesidir, sabit kutusu değil. Sadece sabit barındıran interface — kötü sinyal. ## 5. `final` Class Constants — Compile-Time, Class (Override Yok), Sade ```php class PaymentGateway { final public const API_VERSION = 'v2'; } class StripeGateway extends PaymentGateway { // public const API_VERSION = 'v3'; // Fatal error } ``` PHP 8.1'den beri class constants `final` olabiliyor ([PHP 8.1 release notes](https://www.php.net/releases/8.1/en.php)). Alt sınıflar override edemez. **Ne zaman kullanılır:** Sabitin alt sınıflarda **değişmemesi gerektiğinden eminsen.** Inheritance'da yanlış override'ları engellemek için. **Yaygın yanlış:** Her sabite `final` yapıştırmak. `final` bir kararı temsil eder — "bu noktadan sonra değişmez." Her yerde kullanmak değil, gerektiğinde kullanmak doğru. ## 6. Typed Class Constants — Compile-Time, Class, Sade (**Tip Güvenli**) ```php class Logger { public const string LEVEL_INFO = 'info'; public const int MAX_BUFFER = 1000; public const array CHANNELS = ['file', 'syslog', 'stderr']; } class JsonLogger extends Logger { // public const int LEVEL_INFO = 5; // Fatal error } ``` PHP 8.3'ün getirdiği uzun zamandır beklenen özellik ([PHP Manual: Class constants](https://www.php.net/manual/en/language.oop5.constants.php)). Class const'lara tip belirtilebiliyor. Override edildiğinde tip uyumu zorunlu. **Ne zaman kullanılır:** PHP 8.3+ kullanıyorsan, **her class const'ta**. Tip yazmak ekstra üç karakter; verdiği güvence çok daha fazla. **Yaygın yanlış:** PHP 8.3+ projede typed olmayan eski class const'ları olduğu gibi bırakmak. Migration kolay, otomatik düzeltici araçlar ([Rector: AddTypeToConstRector](https://getrector.com/find-rule?query=AddTypeToConstRector)) bunu tek geçişte yapabiliyor. ## 7. `enum` — Tip + Metod, Sınırlı Küme ```php enum OrderStatus: string { case Pending = 'pending'; case Paid = 'paid'; case Shipped = 'shipped'; case Cancelled = 'cancelled'; public function label(): string { return match($this) { self::Pending => 'Bekliyor', self::Paid => 'Ödendi', self::Shipped => 'Kargoya verildi', self::Cancelled => 'İptal edildi', }; } public function isTerminal(): bool { return in_array($this, [self::Shipped, self::Cancelled]); } } function processOrder(Order $order, OrderStatus $newStatus): void { if ($order->status->isTerminal()) { throw new LogicException('Tamamlanmış sipariş değiştirilemez.'); } // ... } ``` PHP 8.1'in getirdiği en büyük dönüşüm ([PHP Manual: Enumerations](https://www.php.net/manual/en/language.enumerations.basics.php)). Tip + sınırlı küme + davranış tek pakette. **Backed enums** (string/int değerlerle) veritabanı/JSON eşlemesi için; **pure enums** (sadece case'ler) memory-only state için. **Ne zaman kullanılır:** *"Bu değer sadece şu birkaç şeyden biri olabilir"* dediğin her yerde. Status, role, type, priority, color, currency, language — hepsi enum adayı. **Yaygın yanlış:** Bir önceki bölümde gösterdiğim `const STATUS_PENDING = 1` pattern'ini sürdürmek. Eğer hâlâ böyle yazıyorsan: Rector veya benzer bir araç zinciriyle **bugün** geçiş yap. Bu, PHP 8.x'in en büyük üretkenlik kazanımlarından biri. ## 8. `readonly` Property — Runtime, Instance, Sade ```php final class Money { public function __construct( public readonly int $amount, public readonly string $currency, ) {} public function add(Money $other): Money { if ($this->currency !== $other->currency) { throw new InvalidArgumentException('Farklı para birimleri toplanamaz.'); } return new Money($this->amount + $other->amount, $this->currency); } } $price = new Money(100, 'TRY'); // $price->amount = 200; // Error: Cannot modify readonly property $discounted = $price->add(new Money(-20, 'TRY')); // Yeni nesne, eski değişmedi ``` PHP 8.1'den beri property'ler `readonly` olabiliyor ([PHP Manual: Properties](https://www.php.net/manual/en/language.oop5.properties.php)). Kurucuda set edilir, sonrasında değişmez. **Value object** pattern'inin PHP'deki doğal karşılığı. **Ne zaman kullanılır:** Bir nesnenin oluşturulduktan sonra **belirli özelliklerinin** değişmemesi gerektiği her yerde. DTO'lar, value object'ler, configuration object'ler. **Yaygın yanlış:** Setter'ları silip `__set`'i throw etmekle "immutability" yapmaya çalışmak. PHP 8.1+ kullanıyorsan bu pattern artık eski; `readonly` aynı sonucu *dilin garantisiyle* veriyor. ## 9. `readonly class` — Runtime, Instance (Tümü), Sade ```php final readonly class UserDto { public function __construct( public int $id, public string $name, public string $email, public DateTimeImmutable $createdAt, ) {} } ``` PHP 8.2'den beri sınıfın tamamı `readonly` ilan edilebiliyor ([PHP 8.2 release notes](https://www.php.net/releases/8.2/en.php)). Her property otomatik olarak readonly olur, ayrı ayrı yazmaya gerek kalmaz. **Ne zaman kullanılır:** Tüm property'leri immutable olan sınıflar için. Tipik adaylar: DTO, value object, event payload, command/query object. **Yaygın yanlış:** `readonly class` ile birlikte mutable bir nesne (örneğin `ArrayObject`) tutmak. `readonly` sadece **property referansını** sabitler — referansın gösterdiği nesnenin iç durumunu sabitlemez. `array` farklı davranır: [PHP Manual: Properties](https://www.php.net/manual/en/language.oop5.properties.php) sayfası, readonly property'deki array içeriğinin de değiştirilemeyeceğini, readonly property'de tutulan nesnelerin ise içeriden değiştirilebilir kaldığını belirtiyor. ```php final readonly class TagSet { public function __construct( public array $tags, // readonly: array içeriği de sabit ) {} } $set = new TagSet(['a', 'b']); // $set->tags = []; // Error $tags = $set->tags; $tags[] = 'c'; // $set->tags etkilenmez (PHP array kopyalanır) // Ama nesne tutuyorsa: $obj = new stdClass(); $obj->value = 1; $set2 = new SomeReadonly($obj); $set2->obj->value = 999; // Çalışır! readonly nesne referansını korur, içeriğini değil. ``` Bu nüans çok önemli — `readonly` derin değil, sığ değişmezlik sağlar. ## 10. Asymmetric Visibility — Runtime, Instance, "Yumuşak Readonly" ```php final class User { public function __construct( public private(set) string $email, public private(set) DateTimeImmutable $lastLoginAt, ) {} public function recordLogin(DateTimeImmutable $at): void { $this->lastLoginAt = $at; // Sınıf içinden yazılabilir } } $user = new User('test@example.com', new DateTimeImmutable()); echo $user->email; // Dışarıdan okunabilir // $user->email = 'foo@bar'; // Error: Cannot modify $user->recordLogin(new DateTimeImmutable()); // İçeriden değiştirilebilir ``` PHP 8.4'ün getirdiği yeni özellik ([PHP Manual: Visibility](https://www.php.net/manual/en/language.oop5.visibility.php)). Property'nin **okuma görünürlüğü** ile **yazma görünürlüğü** ayrı tanımlanabilir. `public private(set)` = dışarıdan okunur, sadece sınıf içinden yazılır. **Ne zaman kullanılır:** `readonly`'nin "hiç değişemez" katılığı çok sıkı geldiğinde. Bir property'nin **dışarıdan değişmesini** istemiyorsun ama **iç metodlar yazabilsin** istiyorsan. **Yaygın yanlış:** Bunu private property + public getter pattern'inin yerine koymak. Aslında *koymalısın*, ama "her readonly'yi asymmetric'e dönüştür" değil. `readonly` ve asymmetric visibility farklı niyetleri ifade eder — birincisi "asla değişmez", ikincisi "dışarıdan değişmez, içeriden değişebilir." ## Karar Ağacı: Hangisini Ne Zaman? İşte birikenleri tek bir akışta toplayan karar süreci: ``` 1. Değer runtime'da mı belirlenecek? ├─ Evet → define() └─ Hayır (compile-time) → 2'ye git 2. Bir sınıfa mı ait? ├─ Hayır (namespace-scoped) → const (top-level) └─ Evet → 3'e git 3. Sonlu bir küme mi (status, role, color vs.)? ├─ Evet → enum (backed veya pure) └─ Hayır → 4'e git 4. Birden fazla sınıfta paylaşılacak mı? ├─ Evet → interface constant └─ Hayır → 5'e git 5. PHP 8.3+ kullanıyor musun? ├─ Evet → typed class constant └─ Hayır → class constant (visibility ile) 6. Override edilmemeli mi? └─ Evet → yukarıdakine final ekle ``` Nesne tarafı ayrı bir akış: ``` A. Bir nesnenin property'lerinin değişmemesini istiyorum. ├─ Tüm property'ler immutable mı? → readonly class └─ Sadece bazıları → readonly property B. Property dışarıdan okunsun ama sadece içeriden değişsin. └─ PHP 8.4+ → public private(set) (asymmetric visibility) └─ PHP 8.3 ve altı → private property + public getter ``` ## Anti-Patterns: PHP Ekosisteminde Sık Gördüğüm Yanlışlar **1. Status sabitleri için class `const`** ```php // Yanlış (2026 itibarıyla) class Order { public const STATUS_PENDING = 1; public const STATUS_PAID = 2; public const STATUS_SHIPPED = 3; } // Doğru enum OrderStatus: int { case Pending = 1; case Paid = 2; case Shipped = 3; } ``` Bu *tek başına* bir migration projesi olabilir. Bir codebase'de yüzlerce yerde geçer ve tip güvenliğini, IDE autocomplete'ini, exhaustive match'ı topluca getirir. **2. Global config için `define()` yığını** ```php // Yanlış define('DB_HOST', 'localhost'); define('DB_NAME', 'app'); define('REDIS_HOST', '127.0.0.1'); define('MAIL_DRIVER', 'smtp'); // ... 40 satır daha // Doğru final readonly class DatabaseConfig { public function __construct( public string $host, public string $name, public string $user, ) {} } ``` Typed config object'ler hem test edilebilir hem de IDE tarafında autocomplete'lenir. Global state ne kadar azsa, kod o kadar deterministik olur. **3. Setter'ları silerek "immutability" yapmak** ```php // Yanlış class Money { private int $amount; private string $currency; public function __construct(int $amount, string $currency) { $this->amount = $amount; $this->currency = $currency; } public function getAmount(): int { return $this->amount; } public function getCurrency(): string { return $this->currency; } // setter yok = "immutable" sandığımız şey } // Doğru final readonly class Money { public function __construct( public int $amount, public string $currency, ) {} } ``` İkincisi yarı uzunlukta ve dilin garantisini taşıyor. PHP 8.2+ varsa birincisini yazmaya devam etmek için bahane yok. **4. Interface'i sabit kutusu olarak kullanmak** ```php // Yanlış interface Constants { public const PI = 3.14; public const E = 2.71; } class Calculator implements Constants { // ... } ``` Interface = davranış sözleşmesi. Sabit barındırması meşru — ama **sadece** sabit barındırmak için interface uydurmak, yanlış soyutlama. **5. Magic number'lar her yerde** ```php // Yanlış if ($user->loginAttempts >= 5) { $user->lock(); } sleep(300); // Doğru final class AuthPolicy { public const int MAX_LOGIN_ATTEMPTS = 5; public const int LOCKOUT_SECONDS = 300; } if ($user->loginAttempts >= AuthPolicy::MAX_LOGIN_ATTEMPTS) { $user->lock(); } sleep(AuthPolicy::LOCKOUT_SECONDS); ``` Bu, en eski tavsiye — ama hâlâ en çok ihlal edilen. ## Migration: Eski Kodu Yeniye Taşımak **Class const status sabitlerinden enum'a** ```php // Önce class Order { public const STATUS_PENDING = 1; public const STATUS_PAID = 2; public int $status; } if ($order->status === Order::STATUS_PAID) { ... } // Sonra enum OrderStatus: int { case Pending = 1; case Paid = 2; } class Order { public OrderStatus $status; } if ($order->status === OrderStatus::Paid) { ... } ``` Migration genelde kolay — değer eşlemesini koruduğun sürece (`int` backed enum) veritabanı şeması değişmez. Tek dikkat: PDO/ORM eşlemesi. **Setter'sız sınıftan `readonly class`'a** ```php // Önce class UserDto { private int $id; private string $name; public function __construct(int $id, string $name) { ... } public function getId(): int { return $this->id; } public function getName(): string { return $this->name; } } // Sonra final readonly class UserDto { public function __construct( public int $id, public string $name, ) {} } // Çağrı: $dto->getId() yerine $dto->id ``` Bu migration kod tabanı genelinde getter çağrılarını değiştirmeyi gerektirir. IDE refactor'ları (PhpStorm "Inline Method") yardımcı olur. **Global `define`'lardan typed config'e** ```php // Önce define('STRIPE_KEY', getenv('STRIPE_KEY')); define('STRIPE_WEBHOOK_SECRET', getenv('STRIPE_WEBHOOK_SECRET')); // Sonra final readonly class StripeConfig { public function __construct( public string $key, public string $webhookSecret, ) {} public static function fromEnv(): self { return new self( key: getenv('STRIPE_KEY') ?: throw new RuntimeException('STRIPE_KEY missing'), webhookSecret: getenv('STRIPE_WEBHOOK_SECRET') ?: throw new RuntimeException('STRIPE_WEBHOOK_SECRET missing'), ); } } // Container'a register et, inject et ``` Bu migration en büyüğü ama en değerlisi. Global state'in azalması, kodun test edilebilirliğini katlar. ## Performans Araç Seçimini Belirler mi? Mikro-benchmark düzeyinde: - `const` `define()`'dan hızlıdır (compile-time çözüm) - Class const'lar değişken erişiminden hızlıdır - `enum` instance'ları singleton ([PHP Manual: Enumerations](https://www.php.net/manual/en/language.enumerations.basics.php)) — her case için tek nesne, karşılaştırma `===` ile sabit zamanlı - `readonly` çok hafif overhead — sadece yazma denetimi Pratikte: bunların **hiçbiri** sıradan bir web uygulamasında dar boğaz değil. Doğruyu seç, performans çoğunlukla doğrunun yan ürünü olur. "Bu daha hızlı" diye yanlış soyutlamayı seçme — okunabilirlik kaybı, mikro-saniyelik kazançtan kat kat pahalı. İstisna: Sıcak döngülerde milyonlarca iterasyonlu kod yazıyorsan (hashlemeler, parser'lar, transformasyonlar), tek `enum::label()` çağrısı `match` overhead'i toplar — orada `class const` daha hızlı olabilir. Önce ölç, sonra optimize et. ## Kapanış PHP'de değişmezlik tek bir araç değil; üç eksenin (zaman, kapsam, davranış) farklı kombinasyonlarına verilmiş on farklı cevap. Hangisini seçeceğin sorusu aslında *ne yapmak istiyorsun* sorusudur: - Bir değer mi tutuyorsun, bir tip mi yaratıyorsun? - Değer ne zaman belli olacak? - Değişmemesi gereken şey, **referans** mı yoksa **içerik** mi? Bu üç soru sana doğru aracı verir. On aracın hepsini ezberlemen gerekmez — üç ekseni ezberle, gerisi türev. Son olarak: bu konuyu 2022'de [const ve define ekseninde](https://medium.com/@muhammetsafak/php-sabiti-const-i%CC%87fadesi-vs-define-i%CC%87%C5%9Flevi-7d044009e336) ele almıştım. Aradan dört yıl geçti, PHP dört yeni sürüm çıkardı, ekosistem dönüştü. Yazılım yazıları da eskidir — bu yazıyı 2030'da okuyorsan, muhtemelen güncellenmiş bir versiyonu da vardır. ## Production'da Git: Senior'ın Pratik Rehberi - Kaynak: https://muhammetsafak.com/tr/blog/production-git-senior-pratik-rehberi/ Git'i 5 yıl kullanan biri ile 15 yıl kullanan biri arasındaki fark, bildikleri komut sayısı değil. Fark şu: ilki *Git'i kullanmayı* öğrenmiş, ikincisi *Git'le neyi ne zaman yapacağına karar vermeyi* öğrenmiş. Bu yazı temel komutlar üzerine değil. `add`, `commit`, `push`, `pull` zaten biliniyor. Bu yazı, **kararlar** ve **az bilinen ama hayat kurtaran** parçalar üzerine: hangi workflow'u seçeceğine, tarihçeni nasıl temiz tutacağına, hangi commit'in bug'ı soktuğunu nasıl bulacağına, force-push'tan nasıl kurtulacağına dair. Daha temel branch/merge/PR disiplini için daha önce yazdığım [Git akışı: branch, merge ve pull request disiplini](/tr/blog/git-akisi-branch-merge-ve-pull-request-disiplini/) yazıma bakabilirsin. Bu yazı onun bir üst katmanı. ## 1. Workflow Seçimi: GitFlow vs GitHub Flow vs Trunk-Based "Git workflow'unuz ne?" sorusunun cevabı yoksa, bilmeden bir tane seçmişsiniz demektir — büyük ihtimalle yanlışını. Üç ana akış var: ### GitFlow Vincent Driessen'in [2010'da yayınladığı](https://nvie.com/posts/a-successful-git-branching-model/) klasik model: `main` (production), `develop` (entegrasyon), `feature/*`, `release/*`, `hotfix/*` dalları. Her şey planlı, her yayın için ayrı release branch. **Ne zaman seçilir:** Versiyonlu, planlı yayın takvimi olan ürünler. Mobile uygulamalar (App Store yayın döngüsü), enterprise yazılım, "v2.3.1" gibi numaralı sürümler dağıtan paket projeleri. **Ne zaman seçilmez:** Web uygulamaları, sürekli deploy edilen sistemler. GitFlow'u SaaS'a uygulamak — release branch'ler çoğunlukla anlamsız, develop her zaman main'in 2-3 commit gerisinde takılır. ### GitHub Flow Sade model: `main` her zaman deploy edilebilir, her özellik için kısa ömürlü branch, PR ile merge, merge sonrası anında deploy. Release branch yok, develop branch yok. **Ne zaman seçilir:** Küçük-orta takım (3-15 kişi), CI/CD kurulu, günde bir-iki kez deploy. Türkiye'deki agency'lerin ve startup'ların büyük çoğunluğu burada doğru karar verir. **Ne zaman seçilmez:** 50+ kişilik takımlar — main'e PR sırası çok uzun olur. Veya hâlâ haftalık manuel deploy ediyorsanız — bu workflow ön koşulu "deploy bir saniyelik iş" olması. ### Trunk-Based Development Her geliştirici doğrudan `main`'e commit'ler (veya çok kısa ömürlü branch). Yarım kalan özellikler **feature flag** arkasında saklanır. Branch ömrü maksimum birkaç saat. **Ne zaman seçilir:** Büyük takımlar (Google, Facebook, Spotify), continuous deployment kültürü, feature flag altyapısı kurulu. Yüksek disiplin gerektirir. **Ne zaman seçilmez:** Feature flag altyapınız yoksa veya CI test coverage'ınız düşükse — `main`'i bozmak çok kolaylaşır. ### Karar Matrisi | Durum | Tavsiye | |---|---| | Mobile uygulama, App Store dağıtımı | GitFlow | | SaaS, küçük-orta takım, günlük deploy | GitHub Flow | | 3 kişilik freelance agency | GitHub Flow (ya da daha basit: PR'sız doğrudan) | | 50+ geliştirici, deploy günde 10+ | Trunk-based | | Versiyonlu paket / library | GitFlow (release branch yararlı) | | Plansız, "ne lazımsa o" | GitHub Flow (varsayılan) | **Anti-pattern:** Üçünü karıştırmak. "Hem develop branch'ımız var hem feature flag kullanıyoruz hem her özellik release branch'le çıkıyor" — bu üç ayrı disiplinin yarısını uygulamak, hiçbirinin avantajını almamak demek. ## 2. Tarihçe Hijyeni: Rebase ve Autosquash PR açtın, review aldın, "şu değişikliği yap, şu typo'yu düzelt" yorumları geldi. Üç ayrı düzeltme commit'i atıp PR'ı update'ledin. Şimdi PR'da 1 anlamlı commit + 3 "address review" commit'i var. Merge sonrası `git log` bunu okuyacak. Bunu çözmenin iki yolu var. **Squash merge** (GitHub'da tek tıkla) her şeyi tek commit'e indirir — ama bu da farklı bir aşırılık: 200 satırlık 3 mantıksal değişiklik tek commit'e sıkışır. Doğru orta yol: **interactive rebase + autosquash**. ### Fixup Commit Workflow Review yorumunu adresliyorsun. Yeni commit yerine: ```bash git add . git commit --fixup ``` Bu komut `fixup! ` başlıklı bir commit oluşturur. Aynı şekilde 2-3 fixup daha atabilirsin. PR mergea hazır olduğunda: ```bash git rebase -i --autosquash main ``` Editör açıldığında her fixup, ait olduğu commit'in **altına** otomatik konumlanmış ve `fixup` olarak işaretlenmiş olarak gelir. Hiçbir şey değiştirmeden kaydet — Git fixup'ları ait oldukları commit'lerin içine yedirir. Sonuç: tarihçe `git commit --fixup` öncesi kadar temiz, ama review'da yapılan düzeltmeler ilgili commit'in parçası olmuş. Bunu daha da kolaylaştırmak için: ```bash git config --global rebase.autosquash true ``` Bu ayardan sonra `git rebase -i` her seferinde otomatik autosquash uygular ([Git Documentation: git-config](https://git-scm.com/docs/git-config), `rebase.autoSquash`'ı interaktif modda `--autosquash`'i varsayılan yapan ayar olarak tanımlar). Bir daha hatırlamana gerek kalmaz. ### Golden Rule **Public branch'lerde — main, develop, başkalarının çektiği herhangi bir branch — asla rebase yapma.** Tarihçeyi yeniden yazmak, başkasının clone'unda bulunan commit hash'lerini değersizleştirir. Sonuç: merge conflict cehennemi. Rebase **senin private branch'inde** yapılır. PR henüz merge edilmemişse, branch hâlâ senindir — rebase serbest. Merge edildikten sonra dokunma. ## 3. Bug'ı Hangi Commit Soktu? Bisect ile Bulmak Klasik senaryo: production'da bir regression var. Geçen hafta çalışıyordu, bugün çalışmıyor. Aradaki 80 commit'in hangisi soktu? Manuel binary search yapacaksan 4 saatlik bir iş. Bisect ile 6 dakika. ### Manuel Bisect ```bash git bisect start git bisect bad HEAD # şu an bozuk git bisect good v2.3.0 # bu sürümde çalışıyordu ``` Git seni iki commit'in ortasındaki commit'e götürür. Test edersin: - Bozuk: `git bisect bad` - Çalışıyor: `git bisect good` Her cevap arama alanını yarıya indirir. 80 commit için ~7 adımda doğru commit'i bulur. Bitirince: ```bash git bisect reset ``` ### Otomatik Bisect Eğer "bozuk mu çalışıyor mu" sorusu bir komutla cevaplanabiliyorsa (örn. bir test): ```bash git bisect start HEAD v2.3.0 git bisect run npm test -- --testPathPattern=image-upload ``` Git senin yerine her commit'te testi çalıştırır, exit code 0 ise "good", 1 ile 127 arasındaki kodları (125 hariç, o "bu commit'i atla" demektir) "bad" sayar; bunu [Git Documentation: git-bisect](https://git-scm.com/docs/git-bisect) böyle tanımlar. Sen bir kahve içersin, 80 commit içinden bug'ı sokan commit'i ekranda görürsün. **Bisect'in en güzel tarafı:** Hangi commit'in bug'ı soktuğunu bulduğunda, o commit'in **author'ı, mesajı ve tam değişiklik kümesi** gözünün önündedir. Git blame'in tek satıra bakması yerine, bisect bütün hikâyeyi verir. ## 4. Kaybolan Commit'ler Nasıl Geri Getirilir? Reflog Reset --hard yaptın. Wrong branch'e merge ettin. Force-push yedin. "Commit'lerim gitti." Hayır, gitmedi. Git, `HEAD`'in son ~90 günlük tüm hareketlerini **reflog**'da tutar. Hiçbir commit gerçekten silinmez — sadece ona referans veren branch/tag kalmaz. Reflog ile geri bulunabilir. ```bash git reflog ``` Çıktı şuna benzer: ``` abc1234 HEAD@{0}: reset: moving to HEAD~3 def5678 HEAD@{1}: commit: addressed review feedback ghi9012 HEAD@{2}: commit: refactor user service jkl3456 HEAD@{3}: pull: Fast-forward ``` "Sildiğini sandığın" commit `def5678`. Geri getirmek için: ```bash git checkout def5678 git switch -c kurtarilan-branch ``` veya doğrudan branch oluştur: ```bash git branch kurtarilan-branch def5678 ``` **Önemli:** Reflog **lokal**dir. Sadece kendi makinendeki Git'in tuttuğu kayıt. Eğer başkasının force-push'u sebebiyle commit kaybettiysen, **onların** reflog'una bakmak gerekir. İkinci önemli nokta: garbage collection eninde sonunda gerçekten siler. [Git Documentation: git-gc](https://git-scm.com/docs/git-gc) varsayılanları şöyle verir: reflog kayıtları 90 gün (`gc.reflogExpire`), güncel uçtan erişilemeyen kayıtlar 30 gün (`gc.reflogExpireUnreachable`); referansı kalmayan nesneyi GC iki haftalık süreden sonra budar (`gc.pruneExpire`). Yani "geçen yıl yaptığım bir şeyi" kurtarmak için reflog güvenli değil. ## 5. Worktree: Aynı Repo, Birden Fazla Klasör Bir feature üzerinde çalışıyorsun, branch'inde yarım kalmış değişiklikler var. Birden acil bir hotfix gerekti — `main`'e geçmen lazım ama mevcut işini kaybetmek istemiyorsun. Klasik çözüm: `git stash`, branch değiştir, hotfix, geri dön, stash pop. İşliyor ama: - Stash'ler birikiyor - IDE state'i kaybediyor - Açık dosyalar değişiyor - Hangi stash neyin diye unutuyorsun Daha iyi yol: **worktree**. ```bash git worktree add ../hotfix-2026-05 main ``` Bu komut, **aynı repo için** ama **farklı klasörde** ve **main branch'inde** ikinci bir çalışma alanı oluşturur. Feature branch'in dokunulmamış kalır, IDE'de iki ayrı klasör açabilirsin, hotfix'i bitirip: ```bash cd ../hotfix-2026-05 # çalış, commit, push cd ../proje git worktree remove ../hotfix-2026-05 ``` Worktree'leri listelemek: ```bash git worktree list ``` **Tipik kullanım senaryoları:** - Code review için PR branch'ini ayrı klasörde çekme (kendi work'üne dokunmadan) - Birden fazla feature paralel geliştirme - Acil hotfix için anlık ortam - Eski bir release'e bakmak gerektiğinde (`git worktree add ../v2.3 v2.3.0`) Bir kez kullanmaya başlayınca `stash`'ten daha çok worktree açtığını fark edersin. ## 6. Signed Commits: GPG ve SSH Git, commit'lere yazılan author bilgisini **kontrol etmez**. `git config user.email "linus@kernel.org"` yazıp commit atabilirsin — Git itiraz etmez. Bu, supply chain saldırılarının en sevdiği açık. Çözüm: **commit imzalama**. Her commit'i kendi anahtarınla imzalarsın, GitHub/GitLab onu doğrular ve "Verified" rozetiyle gösterir. İki yöntem var. ### GPG ile İmzalama (klasik) ```bash gpg --full-generate-key gpg --list-secret-keys --keyid-format=long git config --global user.signingkey git config --global commit.gpgsign true ``` GPG public key'i GitHub'a yüklersin (Settings → SSH and GPG keys). Bundan sonra her commit otomatik imzalanır. **Sorun:** GPG kurulumu, key management, expire eden key'ler, başka cihaza taşırken çıkan zorluklar. Çoğu geliştirici "GPG sonra bakarım" diye erteler ve hiç imzalanmamış commit atmaya devam eder. ### SSH ile İmzalama (modern) Git 2.34+ ve GitHub 2022 sonrası: **SSH key'inle commit imzalayabilirsin** ([Git 2.34.0 sürüm notları](https://github.com/git/git/blob/v2.34.0/Documentation/RelNotes/2.34.0.txt) "ssh public crypto can be used for object and push-cert signing" der ve bunun için OpenSSH 8.7'ye karşı uyarır). Zaten Git push için kullandığın SSH key'i. ```bash git config --global gpg.format ssh git config --global user.signingkey ~/.ssh/id_ed25519.pub git config --global commit.gpgsign true ``` GitHub'da: Settings → SSH and GPG keys → "New SSH key" → key type'ı **Signing Key** seç (Authentication key ayrı). Bu kadar. GPG'nin tüm kurulum zorluğunu atlamış olursun. 2024 sonrası başlayan biri için **SSH signing varsayılan seçim** olmalı. Doğrulamak için: ```bash git log --show-signature -1 ``` Çıktıda `Good "git" signature for ...` görmen gerekir. ## 7. Tehlikeli Komutlar: `--force` Disiplini Üç komut iş kaybettirebilir: ### `git push --force` yerine `--force-with-lease` `--force` upstream'i hiç sormadan ezer. Eğer branch'ten başka biri bir commit pushladıysa, onun commit'i kaybolur. ```bash # Tehlikeli git push --force # Güvenli git push --force-with-lease ``` `--force-with-lease` upstream'in son hâlinin senin **son fetch ettiğin** hâlle aynı olduğunu kontrol eder: [Git Documentation: git-push](https://git-scm.com/docs/git-push) uyarınca uzak ref'in güncel değerinin elindeki remote-tracking branch ile aynı olmasını şart koşar. Birisi araya commit attıysa push reddedilir, "tekrar fetch et" denir. Bunu da otomatikleştirmek için alias: ```bash git config --global alias.pushf 'push --force-with-lease' ``` ### `git reset --hard` Working directory + index + HEAD'i hedef commit'e sıfırlar. Kaydetmediğin değişiklikler gider. **Reflog'a girer** — yani geri alınabilir. Alternatif yaklaşımlar: - Sadece commit'i geri alacaksan: `git reset --soft HEAD~1` (değişiklikleri staging'de bırakır) - Working directory'yi temizleyeceksen: önce `git stash` ile yedek al, sonra reset ### `git clean -fd` Working directory'deki **tracked olmayan** dosyaları siler. `.gitignore`'da olanları da silmek için `-x` ekler. Yanlış parametreyle çağrılırsa `node_modules` veya local config'ler uçar. Önce `git clean -nfd` (dry run) çalıştır, ne silineceğini gör; sonra `-f` ile gerçek silmeyi yap. ## 8. Az Bilinen Güçlü Komutlar ### `git rerere` — "Reuse Recorded Resolution" Aynı merge conflict'i defalarca çözüyorsan (uzun süreli feature branch + sık main rebase senaryosu klasik), rerere bir kez çözdüğün conflict'i hatırlar, bir sonraki sefer otomatik uygular ([Git Documentation: git-config](https://git-scm.com/docs/git-config), `rerere.enabled`'ı çözülmüş conflict'leri kaydeden, aynı hunk'lar tekrar çıkınca otomatik çözen ayar olarak tanımlar): ```bash git config --global rerere.enabled true ``` Bir defa açtıktan sonra unutursun, sessizce arka planda çalışır. Bir hafta sonra fark edersin: "Bu conflict'i çözdüğümü hatırlamıyorum ama Git çözmüş?" ### `git log -S` ve `git log -G` — Pickaxe Bir kod parçasının ne zaman ve hangi commit ile eklendiğini/silindiğini bulmak için: ```bash git log -S"OldFunctionName" # belirli stringin commit'lerini bul git log -G"regex_pattern" # regex ile ara ``` "Bu deprecated fonksiyon ne zaman kaldırılmıştı?" sorusunun en hızlı cevabı bu. Tüm tarihçeyi tarar, geriye dönük arkeoloji yapar. ### `git blame -L` Tüm dosya değil, belirli satır aralığını blame'le: ```bash git blame -L 45,80 src/Service/PaymentGateway.php ``` Büyük dosyalarda altın değerinde — sadece ilgilendiğin bölümün yazılış hikâyesini görürsün. ### `git switch` ve `git restore` Git çok eskiden beri `git checkout`'u iki farklı iş için kullanıyor: branch değiştirme ve dosya geri alma. Git 2.23 (2019) bunları ayırdı ([Git 2.23.0 sürüm notları](https://github.com/git/git/blob/v2.23.0/Documentation/RelNotes/2.23.0.txt) bunun için `git switch` ve `git restore`'u getirir): ```bash git switch main # branch değiştir git switch -c yeni-feature # yeni branch oluştur ve geç git restore dosya.php # working directory'deki değişikliği geri al git restore --staged dosya.php # staging'den geri al (unstage) ``` `checkout` hâlâ çalışıyor ama yeni kod yazarken `switch`/`restore` kullanmak daha net. Junior'lara yeni komutları öğretmek, eski `checkout`'un iki anlamlı kullanımının yarattığı karışıklığı engelliyor. ## Kapanış Git'i öğrenmek bir kerelik bir iş değil. İlk yıl `add`, `commit`, `push` öğrenirsin. İkinci yıl `branch`, `merge`, `rebase`. Beşinci yıl `bisect`, `reflog`, `worktree`. On beşinci yıl `rerere`, `pickaxe`, `signed commits`. Komutlar 20 yaşında ama her geçen yıl yeni bir tane öğreniyorsun çünkü her yeni senaryo yeni bir araç gerektiriyor. Bu yazıda işlediğim komutların hiçbiri yeni değil — `bisect` 2005'ten beri var. Yeni olan, onları **ne zaman kullanacağını bilmek**. Senior bir geliştiriciyi diğerlerinden ayıran şey komut sayısı değil; doğru komutu doğru anda hatırlamak. Git temelleri için 2022'de yazdığım [Medium serisine](https://medium.com/@muhametsafak/gi%CC%87t-versiyon-kontrol-sistemi-1-giri%C5%9F-kurulum-ve-yap%C4%B1land%C4%B1rma-2a5cdab1486b) bakabilirsin — kurulumdan branch ve cherry-pick'e kadar başlangıç-orta seviye komutlar orada. Bu yazı onun üstüne, yıllar içinde gerçek production sorunlarının öğrettiği parçaları topluyor. Yeni bir Git komutu öğrendiğinde — bu hafta öğreneceğin bir tane var — onu `~/.gitconfig`'inde **alias** olarak kaydet. İki ay sonra adını unutursun, alias unutmaz. --- ## Komut Hızlı Referansı | İhtiyaç | Komut | |---|---| | Review düzeltmesi commit'i | `git commit --fixup ` | | Fixup'ları yedir | `git rebase -i --autosquash main` | | Bug'ı hangi commit soktu? | `git bisect start` → `bad`/`good` | | Otomatik bisect | `git bisect run ` | | Kaybolan commit'i bul | `git reflog` | | Paralel çalışma alanı | `git worktree add ` | | SSH ile imzalama | `git config gpg.format ssh` | | Güvenli force push | `git push --force-with-lease` | | Conflict'leri hatırla | `git config rerere.enabled true` | | String'in commit'ini bul | `git log -S"text"` | | Satır aralığı blame | `git blame -L 10,50 file` | | Modern branch geçişi | `git switch ` | ## Bu günlüğün on iki yılı: geriye dönüp bakmak - Kaynak: https://muhammetsafak.com/tr/blog/bu-gunlugun-on-iki-yili-geriye-donup-bakmak/ Şubat 2014'te bu günlüğe [ilk yazıyı](/tr/blog/composera-gecis-php-bagimliliklarini-elle-yonetmeyi-birakmak/) koyduğumda Composer'dan bahsediyordum. Modülleri elle dahil etmeyi bırakıp bağımlılık yönetimine geçmek o dönem için gerçekten bir dönüşümdü. Küçük ama somut. "İşte böyle yapıyorum" ekseninde kısa, uygulamalı bir not. O ilk yazıdan bu yazıya kadar yüz kırk sekiz ay geçti. Her ay, on iki yıl boyunca. Bazı aylar zordu; konu bulmak değil yazmak için vakit ve enerji bulmak. Ama devam ettim. Bu son yazıyı yazarken geri dönüp bakmak hem doğal hem gerekli. ## Ne değişti, ne kaldı 2014'te PHP geliştiricisiydim. 2026'da da PHP geliştiricisiyim. Bu görünürde değişmemiş gibi görünebilir ama içi tamamen farklı. O zamanlar çözüm arıyordum: "şu özelliği nasıl yaparım, bu kütüphane ne işe yarar, bu framework'ü nasıl kullanırım." Yazılar da bu soruların izinden gidiyordu. Doğru işe yarar bir çözüm bulmak yeterliydi. Sonraki dönemde soruların yapısı değişti. "Nasıl yaparım" yerine "neden bu yol, başka ne seçenekler var, bunun bedeli nedir" gelmeye başladı. Trade-off görmek Senior döneminin işaretiydi. Tek bir doğru cevap olmadığını, bağlam kararı belirliyor diye öğrenmek. Son birkaç yılda ise sorular daha da kaydı. "Bu sistem tutarlı mı, bu karar şimdi özgürlük ama ileride kısıtlama mı getirecek, başkasının anlayabileceği bir şey inşa ediyorum miyim?" Artık tek bir özelliği değil, zaman içinde yaşamaya devam edecek bir şey yazmak istiyorum. Bu üç aşama bu günlükte de okunabilir. İlk yılların yazıları somut ve araç odaklı. Orta dönem kararlar ve karşılaştırmalar içeriyor. Son dönem daha sakin, daha genel örüntülerden konuşuyor. ## Birkaç dönüm noktası Bazı yazılar geriye dönüp bakınca beklenmedik bir ağırlık taşıyor. 2019'daki Go yazısı: "Polyglot'a ilk adım: neden Go öğrenmeye başladım" başlığıyla o zaman küçük bir deney gibi başlayan şey, bugün benim araç kutumda sabit bir yer kaplıyor. O yazıyı yazarken bunun böyle devam edeceğini bilmiyordum. 2022'deki ChatGPT yazısı: "İlk izlenim; heyecan ve sınırların dürüst değerlendirmesi" diye notum var. O heyecanın nereye gittiğini ilerleyen yılların yazıları gösteriyor; yapay zekanın geliştirici işini nasıl değiştirdiğini, ne devirdiğini, neyi hâlâ bıraktığını. 2023'teki "hype'ı filtrelemek" yazısı: Bunu yazan ben ile 2014'te her yeni framework duyduğunda meraklanan ben aynı insan. Ama artık farklı bir refleks var. Her yeni şeyin peşine koşmak değil, önce ne sorun çözüyor diye sormak. ## Polyglot olmak Bu günlüğü PHP ile başladım ve PHP ile bitiriyorum. Ama arasında Go ve Python da girdi. Bu kasıtlı bir karar değildi; problemler getirdi. Go komut satırı araçları ve küçük servisler için, Python otomasyon ve veri işleme için. Şunu öğrendim: bir dili iyi bilmek diğer dillere bakmayı engellemez, tam tersine kolaylaştırır. PHP ile kurduğum derinlik sayesinde Go'daki farklılıkları daha hızlı fark ettim. Python'un getirdiği betik yazma kolaylığı PHP yazarken gereksiz karmaşıklıktan daha çok kaçınmamı sağladı. Diller birbirini besliyor. ## Yazmak hakkında Bu günlüğün bana teknik bilgiden ayrı bir katkısı da yazmayı alışkanlık haline getirmiş olması. Her ay bir şey yazma zorunluluğu — bunu kendime koydum ama bir zorunluluk gibi hissettirdi — düşünmeyi dışarıya çıkarma disiplinini geliştirdi. Bir konuyu yazmadan önce bildiğimi sandığım şeyler, yazmaya başlayınca eksik ya da yanlış çıktı. Bu düzeltme döngüsü, yazılardan çok benim içindi. Yazan geliştirici olmanın kariyer üzerindeki etkisini de zamanla gördüm. Özgeçmiş ne yaptığını söylüyor; yazılar nasıl düşündüğünü söylüyor. Bu fark küçük görünüyor ama uzun vadede birikmeli. ## Kapanış Bu günlüğün son yazısını yazıyorum. "Son" kelimesi ağır ama doğru. On iki yıl boyunca her ay bir şey yazmak tamamlandı. 2014'te Composer kullanmayı öğrenen o geliştirici ile bugünkü arasındaki mesafe sadece zaman değil. Onlarca proje, yüzlerce hata, birkaç dil, birkaç platform, bir sürü karar. Bu günlük o mesafenin bir belgesi oldu. Sonraki şey ne olacak bilmiyorum. Ama bu on iki yılın birikimini taşıyarak başlıyor olacak. ## Görünür olmak: yazmak neden bir kariyer yatırımıdır - Kaynak: https://muhammetsafak.com/tr/blog/gorunur-olmak-yazmak-neden-bir-kariyer-yatirimidir/ "Kişisel marka" ifadesi beni uzun süre rahatsız etti. Yine de [bu günlüğün on iki yılına dönüp baktığımda](/tr/blog/bu-gunlugun-on-iki-yili-geriye-donup-bakmak/) gördüğüm şey, yazmanın kariyerime bir pazarlama işi olarak değil, bambaşka bir yoldan dokunduğuydu. Kendini bir ürün gibi pazarlamak, her başarıyı LinkedIn gönderisi yapmak, sektörde isim yapmak için içerik üretmek — bunlar benim işle ilişkimi tanımlamıyordu. Ben yazılım yazıyordum, pazarlama değil. Ama bu ayrım zamanla daha kaba göründü. Görünür olmak ile kendini pazarlamak aynı şey değil. Ve bu farkı fark etmekte geç kaldım. ## Yazmak önce sizi düzeltir Bir konuyu yazmadan önce bildiğinizi sandığınız birçok şey vardır. Yazmaya başlayınca boşluklar ortaya çıkar: tanımlayamadığınız bir kavram, açıklayamadığınız bir mekanizma, içinde tutarsızlık barındıran bir argüman. Bu boşlukları okuyucu görmez; siz görürsünüz, yazarken. Bu günlüğe 2014'te başladım. İlk yazılarımı bugün okuduğumda hem teknik eksiklikleri hem de düşüncenin ham halini görüyorum. Ama o dönemde yazarak düşüncemi dışarıya çıkardım, geri baktım ve düzelttim. Bu döngü bilişsel bir zorunluluk, gösteriş değil. Şunu gösteren çok insan gördüm: yıllarca tecrübe edinmiş, işini gerçekten bilen, ama düşüncelerini hiç dışarıya çıkarmamış geliştiriciler. Bunlar güçlü insanlar. Ama bir konuyu açıklamaları istendiğinde, bir argümanı savunmaları gerektiğinde, aynı konuyu bağımsız iki farklı kişiye anlaşılır kılmaları gerektiğinde zorlanıyorlar. Çünkü düşünce ifade edilerek olgunlaşıyor; sadece kafada tutularak değil. ## İkinci fayda: birikim Yazmak kısa vadede görünür etki üretmez. Bu günlüğü ilk yıllarda okuyan insan sayısı çok azdı, büyük ihtimalle. Ama 2014'ten bu yana birikmiş on yılı aşkın içerik bugün farklı bir şeye dönüştü: bir iz. Bu iz birkaç şeyi somutlaştırıyor. Düşüncelerimin zaman içinde nasıl değiştiğini görebiliyorum. Bir teknolojiyi ya da yaklaşımı farklı dönemlerde farklı nasıl değerlendirdiğimi görebiliyorum. Ve başkaları da görebiliyor. Bir işe başvurduğunuzda ya da bir iş teklifi geldiğinde özgeçmiş yeterli olmuyor. Özgeçmiş ne yaptığınızı söylüyor; yazılar nasıl düşündüğünüzü söylüyor. Bu iki şey çok farklı. Bunun ötesinde: zaman içinde birikmeli bir referans oluşuyor. Bir konuda fikir sahibi olduğunuzda daha önce o konuyu yazmış olmak, o görüşe güvenilirlik katıyor. Bu güvenilirlik bir gecede inşa edilmiyor, yazılar birikerek oluşuyor. ## Kişisel marka değil, dürüst iz "Kişisel marka" kavramının rahatsızlığı nereden geliyor? Bence marka kelimesi strateji gerektirir: ne söyleneceğini, hangi imajın oluşturulacağını, nasıl algılanmak istendiğini hesaplamak. Bu yaklaşımda içerik araç, hedef imaj. Dürüst bir iz ise farklı. Öğrendiğinizi yazıyorsunuz, düşündüğünüzü yazıyorsunuz. Zaman zaman yanılıyorsunuz, bunu da yazıyorsunuz. Birikim tutarsızlıklar da içeriyor; çünkü siz değiştiniz, görüşleriniz değişti. Bu dürüstlük marka söylemlerinden çok daha güçlü bir şey inşa ediyor. Bu günlük benim "kişisel markam" değil. Benim yazmayı öğrendiğim, düşüncelerimi dışarıya çıkardığım ve zaman içinde neyin değiştiğini gördüğüm bir alan. ## Neden insanlar yazmıyor Engel genellikle mükemmeliyetçilik oluyor. "Hazır değilim", "bu konu başkası tarafından daha iyi yazılmış", "yeterince özgün değil". Bu düşünceler tanıdık. Ama şunu fark ettim: okuyucu mükemmelliği aramıyor, dürüstlüğü arıyor. Aynı sorunu yaşamış biri, aynı karışıklığı hissetmiş biri. Mükemmel teknik rehberler zaten var; sizin yazdığınız sizin bakış açınız ve bu kendi değerini taşıyor. Tutarlılık yeteneği geçiyor. Ayda bir kez, sene boyunca yazıyorsanız on iki yazı birikmiş olacak. Mükemmel bir yazı ikiyi geçmiyor. Tutarlılığın birikmeli etkisi, parlak ama tek seferlik içeriklerden çok daha güçlü. ## Bu günlüğün on iki yılı Bu yazıyı yazarken bu günlüğün kendisinin bir örnek olduğunu fark ediyorum. 2014'te başladım. İlk yazılar ham, konu seçimleri dağınık, üslup olgunlaşmamış. Ama devam ettim. Her ay. Bugün bu birikimi silsem bile içimde bıraktığı şey siliniyor. On iki yıl boyunca her ay düşünmeyi, yazmayı, dışarıya çıkarmayı alışkanlık yaptım. Bu alışkanlık teknik bilgiden çok daha kalıcı bir şey. Eğer yazmak istiyor ama nereden başlayacağınızı bilmiyorsanız, tavsiyem basit: bu hafta öğrendiğiniz bir şeyi yazın. Mükemmel olmasına gerek yok. Kısa olabilir. Ama başlamanın maliyeti beklememenin maliyetinden her zaman daha düşük. Görünür olmak kendinizi pazarlamak değil. Düşüncelerinizi çalışır hale getirmek ve zamanla bunun birikmesine izin vermek. ## Polyglot olmak bir hedef değil bir sonuçmuş - Kaynak: https://muhammetsafak.com/tr/blog/polyglot-olmak-bir-hedef-degil-bir-sonucmus/ ["Polyglot geliştirici" ifadesini](/tr/blog/polyglot-bir-gelistirici-olmak-dili-probleme-gore-secmek/) ilk duyduğumda bunu bir kimlik gibi benimsemek istedim. Birden çok dil bilen, her probleme uygun araçla yaklaşan, tek bir ekosisteme mahkûm kalmayan geliştirici. Kulağa çekici geliyordu. Ama geriye dönüp baktığımda polyglot olmak böyle oldu değildi. Bir hedef belirleyip o yönde yürümedim. Daha doğrusu, şöyle oldu: bir problem geldi, elimin altındaki araç yetersiz kaldı, farklı bir şey öğrendim. Bu birkaç kez tekrarlandı. Bir noktada etrafıma bakınca birden fazla dil biliyordum. İşte bu kadar. ## Go nasıl girdi Go hikâyesi somut. 2019'da bir komut satırı aracı yazmam gerekiyordu. PHP de yazabilirdim elbette, ama deployment için bağımsız bir binary üretmek istiyordum — çalıştıran kişinin PHP yüklemesine gerek kalmadan. Go'nun derlediği şeyin tek ve taşınabilir bir ikili olduğunu öğrendim. Denedim. Çalıştı. Sonrasında Go'nun goroutine ve channel modelini de merakla incelediğimde dilin eşzamanlılık yaklaşımının ne kadar temiz tasarlandığını gördüm. PHP'nin varsayılan olarak tekil süreçli dünyasından gelince bu oldukça farklı hissettirdi. Ama bugün bir web uygulaması yazmak için Go seçmiyorum. HTTP servisi Go ile de yazılır, evet; ama Laravel ekosistemi benim için çok daha üretken. Go'yu hâlâ araç yazmak, küçük servisler ve sistem etkileşimi gerektiren durumlar için tutuyorum. ```go // Go ile basit bir dosya satır sayacı package main import ( "bufio" "fmt" "os" ) func countLines(path string) (int, error) { f, err := os.Open(path) if err != nil { return 0, err } defer f.Close() count := 0 scanner := bufio.NewScanner(f) for scanner.Scan() { count++ } return count, scanner.Err() } func main() { if len(os.Args) < 2 { fmt.Fprintln(os.Stderr, "kullanım: satir-say ") os.Exit(1) } n, err := countLines(os.Args[1]) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } fmt.Printf("%d satır\n", n) } ``` Bu küçük araç PHP ile de yazılırdı. Ama bu ikili dosya olarak dağıtılıyor, kurulum gerektirmiyor. Go'yu seçmemin nedeni buydu; dil merakı değil. ## Python nasıl girdi Python 2020'de girdi. Yine somut bir problem: CSV dosyalarından veri çekmek, dönüştürmek ve farklı formatlarda kaydetmek. Bunu PHP ile de yapabilirdim ama Python'da `pandas` ile aynı şeyi çok daha az satırda yapabildiğimi gördüm. Python'un script yazımı için ne kadar uygun olduğunu da burada öğrendim. Ayrıntıları düşünmeden hızlıca yazıp çalıştırabildiğiniz, sonucu hemen görebildiğiniz bir dil. PHP de betik yazılabilir ama Python bu konuda çok daha doğal hissettiriyor. Bugün Python'u veri dönüştürme, metin işleme ve otomasyon betikleri için kullanıyorum. Web uygulaması yazmak için seçmiyorum. ## Dil bilmek ne anlama gelir Birden fazla dil "bilmek" derken ne kastediyorum? Bence burada dürüst olmak gerekiyor. PHP'de uzmanım. Dilin inceliklerini, ekosistemi, kalıpları ve tuzakları tanıyorum. On sekiz yıl birikiyor. Go'da yeterliyim. Araç yazabiliyorum, HTTP servisi yapabiliyorum, dili okuyabiliyorum. Ama Go'nun daha derin eşzamanlılık senaryolarında uzman olmadığımı biliyorum. Python'da işini görür durumdayım. Betik yazıyorum, veri işliyorum. Ama Python ile büyük uygulama yazmak istesem öğrenmem gereken çok şey olduğunu da biliyorum. Bu ayrım önemli. Polyglot olmak "her dili eşit derinlikte bilmek" değil, "[farklı problemler için farklı araçlara erişebilmek](/tr/blog/bir-yilda-dort-dil-polyglot-pratigin-getirdigi-zihin-esnekligi/)" demek. Derinlik her dilde aynı olmuyor ve olmasına da gerek yok. ## Hedef olarak polyglot'un tuzağı Birden fazla dil öğrenmek bir hedef olarak belirlendiğinde tehlikeli bir yola giriliyor. "Bu yıl Rust öğreneceğim" diye karar vermek, o dili gerçekten kullanacak bir problem olmadan yüzeysel bir tanışıklıktan öteye gidemiyor. Dil ancak probleme çözüm ararken gerçekten özümseniyor. Bence polyglot olmak şu şekilde oluyor: Bir probleme bakıyorsunuz. Elinizdeki araç yetersiz kalıyor ya da o araçla çözüm üretmek çok maliyetli hissettiriyor. Alternatif arıyorsunuz. Buluyorsunuz, öğreniyorsunuz, kullanıyorsunuz. Bu birkaç kez tekrarlandığında fark etmeden polyglot oluyorsunuz. Tersi, yani "önce dili öğreneyim, sonra problem bulurum" yaklaşımı çoğu zaman işe yaramıyor. Öğrenme yüzeysel kalıyor çünkü gerçek bir ihtiyaç yok. ## Bugünden geriye bakarken Bugün PHP, Go ve Python kullanıyorum. Bu üç dilin üçünü de kasıtlı olarak öğrenmeye oturmadım; problemler beni bu dillere götürdü. Ve her biri ayrı bir alan kaplıyor: PHP uygulama geliştirme için, Go araç ve küçük servisler için, Python otomasyon ve veri işleme için. Bu dağılım rastlantı değil, birikimidir. Her dil öğrenildiğinde bir öncekine de farklı bakmaya başlıyorsunuz. Go'nun derleme güvenliği PHP'deki tip sistemini daha ciddi ele almama neden oldu. Python'un okunabilir sözdizimi, diğer dillerde de gereksiz karmaşıklıktan kaçınmama katkı sağladı. Polyglot olmak bir kimlik değil, bir sonuç. Ve bu sonuçla barışık olunca, yeni bir dil öğrenmek de daha sağlıklı bir yere oturuyor: yalnızca bir problemi çözmek için, zamanı gelince. ## Laravel'le on iki yıl: bir framework'le büyümek - Kaynak: https://muhammetsafak.com/tr/blog/laravelle-on-iki-yil-bir-frameworkle-buyumek/ [2014 yılında Laravel 4 ile tanıştığımda](/tr/blog/laravel-4-ile-ilk-projeme-baslarken/) framework (çerçeve) kullanmak benim için gerçek bir geçiş anlamına geliyordu. Ham PHP yazıyordum, dosyaları `include` ile birbirine bağlıyordum ve veritabanı işlemlerini elle yazılmış SQL sorguları yönetiyordu. Laravel'in ilk benim üzerimde bıraktığı etki, her şeyin bir yeri olduğunu hissetmesiydi. Route, controller, model, view — herkes yerindeydi ve bir arada çalışıyordu. On iki yıl sonra Laravel hâlâ kullanıyorum. Bu süre içinde framework de değişti, ben de. İlişkiyi geriye dönüp değerlendirirken ilginç bir örüntü görüyorum: olgunlaşma birlikte gerçekleşti. ## İlk dönem: framework'ün bize öğrettikleri Laravel 4 ve 5 döneminde framework bana çok şey öğretti. [Eloquent ORM](/tr/blog/eloquent-orm-ile-veritabani-islemlerini-sadestirmek/), [middleware](/tr/blog/laravel-middleware-ile-isteklere-ortak-davranis-eklemek/), [service provider](/tr/blog/laravelde-servis-saglayici-ve-containeri-dogru-kullanmak/), [event ve listener](/tr/blog/laravel-de-olaylar-events-ve-dinleyiciler/) — bunların hepsi soyut kavramlardı ama Laravel bunları somut, elle tutulur bir şekilde sunuyordu. O dönemde framework'ün kısıtlarına da çarptım. Büyük controller sınıfları, her şeyin Eloquent modeline dolduğu şişik modeller, `app/Http/Controllers` klasöründeki kaos. Bunlar framework'ün değil benim hatalarımdı, ama Laravel'in düzeni aynı zamanda bu hatalara da yol açıyordu — kolaylık bazen tembelliği getiriyor. ## Orta dönem: framework'ü anlamak Senior dönemimde Laravel ile ilişkim değişti. Artık Laravel'i kullanmak değil, Laravel'i anlamak istiyordum. Service container nasıl çalışıyor? Eloquent'in lazy loading mekanizması tam olarak ne yapıyor? Middleware pipeline nasıl akıyor? Bu soruları sormak, framework'ü daha iyi kullanmamı sağladı ama aynı zamanda sınırlarını da net görmemi. Bazı şeyler Laravel'de gereksiz yere karmaşık ya da gereksiz yere sihirli. Magic özellikler hızlı prototip için güzel, ama büyük bir codebase içinde nereye bakacağını bilmek zorlaşabiliyor. Bu dönemde action class ve form request gibi yaklaşımları framework'ün önerdiği kalıpların dışında tercih etmeye başladım. Composer ile gelen topluluk paketlerini daha seçici değerlendiriyordum. Laravel'in her sunduğunu kullanmak zorunda olmadığımı kavradım. ## Son dönem: araçla barışmak Staff seviyesine geldiğimde Laravel ile ilişkim üçüncü bir aşamaya geçti: barışmak. Bir framework'ün her tasarım kararını beğenmek zorunda değilsiniz. Bazı şeyleri beğenmeyebilirsiniz, bazı tasarım seçimlerini farklı yapardınız diyebilirsiniz — ama yine de o araçla çalışabilirsiniz. Laravel 10 ve 11 ile gelen değişikliklere bu bakış açısıyla yaklaştım. Skeleton ve stub'larda yerel type declaration'lar, skeleton'ı inceltme çabası, Pulse ve Reverb gibi eklemelerin sınırlarını öngörebilmek — bunlar artık "bu harika mı, berbat mı?" sorusundan çıkıp "bu benim probleme uyuyor mu?" sorusuna dönüştü. ```php withRouting( web: __DIR__.'/../routes/web.php', api: __DIR__.'/../routes/api.php', ) ->withMiddleware(function ($middleware) { $middleware->api(append: [ \App\Http\Middleware\EnsureTokenIsValid::class, ]); }) ->create(); ``` Bu kod üç yıl önce Laravel'de yoktu. İskelet inceldikçe yapılandırma daha açık hale geliyor; sihir azalıyor. Bu benim beğendiğim bir yön. ## Bir araçla büyümenin anlamı Bence bir araçla uzun süre birlikte kalmak, onu körce benimsemek değil. Aynı zamanda ona belirli beklentilerle yaklaşmayı da bırakmak. Framework bir araçtır; problemi olan benim, çözüm üreten benim. Framework sadece o çözümü ifade etmek için bir platform. On iki yılda Laravel'i birden çok kez hayal kırıklığına uğrattım ve o da beni. Her büyük sürüm atlamasında migration sancısı oldu. Her yeni özelliğin bir öğrenme maliyeti vardı. Ama toplamda bu araç benim kariyerimin önemli bir bölümünü taşıdı. Farklı diller ve framework'ler denedim. Go, Python, React, Vue — bunların hepsini kullandım ve hepsinden bir şeyler aldım. Ama bir PHP geliştiricisi olarak Laravel benim için birincil araç kalmaya devam etti. Bunun nedeni, on iki yıl boyunca ekosistemi de, dili de, topluluğu da birlikte olgunlaşmış olması. O olgunluk birikmeli ve değerlidir. Yeni bir projeye başlarken Laravel seçimini hâlâ düşünmeden verdiğim seçimler arasında değil; her seferinde kısa bir değerlendirme yapıyorum. Ama çoğu zaman o değerlendirme yine Laravel'e işaret ediyor. On iki yıl bu sebeple makul bir ağırlık taşıyor. ## 2026'da PHP ile uygulama geliştirmek: ekosistemin hali - Kaynak: https://muhammetsafak.com/tr/blog/2026da-php-ile-uygulama-gelistirmek-ekosistemin-hali/ "PHP öldü" cümlesini ilk duyduğumda galiba 2012 ya da 2013'tü. O günden beri bu cümleyi her yıl yeniden duyuyorum — bazen Node.js'in yükselişiyle birlikte, bazen Python'ın veri bilimi dalgasında, bazen de "modern web" söylemlerinin altında. Aradan on yılı aşkın zaman geçti. PHP hâlâ burada, hâlâ yazılıyor ve hâlâ işler üretiyor. Bunu bir savunma olarak yazmıyorum. Bir dilin "ölmediğini" kanıtlamak için vakit harcamak verimli değil. Bunun yerine şunu yazmak istiyorum: 2026'da PHP ile uygulama geliştirmek nasıl bir şey ve ekosistem gerçekten nerede duruyor? ## Dil artık başka bir dil PHP 5.x dönemine dair eleştirilerin büyük çoğunluğu hâlâ tekrarlanıyor, ama o dönemden bu yana dilin üstüne birkaç kat daha konum geldi. PHP 8.0 ile gelen `union type`, `match` ifadesi ve `nullsafe operator` sade birer sözdizim şekeri değildi — bunlar dilin tip sistemine yapılan ciddi yatırımlardı. [PHP 8.1'deki `enum` ve `readonly property`](/tr/blog/php-8-1-yolda-enumlar-ve-readonly-ile-daha-guvenli-modeller/), [PHP 8.2'nin `readonly class` desteği](/tr/blog/php-8-2-yolda-readonly-siniflar-ve-yeni-tipler/) ve [PHP 8.4'ün `property hook` mekanizması](/tr/blog/php-8-4-yolda-property-hooklar-ve-asimetrik-gorunurluk/) art arda geldi. [PHP 8.4 sürüm sayfası](https://www.php.net/releases/8.4/en.php) bunların sonuncusunu şöyle tanımlıyor: "Property hooks provide support for computed properties that can natively be understood by IDEs and static analysis tools, without needing to write docblock comments that might go out of sync." Bugün PHP'de yazdığım kod, 2014'te yazdığım kodla aynı dil görünümünü taşısa da neredeyse farklı bir şey. Tip güvenliği var, value object'ler temiz yazılabiliyor, `enum` domain katmanını anlamlı kılıyor. Bunlar küçük eklemeler gibi görünebilir ama uygulama yazarken hissettikleri birikimli bir fark yaratıyor. ```php $this->amount * 100; } } $price = new Money(25, 'TRY'); echo $price->inCents; // 2500 ``` Bu kod PHP'den beklenmedik bir şeydi, değil mi? Ama 2026'da bu olağan PHP. ## 2026'da PHP ekosistemi ne kadar sağlıklı? Composer'ın Packagist kayıt defterinde şu an 470.000'den fazla paket listeleniyor ([Packagist: Statistics](https://packagist.org/statistics)) ve sayı büyümeye devam ediyor. PHP'nin kütüphane ekosistemi, birçok dile kıyasla çok daha olgun ve kullanılabilir durumda. Framework tarafında Laravel tek lider olmaktan çıkmıyor. Symfony ekosistemi kurumsal çevrelerde güçlü duruyor. Slim ve Lumen gibi mikro seçenekler, Pest gibi test araçları ve çok iyi tasarlanmış paketler mevcut. Livewire ve Inertia.js (tek sayfa uygulamalar için bir köprü katmanı) sayesinde PHP kökenli geliştiriciler, ayrı bir JavaScript uygulaması yazmak zorunda kalmadan modern arayüzler sunabiliyor. PHP-FPM performansı, RoadRunner (kalıcı süreç PHP çalıştırıcısı) ve Laravel Octane ile birleştiğinde gerçekten rekabetçi seviyelere çıkıyor. "PHP yavaştır" söylemi de artık 2026 için geçerli değil; doğru yapılandırmayla PHP servisleri çok makul iş yüklerini kaldırıyor. ## "Öldü" söylemi nereden geliyor? Bu söylemin bir kısmı meşru tarihsel bir eleştiriye dayanıyor: PHP 4-5 dönemi gerçekten dağınıktı. `register_globals`, tutarsız fonksiyon adlandırması, tip yokluğu — bunlar gerçek sorunlardı. Ama bir dilin geçmişteki sorunlarını bugünkü kararlarınıza taşımak, 2010'dan bu yana değişmeyen bir fotoğrafa bakmak gibi. Bir kısmı ise ekosistem dinamiklerinden kaynaklanıyor. JavaScript topluluğu gürültülüdür, çok konuşur. PHP topluluğu sessizce iş üretir. Sessiz olan daha az görünür olur; daha az görünür olan "yokmuş gibi" algılanır. Bu bir algı meselesi, gerçeklik meselesi değil. ## Hangi problemler için hâlâ doğru seçim PHP'nin güçlü kaldığı alan bence hâlâ web uygulamaları ve API geliştirmedir. İçerik yönetimi, SaaS ürünleri, monolitik uygulamalar, çok sayfalı uygulamalar — bunlarda PHP ekosistemi hem üretkenlik hem sürdürülebilirlik açısından rekabetçi. Eğer Go veya Rust gibi düşük seviyeli sistem performansı gerekiyorsa, orada PHP'nin yeri yok. Eğer veri bilimi veya makine öğrenmesi pipeline yazıyorsanız, Python daha uygun. Ama bunlar PHP'nin iddia ettiği alanlar da değil. ## Nerede duruyorum On iki yıldır PHP yazıyorum. Bu süre içinde Go, Python ve TypeScript de öğrendim ve kullandım. Ama PHP bugün hâlâ birincil dilim. Bunun nedeni alışkanlık değil; PHP'nin uygulama geliştirme için sunduğu olgunluk ve ekosistem derinliği, 2026'da hâlâ pratik bir tercih olmasını sağlıyor. Bir dilin değerini onun hakkında yazılan haber sayısına ya da konferans konuşmalarına göre ölçmek yanlış. Değer, problem çözme kapasitesi ve ekosistem sağlığıyla ölçülür. Bu ölçütle PHP, 2026'da oldukça iyi bir yerde duruyor. --- ### Kaynaklar - [PHP 8.0 sürüm sayfası, erişim 2026-10-07](https://www.php.net/releases/8.0/en.php) - [PHP 8.4 sürüm sayfası, erişim 2026-10-07](https://www.php.net/releases/8.4/en.php) ## On bir yılın ardından: bir geliştiricinin değişen önceliği - Kaynak: https://muhammetsafak.com/tr/blog/on-bir-yilin-ardindan-bir-gelistiricinin-degisen-onceligi/ Bu günlüğü 2014'te tutmaya başladım. On bir yıl, bir geliştiricinin nasıl değiştiğini görmek için yeterince uzun bir süre. Yıl sonuna yaklaşırken, geçmiş yazılarıma dönüp baktım ve değişen şeyin teknolojiler olmadığını fark ettim — onlar zaten değişir. Değişen, neye öncelik verdiğimdi. ## Hızdan dayanıklılığa İlk yılların yazılarında tekrar eden bir kelime var: hız. Bir şeyi ne kadar hızlı kurabildiğim, bir aracın işimi ne kadar hızlandırdığı. O dönemde iyi geliştirici, hızlı teslim eden geliştiriciydi. Bugün hız hâlâ önemli ama artık birincil ölçü değil. Şimdi bir çözüme bakarken sorduğum ilk soru şu: bu, altı ay sonra değiştirilmesi gerektiğinde ne kadar acıtacak? Hızlı yazılmış ama dokunulamaz bir kod, yavaş yazılmış ama esnek bir koddan pahalıya geliyor. Önceliğim hızdan **dayanıklılığa** kaydı. Bu kayma kademeli oldu. Bir noktada, "bu nasıl çalışır?" sorusundan çok "bu neden bu şekilde çalışıyor?" ve "bunu başkası okuyunca ne anlar?" soruları sormaya başladığımı fark ettim. Kodun bugün işe yaraması yetmez; altı ay sonra onu değiştirmek zorunda kalan birinin — çoğunlukla kendimin — işini kolaylaştırması gerekiyor. ## Koddan karara Mid-level dönemimde işimin merkezinde kod vardı — yazmak, çalıştırmak, düzeltmek. Yazılarım da öyleydi: "şunu nasıl yaptım". Yıllar içinde işimin ağırlık merkezi koddan karara kaydı. Bugün değer kattığım yer, çoğu zaman bir satır kod değil; "bunu yapmayalım", "bunu şimdi değil", "bu problemi şu dille çözelim" gibi kararlar. Kod, kararın yalnızca son adımı. Yazdığım en iyi şeylerden bazıları, yazmadığım kodlar oldu. Bu değişimin en belirgin göstergesi şu: eskiden bir toplantıda soru sormak için sırama beklerdim. Şimdi toplantıyı yönlendirmek, soru sormak yerine çerçeve kurmak benim işim haline geldi. Teknik kararlar hâlâ benim alanım; ama o kararların neden alındığını, kiminle nasıl paylaşıldığını, hangi sırayla hayata geçirileceğini düşünmek de işin içine girdi. ## Tek dilden çok dile [Yedi yıl önce kendimi bir PHP geliştiricisi olarak tanımlıyordum](/tr/blog/2018in-muhasebesi-bir-php-gelistiricisi-nereye-geldi/). Bugün PHP hâlâ omurgam ama kendimi bir dille tanımlamıyorum. Go, Python, JavaScript dünyasında da çalışıyorum. Bu, bir koleksiyon hevesi değil; her problemin kendi diline yöneldiğim bir noktaya geldim. [Polyglot olmak](/tr/blog/polyglot-bir-gelistirici-olmak-dili-probleme-gore-secmek/), bir hedef değil yılların doğal bir sonucu oldu. Her yeni dili öğrenirken beklenmedik bir şey fark ettim: yeni bir dil, ana dilde nelerin "dilin kendisi" olduğunu, nelerin "alışkanlık" olduğunu gösteriyor. Go'da hata yönetimini öğrenmek, PHP'de istisnaları nasıl kullandığımı ve ne zaman gereksiz yazdığımı yeniden sorgulatı. Dil öğrenmek, salt kelime hazinesi değil; farklı düşünme pratikleri edinmek. ## Değişmeyen şey Bunca değişimin içinde değişmeyen bir şey var: öğrenmenin sürmesi. On bir yıl önce de yeni bir şey öğreniyordum, bugün de. Fark şu ki artık her yeni şeyi öğrenmem gerekmediğini biliyorum; neyi öğrenmeyeceğime karar vermek de bir beceri. Bir de yazmak değişmedi. Bu günlük on bir yıldır benim için bir düşünme aracı oldu. Bir şeyi yazana kadar onu tam anlamadığımı defalarca gördüm. ## Sonraki yıl Önceliklerimin nereye kayacağını tam bilmiyorum. Ama yönü tahmin edebiliyorum: daha az "nasıl", daha çok "neden" ve "ne zaman değil". On bir yıl önce iyi bir geliştirici olmak, çok şey yapabilmekti. Bugün, doğru şeyi seçebilmek. Bu günlük, o değişimin kaydı olmaya devam edecek. ## Yapay zeka çağında kod yazmanın değişen el becerisi - Kaynak: https://muhammetsafak.com/tr/blog/yapay-zeka-caginda-kod-yazmanin-degisen-el-becerisi/ [İlk yapay zeka kod aracını denememin](/tr/blog/chatgpti-bir-gelistirici-olarak-ilk-kez-kullanmak/) üzerinden yaklaşık üç yıl geçti. O zaman bir merak, bugün günlük işin sıradan bir parçası. Bu süre yeterince uzun oldu; artık "bu araçlar işe yarar mı?" sorusunun ötesine geçip daha gerçek bir soruyu sorabiliyorum: bu araçlar kod yazmanın *hangi* kısmını devraldı, geriye geliştiriciye ne kaldı? ## Devralınan kısım: üretim Açık olan şu: kodun fiziksel üretimi artık ucuzladı. Bir fonksiyonun iskeletini kurmak, bilinen bir kalıbı yazmak, bir testin tekrar eden kısmını doldurmak — bunlar eskiden zaman alırdı, şimdi almıyor. On yedi yıldır bu işi yapıyorum ve daktilo hızının hiçbir zaman gerçek darboğaz olmadığını biliyordum. Şimdi bu iyice belli oldu. Eğer bir geliştiricinin değeri "kodu hızlı yazabilmek" idiyse, o değer eridi. Ama benim için hiçbir zaman orada değildi. ## Kalan kısım: muhakeme Değişmeyen, hatta önemi artan şey muhakeme. Araç bir çözüm öneriyor; [o çözümün doğru çözüm olup olmadığına karar vermek](/tr/blog/yapay-zekanin-kod-yazmaya-etkisini-ilk-kez-ciddiye-almak/) hâlâ geliştiricinin işi. Ve bu karar, üç ayrı beceri istiyor: - **Problemi doğru kurmak.** Araç, sorduğunuz soruyu yanıtlar; yanlış soruyu sorarsanız kusursuz ama yararsız bir cevap alırsınız. Asıl iş, soruyu doğru çerçevelemekte. - **Çıktıyı denetlemek.** Üretilen kod, kendinden emin bir tonla yanlış olabilir. Onu okumak, anlamak, kenar durumlarını sınamak — bu beceri eskisinden daha kritik. - **Bütünü görmek.** Araç, önündeki parçayı çözer; o parçanın sistemin geri kalanıyla nasıl oturduğunu görmek hâlâ insanın işi. ## Değişen el becerisi Yani el becerisi yok olmadı; yer değiştirdi. Eskiden ustalık, çözümü zihinden koda akıcı biçimde geçirmekti. Şimdi ustalık, bir çözümü değerlendirmek, kötü olanı hızlı reddetmek ve iyi olanı bütüne yerleştirmek. Daha az "yazma", daha çok "yargılama". Bunun bir tehlikesi var: muhakeme, kullanılmazsa körelir. Bir junior, üretimi tümüyle araca devrederse, çıktıyı denetleyecek birikimi hiç kazanamayabilir. Bu yüzden hâlâ, bilmediğim bir konuyu önce kendim eşeleyip sonra araca soruyorum. Aracı bir hızlandırıcı olarak kullanıyorum, bir ikame olarak değil. ## Aracın güvenli göründüğü yer yanlış olabilir Son aylarda fark ettiğim bir örüntü var: araç en güvenli göründüğünde en dikkatli olmak gerekiyor. Yaygın kalıpları — bir REST uç noktası, bir form doğrulaması, bir veritabanı sorgusu — sağlam yazıyor. Ama problemin kendine özgü bir boyutu varsa, araç bunu görmeden "standart" bir çözüm üretiyor; ve standart çözüm, sizi sormadığınız bir problemin cevabını veriyor. Geçen ay bir para dönüştürme fonksiyonu için önerilen kod, ondalık hassasiyeti float aritmetiğiyle yönetiyordu. Küçük miktarlarda sorunsuz çalışırdı; büyük miktarlarda yuvarlama hatası birikir ve yanlış hesaplama üretirdi. Araç bunu güvenli görünen bir biçimde yazmıştı; fark etmek, o alandaki geçmiş deneyimi gerektirdi. Bu durum, araçla ilişkinin sağlıklı kalması için önemli bir şeyi işaret ediyor: eleştirel mesafeyi korumak. Araç ne kadar iyi olursa olsun, çıktısını doğrulamak geliştiricinin vazgeçilmez sorumluluğu olmaya devam ediyor. "Araç yazdı, doğrudur" düşüncesi, deneyim yıllarını en hızlı biçimde etkisizleştiren düşünce tarzı. ## Sonuç Yapay zeka, kod yazmayı bitirmedi; kod yazmanın *değerli kısmını* daha görünür kıldı. O kısım her zaman muhakemeydi — ne yapılacağına, neden yapılacağına ve doğru yapılıp yapılmadığına karar vermek. Üretim ucuzladıkça, yargı pahalılaştı. Geliştirici olarak yatırım yapmaya devam edeceğim yer, tam olarak orası. ## Mentorluk ve bilgi paylaşımı: başkasını büyütürken büyümek - Kaynak: https://muhammetsafak.com/tr/blog/mentorluk-ve-bilgi-paylasimi-baskasini-buyuturken-buyumek/ Kıdemli geliştiriciler arasında yaygın bir varsayım var: mentorluk, kendi işinden çalıp başkasına verdiğin bir fedakârlıktır. Yıllar içinde bu varsayımın yanlış olduğunu gördüm. Mentorluk bir fedakârlık değil; kıdemli bir geliştirici için kendi gelişiminin de en sağlam araçlarından biri. Bu yazı, neden böyle düşündüğümü anlatıyor. ## Öğretmek, anlamayı sınar Bir konuyu birine anlatana kadar onu gerçekten anladığınızı sanırsınız. Anlatmaya başladığınız an, bilginin içindeki boşluklar ortaya çıkar. "Burada neden böyle yapıyoruz?" sorusuna net bir cevap veremiyorsanız, o konuyu aslında ezberlemişsiniz demektir; anlamamışsınız. Bana sorulan sorular, yıllardır kendi kör noktalarımın haritası oldu. Bir junior'ın "bu neden gerekli?" sorusu, çoğu zaman benim alışkanlıktan yaptığım ama gerekçesini unuttuğum bir şeyi gün yüzüne çıkardı. Mentorluk, karşınızdakini büyütürken size kendi bilginizin gerçek sınırlarını gösteriyor. Somut bir örnek vereyim: bir junior geliştirici benden Laravel'de neden her zaman `$request->validated()` kullandığımı sordu. "Doğrulama geçmişi garantili veri" diye cevap verdim. Sonra ekledi: "Peki ya controller'ı formdan bağımsız olarak çağırdığımızda?" O ana kadar o soruyu hiç sormamıştım. Bir saatlik konuşma, benim form input güvenliği konusundaki varsayımlarımı yeniden gözden geçirmemi sağladı. ## İyi mentorluk cevap vermek değildir Yıllar içinde yaptığım en büyük hata, mentorluğu "doğru cevabı söylemek" sanmaktı. Cevabı vermek hızlıdır ve iyi hissettirir — ama karşınızdaki kişiye düşünmeyi değil, size bağımlı olmayı öğretir. İyi mentorluk, **düşünmeyi modellemektir.** Cevabı söylemek yerine, o cevaba nasıl ulaştığınızı yüksek sesle göstermek: "Ben şu an şunu merak ederdim, şuraya bakardım, şunu denerdim." Amaç, karşınızdakinin bir sonraki sefer size ihtiyaç duymamasıdır. İyi bir mentor, kendini gereksiz kılmaya çalışır. Bu geçişi yapmak düşündüğümden daha zor oldu. Cevabı bildiğinizde susup karşınızdakini düşünmeye bırakmak, özellikle zaman baskısı altında, sabır gerektiriyor. Ama "bak böyle yapılıyor" yerine "sen olsaydın ne yapardın, neden?" sorusuyla başlamak, o kişinin bir sonraki benzer sorunla karşılaştığında bağımsız davranmasını sağlıyor. ## Resmi program beklemeyin Mentorluk denince akla resmi eşleştirme programları gelir. Oysa gerçek mentorluğun çoğu, günlük işin içinde, küçük anlarda olur: - Bir pull request'e yorum yazarken "neden" sorusuna da yer açmak. - Bir sorunu birlikte çözerken ekranı paylaşıp düşünce akışını görünür kılmak. - Bir kararı belgelerken, yalnız sonucu değil gerekçeyi de yazmak. Bunların hiçbiri için bir unvana ya da programa ihtiyaç yok. Yalnızca bilgiyi kendinize saklamamaya karar vermeniz yeterli. Pull request yorumları bu açıdan özellikle güçlü bir araç. "Bu değiştirilmeli" yazmak ile "bu neden sorun, alternatif nedir, trade-off'ları neler" yazmak arasındaki fark büyük. İlki bir direktif, ikincisi öğretme anı. İkincisi daha uzun sürüyor, evet — ama o yorumu yazan kişi bir sonraki benzer kararı verirken o gerekçeyi hatırlıyor. ## Bilgi paylaşımının bir yan etkisi: netleşmek Başkasına bir şeyi anlatmak için önce kendinize anlatmanız gerekiyor. Yazmak da aynı işi yapıyor. Bu günlüğü yıllardır tutmamın bir sebebi bu: bir konuyu başkalarına anlatır gibi yazmak, o konuyu gerçekten oturtmayı zorluyor. Fikirler kafanızda net görünebilir; yazmaya başladığınızda eksik kaldığı noktalar kendiliğinden ortaya çıkıyor. Mentorluk konuşmaları da benzer bir netleşme etkisi yaratıyor. Birini bir konuda yönlendirirken kendinizi o konuyu daha yüksek çözünürlükte görürken buluyorsunuz. Bazen anlatırken fikrinizi değiştirdiğiniz bile oluyor. ## Bileşik getiri Bilgi paylaşımının kariyere etkisi yavaş ama bileşiktir. Büyüttüğünüz insanlar zamanla sizin güvenebileceğiniz, iş devredebileceğiniz kişiler olur. Açıkladığınız her kavram, sizin de o kavramı daha net tutmanızı sağlar. Ve bir süre sonra etrafınızda, sizin düşünme biçiminizi taşıyan bir ekip oluşur. Kıdem, bildiğiniz şeyleri saklayıp vazgeçilmez olmaya çalışmak değildir. Tam tersi: bildiğinizi öyle iyi aktarmaktır ki, ardınızda sizden bağımsız çalışabilen insanlar bırakırsınız. Başkasını büyütmek, [kıdemin](/tr/blog/kidem-unvanla-gelmeyen-sorumluluk/) bir yan ürünü değil; kıdemin kendisidir. ## Bir özelliği fikirden üç platforma: uçtan uca akışım - Kaynak: https://muhammetsafak.com/tr/blog/bir-ozelligi-fikirden-uc-platforma-uctan-uca-akisim/ Bir özelliği üç platformda birden teslim etmek — Laravel API, React web arayüzü ve React Native mobil uygulama — her seferinde ilk kez yapılan bir iş gibi görünmez artık. Bir ritim oluştu. Bu ritmi ilk kez bilinçli olarak yazmaya çalışıyorum; hem kendi referansım hem de benzer pozisyondaki başkalarına belki bir iz bırakır diye. Süreç zaman içinde olgunlaştı. Erken dönemde her özellik biraz kaotikti: hangi uçta başlamalıyım, ne kadar detaylı tanımlamalıyım, ne zaman koda geçmeliyim soruları net değildi. Şimdi bunların cevapları neredeyse otomatik. ## Başlangıç noktası: sözleşme Her yeni özellik için ilk adım API contract'ını yazmak. Kod yazmadan önce. Bir metin editöründe, bazen gerçek bir OpenAPI taslağında, bazen sadece birkaç paragraf notla: - Bu özellik hangi resource üzerinde çalışıyor? - Hangi endpoint'ler gerekiyor? - İstek ve yanıt gövdeleri nasıl görünüyor? - Hata durumları neler, HTTP durum kodları ne olacak? Bu adımı atlamak cazipor — "küçük bir özellik, hemen yazayım" dürtüsü her zaman var. Ama atladığımda web ve mobil taraf API'nin şekline sonradan uyum sağlamaya çalışıyor ve bu sürtünme pahalıya geliyor. ## Laravel tarafı: önce API Sözleşme netleşince [Laravel'de API katmanına](/tr/blog/bir-ozelligi-uc-katmanda-api-web-mobil-birden-teslim-etmek/) geçiyorum. Temel akış: **Migration ve model.** Yeni bir veri yapısı gerekiyorsa önce migration, ardından model. Relation'ları baştan doğru kurmak, ilerleyen aşamalarda düzeltme maliyetini düşürüyor. **Form Request ile doğrulama.** Doğrulama mantığını controller'da değil, ayrı bir Form Request sınıfında tutuyorum. Bu hem test edilebilirlik hem de controller'ı ince tutmak için: ```php class StoreEventRequest extends FormRequest { public function rules(): array { return [ 'title' => ['required', 'string', 'max:255'], 'starts_at' => ['required', 'date', 'after:now'], 'ends_at' => ['required', 'date', 'after:starts_at'], ]; } } ``` **Action sınıfı.** Business logic'i controller'dan ayırıp bir Action sınıfına taşımak, aynı mantığı birden fazla giriş noktasından (web controller, API controller, artisan komutu) çağırmayı mümkün kılıyor. **API Resource.** Model verisini doğrudan döndürmek yerine bir Resource sınıfı üzerinden biçimlendirmek. Hem field exposure'ı kontrol ediyor hem de yanıt yapısını API contract'ına kilitlemiş oluyor. ```php class EventResource extends JsonResource { public function toArray(Request $request): array { return [ 'id' => $this->id, 'title' => $this->title, 'starts_at' => $this->starts_at->toIso8601String(), 'ends_at' => $this->ends_at->toIso8601String(), ]; } } ``` ## TypeScript tipleri: köprü API sözleşmesi netleşince web ve mobil taraf için TypeScript tip tanımlarını yazıyorum. Bu tanımlar hem web hem React Native projesinde aynı: ```typescript interface Event { id: number; title: string; starts_at: string; ends_at: string; } interface CreateEventPayload { title: string; starts_at: string; ends_at: string; } ``` Bu tipleri ortak bir pakette ya da monorepo yapısında paylaşmak ideal; yoksa her iki projede ayrı tutuyorum ama kaynak olarak API sözleşmesini referans alıyorum. ## Web ve mobil: paralel, bağımsız API çalışır hale gelince web ve mobil tarafı paralel ilerletebiliyorum. İkisi aynı API'yi tüketiyor ama arayüz kararları bağımsız. Web tarafında `useQuery` ile veri çekme, form için `useActionState` (React 19 ile geldi) varsayılanım oldu. Mobil tarafta FlatList, navigasyon geçişleri ve platform bazlı davranış farklılıkları (iOS ve Android) ayrıca ele alınıyor. Bu farkları en baştan bekliyorum, sürpriz olarak karşılamıyorum. ## Test ve onay Bir özelliği tamamlanmış saydığım an: API'de unit/feature test var, web arayüzünde temel akış çalışıyor, mobilde iOS ve Android fiziksel cihazda onaylandı. Bu üç koşul sağlanmadan özellik "bitti" değil. Bu ritmin asıl değeri hız değil. Bir sorunun nerede çıktığını hızlıca izole edebilmek — API'de mi, web'de mi, mobilde mi — ve her katmanda bağımsız hareket edebilmek. Tekrarlanabilirlik bunu mümkün kılıyor. ## PHP 8.5 yolda: dilin olgunlaşan yüzü - Kaynak: https://muhammetsafak.com/tr/blog/php-85-yolda-dilin-olgunlasan-yuzu/ PHP 8.5 henüz yayımlanmadı; bu yazıyı Ağustos 2025'te yazıyorum ve sürüm yıl sonuna doğru bekleniyoru. Ama RFC süreçlerini takip edenler için şekillenen tablo oldukça net. PHP'nin bu adımla nereye gittiğini, geride bıraktığı yolda neyi öğrettiğini burada düşünmek istiyorum. > **Güncelleme · 2026-10-05** (yukarıdaki paragraf için) — PHP 8.5, 20 Kasım 2025'te yayımlandı; yukarıdaki "henüz yayımlanmadı" cümlesi yazıldığı günün durumunu anlatıyor. Yazıda sürüme girdiğini söylediğim pipe operatörü (`|>`) yayımlanan özellikler arasında yer aldı; sürümün öne çıkan özelliklerini [php.net'teki 8.5 sayfasında](https://www.php.net/releases/8.5/en.php) görebilirsiniz. Aşağıdaki "8.5'te neler şekilleniyor" bölümünde andığım lazy initializer geliştirmeleri ise o sayfadaki öne çıkan özellikler arasında yer almıyor. 2008'den beri PHP yazıyorum. Bu süreçte dilin geçirdiği dönüşümü içeriden izledim: PHP 5'in OOP devrimiyle başlayıp PHP 7'nin hız atlamasına, [PHP 8.0'ın attribute'larına ve match ifadesine](/tr/blog/php-8-0-ile-kod-yazmak-attribute-lar-ve-nullsafe-operator/), [8.1'in enum'larına ve readonly property'lerine](/tr/blog/php-8-1-yolda-enumlar-ve-readonly-ile-daha-guvenli-modeller/) kadar. Her sürüm küçük gibi görünüyor ama birikimi büyük. ## 8.x serisinde PHP nasıl değişti? PHP 8.0 dilin karakter değişiminin başlangıcıydı. Union type, nullsafe operator, match expression ve JIT (Just-In-Time) bir arada gelen büyük bir yenileme paketiydi. 8.1, readonly özellikler ve enum'larla sınıf modellemesini güçlendirdi. Immutable veri yapılarını PHP'de dil düzeyinde yazmak ilk kez bu kadar doğal hale geldi. 8.2, readonly sınıfları ve DNF (Disjunctive Normal Form) türlerini getirdi. 8.3 ise typed class constants ve `json_validate()` gibi pratik eklemelerle geldi. 8.4'te property hook'ları ve asymmetric visibility öne çıktı. Getter/setter yazma biçimini kökten değiştiren bu özellik, boilerplate kodu azaltırken açıklığı artırıyor. [PHP 8.3 sürüm sayfası](https://www.php.net/releases/8.3/en.php) typed class constants ve `json_validate()` işlevini, [PHP 8.4 sürüm sayfası](https://www.php.net/releases/8.4/en.php) `array_find()`, `array_find_key()`, `array_any()` ve `array_all()` işlevlerini listeliyor; [PHP kılavuzu](https://www.php.net/manual/en/language.oop5.lazy-objects.php) da şunu not ediyor: "Lazy objects were introduced in PHP 8.4." ## 8.5'te neler şekilleniyor RFC sürecinde öne çıkan birkaç öneri var. Bir kısmı çoktan kesinleşti, bir kısmı ise hâlâ tartışmada. **Pipe operatörü (`|>`).** F# ve Elixir'den aşina olunan bu operatör, fonksiyon zincirini soldan sağa okunabilir biçimde yazmanın önünü açıyor. PHP topluluğu bu konuda uzun süre konsensüse varamadı; RFC nihayet gereken üçte iki çoğunluğu aşarak kabul edildi ve operatör 8.5'e alındı. Dil yüzeyini genişletmek ile sadeliği korumak arasındaki gerilim burada da beliriyor. **Lazy initializer geliştirmeleri.** 8.4 ile gelen lazy object desteğinin üzerine inşa edilen yeni öneriler, nesne başlatma maliyetini daha fine-grained kontrol etmeyi mümkün kılabilir. **`array_*` fonksiyon ailesine eklemeler.** PHP'nin array API'si zengin ama bazı yaygın işlemler için hâlâ çok fazla satır gerekiyor. `array_find()` ve `array_find_key()` bu boşluğu doldurmak için 8.4 ile eklendi. ## Olgun bir dil nasıl görünür? PHP'nin evriminde artık belirgin bir örüntü var: büyük sürprizer değil, tutarlı iyileştirmeler. Bu olgunluğun işareti. Bir dilin erken döneminde her büyük sürüm paradigma değiştirebilir. Olgun bir dilde bu değişimler daha ince, daha planlı olur. Geriye dönük uyumluluk öncelik kazanır; milyonlarca satır kodu çalışır tutmak, radikal yenilikleri sindirmekten daha önemli hale gelir. PHP bu dengede dikkatli bir çizgi izliyor. Her sürümde breaking change'ler var ama bunlar önceden duyuruluyor, deprecation süreçleri yönetiliyor. 8.0'da deprecated edilen işlevlerin kaldırılması ise 8.5 ile değil, ancak bir sonraki ana sürümle gelecek; minor sürümlerde geriye dönük uyumluluk korunuyor — bu temizlik rutini de dilin olgunluğunun bir parçası. ## Tip sisteminin derinleşmesi PHP'nin son birkaç sürümünde en tutarlı yatırım yaptığı alan tip sistemi. Her sürümde tipler daha güçlü, daha ekspresif hale geliyor. Bu yatırımın somut etkisi: statik analiz araçları — özellikle Psalm ve PHPStan — artık PHP kodundaki hataların büyük bölümünü çalıştırmadan önce yakalayabiliyor. ```php // PHP 8.x'in tip ifade kapasitesi function processResult(int|string $id): User|null { // ... } // readonly ile değişmez modeller readonly class Money { public function __construct( public readonly int $amount, public readonly string $currency, ) {} } ``` Bu kod beş yıl önce PHP'de mümkün değildi. Şimdi hem dil sözdizimi hem de araç desteği açısından yerli yerinde görünüyor. PHP'nin nereye gittiğini anlamak için 8.5 çıktıktan sonra değerlendirme yapabilirim. Ama şimdiden söyleyebileceğim şu: 2008'de başladığım dil ile bugün yazdığım dil örtüşüyor, ama özellikleri ve araçları bakımından neredeyse tanınmayacak kadar farklı. Bu iyi bir şey. --- ### Kaynaklar - [PHP 8.3 sürüm sayfası, erişim 2026-10-07](https://www.php.net/releases/8.3/en.php) - [PHP 8.4 sürüm sayfası, erişim 2026-10-07](https://www.php.net/releases/8.4/en.php) - [PHP kılavuzu: Lazy objects, erişim 2026-10-07](https://www.php.net/manual/en/language.oop5.lazy-objects.php) - [PHP ChangeLog 8 (8.5.0, 20 Kas 2025), erişim 2026-10-07](https://www.php.net/ChangeLog-8.php) ## Go'nun standart kütüphanesiyle ne kadar uzağa gidilir - Kaynak: https://muhammetsafak.com/tr/blog/gonun-standart-kutuphanesiyle-ne-kadar-uzaga-gidilir/ Go öğrenmeye başlayanların erken fark ettiği şeylerden biri: dilin standart kütüphanesi (standard library) alışılmadık derecede zengin. HTTP sunucusu, JSON işleme, şifreleme, dosya sistemi, şablon motoru, test altyapısı — bunların hepsi üçüncü parti paket olmadan kullanılabiliyor. PHP dünyasından gelince bu bir kültür şoku. PHP'de üçüncü parti paket kurmadan neredeyse hiçbir şeyi "modern" biçimde yapamıyorsunuz. Composer olmadan çalışmak teorik olarak mümkün ama pratik olarak tercih edilmiyor. Go'da ise standart kütüphane gerçekten kendi başına bir üretim tercihi. ## Standart kütüphane neyi kapsıyor Go'da `import` ifadesinin ardında `github.com` görmeden yapabileceklerinize kısaca bakalım: **[HTTP servisi:](/tr/blog/go-da-http-servisi-yazmak-standart-kutuphane-yeter-mi/)** `net/http` paketi hem client hem server tarafı sağlıyor. Basit bir servis için router kütüphanesi gerekmez: ```go package main import ( "encoding/json" "net/http" ) type Response struct { Message string `json:"message"` } func healthHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(Response{Message: "ok"}) } func main() { http.HandleFunc("/health", healthHandler) http.ListenAndServe(":8080", nil) } ``` Bu kod, bir üretim servisinin sağlık kontrol ucu için yeterli. Ek bağımlılık yok. **JSON işleme:** `encoding/json` ile hem serialization hem deserialization mümkün. Büyük veri setlerinde ya da özel dönüşüm senaryolarında başka paketlere bakıyorum, ama standart kullanım durumları için `encoding/json` yeterli. **Veritabanı erişimi:** `database/sql` arayüzünü driver'lar gerçekleştiriyor. Sürücü paketini dışarıdan alıyorsunuz, ama ORM zorunluluğu yok. Saf SQL'i standart kütüphane üzerinden çalıştırmak doğal. **Şablon motoru:** `html/template` paketi XSS (Cross-Site Scripting) korumasını yerleşik olarak sunuyor. Sunucu taraflı HTML üretimi için başka bir şeye ihtiyaç yok. **Test altyapısı:** `testing` paketi alt testlerle ([table-driven tests](/tr/blog/go-da-test-yazmak-tablo-testleri-table-driven-tests/) bunların üzerine kurulur), benchmark ve fuzz test desteğiyle geliyor. Go'nun test kültürü büyük ölçüde bu paketin üzerine kurulu. ## Nerede sınıra geliyorsunuz Standart kütüphane her şeyi karşılamıyor ve bu bilinçli bir tasarım kararı. En çok sınıra geldiğim yerler: **Routing.** `net/http`'nin varsayılan router'ı path parametrelerini (`:id` gibi) desteklemiyor. Go 1.22 ile bu kısıtlama kısmen giderildi — pattern sözdizimi eklendi: ```go // Go 1.22+ http.HandleFunc("GET /users/{id}", getUserHandler) ``` Ama karmaşık yönlendirme senaryolarında, özellikle middleware zinciri ve grup yapılandırması gerektiğinde `chi` ya da `gorilla/mux` gibi paketlere başvuruyorum. **ORM.** `database/sql` ile saf SQL yazmak bir süre sonra tekrarlı oluyor. `sqlx` gibi hafif bir kütüphane standart arayüzü korurken struct taramasını kolaylaştırıyor. Ağır bir ORM almadan yapabildiğim durumlar çok daha fazla. **Doğrulama.** Standart kütüphanede doğrulama kütüphanesi yok. `go-playground/validator` bu ihtiyacın fiili standardı haline geldi. **Loglama.** `log/slog` paketi Go 1.21'de geldi ve structured loglama için artık standart kütüphane yeterli. Önceki sürümlerde `zap` ya da `zerolog` zorunluydu. > **Tan:** Bağımlılığın bedeli eklendiği gün değil, ilk güvenlik yaması ya da sürüm yükseltmesi geldiğinde ödenir. Bu yüzden eklemeden önce sorulacak şey, o bakımı kimin üstleneceği. Standart kütüphane bu soruyu çoğu zaman en aza indirir. ## Az bağımlılığın değeri Go topluluğunun "vendor her şeyi kontrol edin" yaklaşımı ve minimal bağımlılık kültürü birbiriyle bağlantılı. Daha az bağımlılık şu anlama geliyor: derleme süresi daha kısa, güvenlik açığı yüzeyi daha küçük, bağımlılık güncelleme yükü daha az. Bir paketi eklemeden önce şu soruyu soruyorum: bu paketi standart kütüphaneyle ya da birkaç yardımcı fonksiyonla karşılayabilir miyim? Çoğu zaman cevap evet. Yanıt hayır olduğunda paket ekliyorum, ama bu bir karar — varsayılan değil. PHP'de Composer olmadan yazmayı düşünmüyorum; ekosistem o şekilde kurulu. Go'da ise standart kütüphane bir başlangıç noktası değil, uzun süre yeterli olan gerçek bir seçenek. Bu fark, bağımlılıklara bakış açımı da biçimlendirdi. ## Uzman mı, genelci mi: T-tipi bir kariyer - Kaynak: https://muhammetsafak.com/tr/blog/uzman-mi-genelci-mi-t-tipi-bir-kariyer/ Kariyer konuşmalarında sürekli karşılaştığım bir soru var: uzman mı olmalıyım, genelci mi? Bu soruyu on yıldan fazla süre boyunca hem kendime hem de etrafımdaki geliştiricilere yönelttim. Yanıtım giderek netleşti: yanlış bir soru bu. Uzman ve genelci, karşıt kutuplar olarak konumlandırılıyor. Gerçekte başarılı kariyerler bu iki kutbun arasında değil, ikisini aynı anda taşıyan bir yapıda seyrediyor. Bu yapıyı T-tipi kariyer olarak tanımlıyorum — bir alanda gerçek derinlik, etrafında çalışan bir genişlik. ## T-tipinin anatomisi T harfinin yatay çizgisi genişliği temsil ediyor: birden fazla teknoloji, domain, araç hakkında yeterince bilgi sahibi olmak. Birlikte çalıştığınız insanları anlamak, farklı katmanların birbiriyle nasıl konuştuğunu görmek, bir problemi tek bir perspektiften değil birkaç farklı perspektiften okuyabilmek. T'nin dikey çizgisi ise derinliği: bir alanda gerçekten uzmanlaşmış olmak. Bu alan bir dil olabilir, bir domain olabilir, bir technology stack olabilir. Ama bu derinlik yüzeysel değil — o alanda başkalarının göremediği şeyleri görebiliyor olmalısınız. On yılı aşkın PHP deneyimim bu T'nin dikey kolu. PHP ile sadece kod yazmıyorum; dilin hangi kararların sonucunda bugüne geldiğini, hangi kalıpların neden çalışıp neden çalışmadığını, ekosistemin nasıl evrildiğini görüyorum. Go, TypeScript, React Native ise genişliğin parçaları — hepsinde yetkinim ama her birinde PHP kadar derine gitmedim. ## Polyglot'un T-tipindeki yeri Polyglot olmak T-tipinin doğal bir parçası. Birden fazla dil bilmek, dilden bağımsız programlama kavramlarını daha net görmenizi sağlıyor. Farklı hata yönetimi modellerini, tip sistemlerini, eşzamanlılık yaklaşımlarını görmüş biri, tek bir dilin paradigmasına sıkışmış birinden farklı soruyor soruları. Ama polyglot olmak başlı başına bir hedef olduğunda — "her yeni dili öğreneceğim" diye bir misyon — dağılmaya dönüşüyor. Geniş ama sığ bir T ortaya çıkıyor; yatay çizgi uzun ama dikey çizgi yok. Derinlikten yoksun genişlik, ekip içinde değerini kaybediyor. Her konuda "biraz bilen" biri aslında kritik anlarda güvenilemez — çünkü o kritik anlarda derinlik gerekiyor, genişlik değil. ## Derinleşilecek alanı seçmek Hangi alanda derinleşeceğinizi seçmek, T'nin en kritik kararı. Bu seçim rastgele veya piyasanın talebi üzerinden yapıldığında uzun vadede tatminsizlik üretiyor. Birkaç kriter işe yarıyor: **Gerçek ilgi.** Zorunluluk olmadan da o alana çekilip çekilmediğiniz. [Bir şeyi derinlemesine öğrenmek](/tr/blog/hypei-filtrelemek-her-yeni-teknolojiyi-ogrenmek-zorunda-degilsin/) için uzun vadeli enerji şart; bu enerji ancak gerçek ilgiyle geliyor. **Tamamlayıcılık.** Zaten çalıştığınız bağlamda değer üretip üretmediği. PHP ile web uygulamaları geliştiriyorsanız PHP'de derinleşmek tamamlayıcı. Ama aynı bağlamda Kubernetes derinliği sizi farklı bir lane'e taşır — bu yanlış değil, ama farklı bir kariyer kararı. **Evrim kapasitesi.** O alanın önümüzdeki beş-on yılda nasıl şekilleneceği. Tamamen statik bir alan yıllarca öğrenilen derinliği değersizleştireceği için seçici olmak gerekiyor. ## Genişlik ne zaman yatırım, ne zaman dağılma Genişlik yatırım: yeni bir dil veya araç, mevcut derinliğinizi farklı bir perspektiften görmek için kullanıyorsanız. [Go öğrenmek](/tr/blog/polyglot-bir-gelistirici-olmak-dili-probleme-gore-secmek/), PHP'deki hata yönetimi kararlarımı sorgulatıyor. TypeScript'in tip sistemi, PHP'deki tip tasarımı düşüncemi zenginleştiriyor. Genişlik dağılma: her altı ayda farklı bir trend peşinden koşuyorsanız, hiçbirinde derinleşmeden geçiyorsanız, öğrendiğiniz şeyleri uygulamada kullanmadan bırakıyorsanız. Bu, portföye satır ekliyor ama gerçek kapasiteye değil. Pratik bir test: son öğrendiğiniz şeyi mevcut işinizde bir problem çözmek için kullandınız mı? Kullandıysanız yatırım. Kullanmadıysanız ya da kullanma fırsatı aramadıysanız dağılmaya yakın bir alan bu. ## Kariyer boyunca T'nin değişimi T-tipi kariyer statik değil. Dikey kolun yavaş yavaş değişmesi mümkün — ve zaman zaman gerekli. Bazı alanlar teknolojinin evrimi içinde değerini yitiriyor; bu noktada derinliği kaydırmak stratejik bir karar. Ama bu kaydırma kolay değil ve ucuz değil. Yıllarca birikmiş derinliği bırakıp yeniden başlamak hem maliyetli hem de kimlik düzeyinde zorlayıcı. Bu yüzden derinleşilecek alanı baştan seçmek önemli. Piyasanın ani taleplerine göre sürekli kaydıran biri, hiçbir zaman gerçek T'yi inşa edemiyor. Mid ve kıdemli geliştiricilere, kariyer kararlarında yön ararken söyleyeceğim şey bu: uzman mı genelci mi sorusunun cevabını aramayın. "Dikey kolum nerede, yatay çizgim ne kadar geniş" sorusunu sorun. Derinlik olmadan genişlik gürültü, genişlik olmadan derinlik kıırılganlık. İkisi birlikte kariyer. ## Expo'da güncellemeleri OTA ile yönetmek - Kaynak: https://muhammetsafak.com/tr/blog/expoda-guncellemeleri-ota-ile-yonetmek/ Mobil geliştirmenin en sinir bozucu kısıtlarından biri mağaza döngüsü. Bir hata gördünüz, düzelttiniz, derleme aldınız — ama kullanıcıya ulaşması App Store incelemesinin bitmesini bekliyor. Kritik bir hata için bu süre kabul edilebilir değil. OTA (Over-the-Air) bu sorunu çözüyor, ama tam olarak anlamadan kullanmak da sorun yaratıyor. Expo Updates ile birkaç yıldır üretim ortamında çalışıyorum; öğrendiklerimi burada özetleyeceğim. ## OTA nedir, ne değildir OTA güncelleme, uygulamanın JavaScript paketini (bundle) ve varlıklarını (assets) mağaza incelemesi olmadan cihaza göndermek demek. Expo'nun OTA altyapısı bunu `expo-updates` paketi üzerinden yönetiyor. Kritik sınır: **native kod değiştirilemez**. Yeni bir native modül eklediğinizde, bir Expo SDK sürümü atladığınızda ya da uygulama izinlerini değiştirdiğinizde [mağaza sürümü](/tr/blog/expo-ile-mobil-uygulamayi-magazaya-gondermek/) şarttır. OTA yalnızca JavaScript katmanındaki değişiklikleri taşıyabiliyor. Bu ayrımı aklınızda net tutmak gerekiyor. "OTA'ya gönderirim" diye başladığınız bir değişiklik sonunda native bağımlılık gerektiriyorsa, planınızı en baştan yeniden yapmak durumunda kalırsınız. ## Expo Updates ile basit yapılandırma `eas.json` dosyasında güncelleme kanalları (channels) tanımlıyorum: ```json { "build": { "production": { "channel": "production" }, "staging": { "channel": "staging" } } } ``` `app.json` veya `app.config.js` içinde güncelleme politikasını belirliyorum: ```json { "expo": { "updates": { "enabled": true, "fallbackToCacheTimeout": 0, "checkAutomatically": "ON_LOAD" } } } ``` `fallbackToCacheTimeout: 0` ile uygulama açılışında güncelleme varsa beklemeden indiriyor ve bir sonraki açılışta uygulayıyor. Bu "indirip bir sonraki oturumda uygula" yaklaşımı en az sürtünmeli deneyimi veriyor. ## EAS Update ile güncelleme yayınlamak ```bash # Staging kanalına güncelleme gönder eas update --channel staging --message "Form doğrulama hatası düzeltildi" # Production'a eas update --channel production --message "v2.4.1 hotfix" ``` Bu komut JavaScript paketini ve değişen varlıkları EAS (Expo Application Services) sunucularına yükleyip ilgili kanala bağlıyor. O kanalı izleyen cihazlar bir sonraki uygulama açılışında güncellemeyi alıyor. ## Güncelleme stratejisi Üretim ortamında iki yaklaşım var: **pasif güncelleme** ve **force update**. Pasif güncelleme, uygulamanın arka planda güncellemeyi indirip bir sonraki açılışta uygulaması. Kullanıcı hiçbir şey fark etmiyor. Kritik olmayan düzeltmeler için bu yeterli. Kritik bir güvenlik açığı ya da ciddi bir işlev hatası için ise güncellemeyi zorlamak gerekebilir. `expo-updates` API'si üzerinden kontrol alınabilir: ```javascript import * as Updates from 'expo-updates'; async function checkForUpdate() { try { const update = await Updates.checkForUpdateAsync(); if (update.isAvailable) { await Updates.fetchUpdateAsync(); await Updates.reloadAsync(); } } catch (e) { // Ağ yoksa sessizce geç } } ``` Bu kodu uygulama açılışında ya da kullanıcı belirli bir ekrana girdiğinde çağırabilirsiniz. `reloadAsync()` uygulamayı güncel paket ile yeniden başlatıyor. ## Kanal yönetimi ve test süreci Üretim ortamına OTA göndermeden önce staging kanalında test etmek iş akışımın değişmez parçası. EAS update, aynı komutun farklı kanallarla çalışmasına izin verdiği için bu çok basit. Bir güncellemeyi yayınlamadan önce şu soruyu soruyorum: bu değişiklik native bir bağımlılığa dokunuyor mu? Hayırsa OTA gönderebilirim. Evetse mağaza sürümü planına geçiyorum. ## OTA'nın gerçek değeri Mağazaya göndereceğim her sürüm Apple incelemesini beklemek zorunda. Bu süre değişken; Apple'ın açıklamasına göre [başvuruların ortalama %90'ı 24 saatten kısa sürede inceleniyor](https://developer.apple.com/distribute/app-review/), geri kalanı daha uzun sürüyor. Bir üretim hatasını birkaç saat bile bekletmek çoğu zaman kabul edilemez. OTA güncellemelerle JavaScript katmanındaki hataları birkaç dakika içinde tüm kullanıcılara ulaştırabiliyorum. Bu hız, mobil geliştirmenin en büyük kısıtlarından birini önemli ölçüde hafiflettiriyor. Ama bu araç ne kadar güçlü olursa olsun, native sınırın bilincinde olmak gerekiyor — aksi halde yanlış araçla yanlış sorunu çözmeye çalışırsınız. ## React 19 ve form'ların yeni hali (actions) - Kaynak: https://muhammetsafak.com/tr/blog/react-19-ve-formlarin-yeni-hali-actions/ [React 19](/tr/blog/react-18-ve-eszamanli-concurrent-render/), 2024 sonunda kararlı (stable) sürüm olarak yayımlandı. Bu sürümün en somut yeniliklerinden biri form yönetimini yeniden düşünmesi. Yıllardır React'te form yazmak, ya controlled component kalıplarıyla biriken `useState` çağrıları ya da harici kütüphanelere devretmek anlamına geliyordu. React 19 bu alana birinci sınıf bir çözüm getirdi. Form actions kavramı, form gönderimini doğrudan bir fonksiyona — bir "action"a — bağlama fikrine dayanıyor. Bu yaklaşım React Server Components ile birlikte düşünüldüğünde daha da anlam kazanıyor, ama salt istemci tarafında da kullanılabiliyor. ## Önceki durumu hatırlamak React 18 ve öncesinde basit bir form şöyle yazılırdı: ```jsx function LoginForm() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); async function handleSubmit(e) { e.preventDefault(); setLoading(true); setError(null); try { await login({ email, password }); } catch (err) { setError(err.message); } finally { setLoading(false); } } return (
setEmail(e.target.value)} /> setPassword(e.target.value)} /> {error &&

{error}

}
); } ``` Bu kod çalışıyor. Ama her form için bu kalıbı tekrarlamak yoruyor. `loading`, `error` ve `e.preventDefault()` her form bileşeninde yeniden yazılıyor. ## React 19 ile action yaklaşımı React 19, `useActionState` hook'unu ve form'un `action` prop'unu birinci sınıf olarak tanıttı: ```jsx import { useActionState } from 'react'; async function loginAction(prevState, formData) { const email = formData.get('email'); const password = formData.get('password'); try { await login({ email, password }); return { success: true }; } catch (err) { return { error: err.message }; } } function LoginForm() { const [state, dispatch, isPending] = useActionState(loginAction, null); return (
{state?.error &&

{state.error}

}
); } ``` Birkaç şeye dikkat etmek gerekiyor. `e.preventDefault()` çağrısı ortadan kalktı — `action` prop'u bunu otomatik olarak yönetiyor. `loading` state'i `isPending`'e dönüştü. `useState` çağrıları azaldı. Form verisi doğrudan `FormData` API'si üzerinden geliyor — her alan için ayrı state tutmak gerekmiyor. ## useOptimistic ile iyimser güncelleme React 19'un bir diğer eklemesi `useOptimistic` hook'u. Sunucuya istek gitmeden önce arayüzü güncelleyip, istek başarısız olursa geri almak için kullanılıyor: ```jsx function TodoList({ todos }) { const [optimisticTodos, addOptimistic] = useOptimistic( todos, (state, newTodo) => [...state, { ...newTodo, pending: true }] ); async function addTodoAction(formData) { const title = formData.get('title'); addOptimistic({ id: Date.now(), title }); await saveTodo({ title }); } return ( <> {optimisticTodos.map(todo => (
  • {todo.title}
  • ))}
    ); } ``` Bu yaklaşım, liste uygulamalarında kullanıcıya anında geri bildirim verirken arka planda sunucu çağrısını tamamlıyor. Daha önce bu iyimser güncelleme (optimistic update) kalıbını elle yönetmek ciddi bir karmaşıklık taşıyordu. ## Anlık doğrulama action modeline uymaz Action modeli her form senaryosu için doğru seçim değil. Kullanıcı yazarken alanı anlık doğrulamak istediğinizde — mesela e-posta formatı için anında geri bildirim — hâlâ `useState` ile kontrollü bileşen gerekiyor. `FormData`, submit anında veriyi yakalar; yazma sırasında değil. Benzer şekilde, bir alanın değerine bağlı olarak başka bir alanın seçeneklerini değiştiren bağımlı alanlar da action modeliyle temiz çözülmüyor. Şehir seçince ilçelerin güncellenmesi gibi senaryolarda state'i tutmak kaçınılmaz. Dolayısıyla "action modeli her şeyin yerini aldı" demek henüz erken; bu iki model birlikte var olmaya devam edecek, seçimi kullanım durumuna bırakmak gerekiyor. ## Değerlendirme React 19'un form modeli, gerçek bir sadeleştirme sunuyor. Kontrollü bileşen kalıbı tamamen ortadan kalkmıyor — her form kullanım durumu action modeliyle çözülmüyor. Anlık doğrulama (real-time validation) ya da iç içe bağımlı alanlar gibi senaryolarda hâlâ state yönetimi gerekebilir. Ama temel gönderim akışları için bu yeni model daha az gürültülü. React Native tarafında 0.78 sürümü Actions, `useActionState` ve `useOptimistic` ile birlikte geliyor; ancak `
    ` özellikleri `react-dom`'a ait, dolayısıyla burada anlatılan form modeli bir web hikâyesi. Web projelerinde yeni bir proje başlatırken bu modeli tercih etmek için geçerli nedenler var. Bir çerçevenin form modelini standartlaştırması, ekip içinde "herkes farklı bir yol kullanıyor" sorununu da çözüyor. Bu sessiz faydayı küçümsememek gerekiyor. React Hook Form, Formik ve el yapımı `useState` zincirleri aynı kod tabanında yan yana varolduğunda bakım maliyeti görünmez biçimde birikir; ortak bir model bu birikimi durdurur. Bir de şunu eklemek isterim: `useActionState`'e verilen action fonksiyonunun ilk parametresi olan `prevState`, birden çok submit arasında durumu biriktirmek için kullanılabiliyor. Bir çok adımlı form akışında bu özellik işe yarıyor; önceki adımın sonucunu bir sonrakine taşımak için ek bir state yöneticisine ihtiyaç kalmıyor. React'in bunu hook'un içine gömmesi, pratik bir tasarım kararı. ## Polyglot bir kod tabanını sürdürmek: bağlam değiştirme maliyeti - Kaynak: https://muhammetsafak.com/tr/blog/polyglot-bir-kod-tabanini-surdurumek-baglan-degistirme-maliyeti/ [Birkaç yıldır aynı anda PHP, Go ve TypeScript ile çalışıyorum](/tr/blog/bir-yilda-dort-dil-polyglot-pratigin-getirdigi-zihin-esnekligi/). Farklı günlerde farklı dillerde yazıyorum; bazen aynı gün içinde iki dil arasında gidip geliyorum. Bu pratiğin bilişsel bir bedeli var ve bu bedelin büyük bölümü görünmüyor — toplantı süresi, derleme süresi ya da test geçiş oranı gibi ölçülmüyor. Yine de gerçek. Bağlam değiştirme maliyeti yazılım dünyasında çoğunlukla görev bazında konuşuluyor: bir özelliği yarıda bırakıp bir hata gidermek, sonra toplantıya girmek. Dil bazındaki bağlam değiştirme daha az konuşulan ama benim deneyimimde daha derin bir kategori. ## Dilin düşünme biçimi Her dil farklı düşünme biçimi dayatıyor. Bu klişe gibi görünüyor ama uygulamada somut sonuçlar doğuruyor. Go'da hata yönetimi (error handling) deyimsel (idiomatic) kodun merkezinde. Her fonksiyon çağrısının ardından `if err != nil` kontrolü var; bu doğrusal, açık, sözcük düzeyinde görünür bir akış. PHP'de ise istisna (exception) fırlatma ve yakalama daha yaygın; kontrol akışı daha örtük. Bir sabah Go'da iki saat yazdıktan sonra Laravel kodu yazmaya geçtiğimde, hata yönetiminde küçük düşünce gecikmeleri yaşıyorum. Deyimsel PHP'ye dönmek bir ısınma süresi gerektiriyor. Aynısı tersi yönde de geçerli — PHP'den Go'ya geçerken birkaç satır yazan parmaklarım, alışkanlıkla yanlış syntax'ı yazıyor. TypeScript ise bu ikisinden farklı bir katman: tip sistemi daha zengin, IDE entegrasyonu daha güçlü, ama tarayıcı/Node ortamının tuhaflıkları her zaman arka planda. PHP veya Go'dan TypeScript'e geçerken "async her yerde" zihniyetini yeniden yüklemem gerekiyor. ## Görünmez maliyet nerede birikir Üç dilde çalışmanın maliyeti genellikle yanlış yerde aranıyor. "Dil sözdizimini unutmak" yüzeysel bir sorun; gerçek maliyet daha derinde. **Deyimsellik kaybı.** Her dilin bir deyimsel tarzı var. Go'da slice'ları range ile gezersiniz, PHP'de `array_map/filter/reduce` ya da collection pipeline kullanırsınız, TypeScript'te fonksiyonel akışı tercih edersiniz. Bağlam değiştirmek bu tarzları bulanıklaştırıyor. Yazdığım PHP kodu zaman zaman "Go zihniyetiyle yazılmış PHP" gibi görünüyor. Bu, çalışıyor ama kodu okuyan başkasına verimli bir sinyal vermiyor. **Araç hafızası.** Debugger kısayolları, IDE yapılandırmaları, paket yöneticisi alışkanlıkları. Go'da `go mod tidy`, PHP'de `composer require`, TypeScript'te `npm install`. Küçük farklılıklar ama yüksek frekansla tekrar eden şeyler birikip zaman çalıyor. **Kütüphane dönüşüm noktaları.** Bir problemi farklı dillerde farklı kütüphanelerle çözüyorsunuz. Tarih-saat işlemleri için PHP'de Carbon, Go'da standart `time` paketi, TypeScript'te `date-fns` ya da `luxon`. Hangi API'yi düşünmeniz gerektiğini seçmek de bağlam değiştirmenin bir parçası. ## Maliyeti düşürmenin yolları Birkaç pratik geliştirerek bu maliyeti kabul edilebilir düzeyde tutuyorum. **Dil bloklarıyla çalışmak.** Aynı gün içinde diller arasında sık sık gidip gelmek yerine, mümkün olduğunda sabah Go, öğleden sonra PHP gibi bloklar kuruyorum. Her zaman mümkün değil, ama yapılabildiğinde geçiş sürtünmesini azaltıyor. **Her dil için ayrı çalışma alanı.** Editör pencereleri, terminal sekmeleri ve hatta bazen tarayıcı profili dil bazında ayrılmış oluyor. Bu küçük fiziksel/dijital ayrım zihinsel geçişi destekliyor. **Deyimsel koda bilinçli özen.** Bir dilde yazdığımda o dilin deyimselliğine bağlı kalmak için ekstra özen gösteriyorum. Code review'larda başkalarına baktığımda şunu arıyorum: bu kod, bu dilde yazan biri gibi görünüyor mu? ```go // Go deyimsel örüntü users, err := repository.FindActive() if err != nil { return nil, fmt.Errorf("active users fetch failed: %w", err) } ``` ```php // PHP deyimsel örüntü try { $users = $repository->findActive(); } catch (RepositoryException $e) { throw new ServiceException('Active users fetch failed', previous: $e); } ``` İkisi de hata yönetiyor, ama her biri kendi dilinin beklentisini yansıtıyor. ## Polyglot'un getirisi hâlâ pozitif mi Evet. Farklı dillerin problem çözme yaklaşımlarını görmek, tek dilde sıkışıp kalmış bir geliştiricide olmayan bir perspektif kazandırıyor. [Go'nun hata yönetimi yaklaşımını](/tr/blog/goda-hata-yonetimi-error-degeriyle-yasamak/) PHP'de taklit etmek istemiyorum, ama o yaklaşımı görmüş olmak hata tasarımı hakkında daha net düşünmemi sağlıyor. Maliyet gerçek, getiri de gerçek. Bağlam değiştirme bedelini görmezden gelmek değil, farkında olmak ve yönetmek gerekiyor. Yönetilemeyen bir maliyet zamanla fark edilmeden biriküyor — çıktının kalitesinde değil, bunu sürdürmek için harcanan enerjide. ## Laravel 12: sürüm ritmi olgunlaşırken - Kaynak: https://muhammetsafak.com/tr/blog/laravel-12-surum-ritmi-olgunlasirken/ Laravel 12, Şubat 2025'te yayımlandı. Açıkçası sürümü beklerken büyük bir sürpriz beklentisi içinde değildim ve yanılmadım. Bu bir şikâyet değil, bir tespit — olgun bir framework'ün olgun bir sürümü böyle görünür. Yıllar önce her Laravel sürümü bir heyecan dalgası yaratırdı. [Laravel 5 dizin yapısını baştan yazdı](/tr/blog/laravel-5e-gecis-yeni-dizin-yapisi-ve-env/), [Laravel 6 semantic versioning'e geçti](/tr/blog/laravel-6-ve-ltsin-anlami-surum-stratejisi/), [Laravel 8 factory'leri yeniden tasarladı](/tr/blog/laravel-8-model-factory-leri-ve-yeni-dizin-yapisi/). Şimdi o çağ geride. Şu anda yaşadığımız dönem farklı bir şey: öngörülebilirlik. ## Yıllık sürüm temposu ve ne anlama gelir Laravel her yıl, yılın ilk çeyreğinde yeni bir ana sürüm yayımlıyor. Bu tempo, 2022'den bu yana tutarlı şekilde devam ediyor. Bir proje yöneticisi ya da ekip lideri olarak bunu değerli buluyorum çünkü planlama yapılabiliyor. Her ana sürüm 18 ay hata düzeltmesi, sürümden itibaren toplam iki yıl da güvenlik düzeltmesi alıyor. Bu, Laravel 11'i hâlâ kullanan bir projenin 2026'ya kadar güvenlik güncellemesi alacağı anlamına geliyor. Paniklemeden, planlı bir takvimde güncelleme yapabiliyorsunuz. > **Güncelleme · 2026-10-05** (yukarıdaki paragraf için) — Bunu yazdığımda Laravel 11'in güvenlik desteği henüz sürüyordu; artık sürmüyor. Laravel 11'in güvenlik düzeltmeleri 12 Mart 2026'da sona erdi, yani hâlâ 11'de olan bir proje artık güvenlik güncellemesi almıyor. Laravel 12'nin hata düzeltme penceresi 13 Ağustos 2026'da kapandı; güvenlik düzeltmeleri 24 Şubat 2027'ye kadar sürüyor ([Laravel 12.x sürüm notları](https://laravel.com/docs/12.x/releases#support-policy)). Karşılaştırma için: PHP dünyasında bu tür bir öngörülebilirlik herkesin hakkı değil. Bazı kütüphaneler yıllarca güncellenmezken bazıları altı ayda bir breaking change çıkarıyor. Laravel'in bu disiplinli temposu, çerçeveye bağımlı kod tabanlarının ömrünü uzatıyor. ## Laravel 12'de ne geldi Sürüm notlarına baktığımda öne çıkan birkaç değişiklik var. Bunların büyük bölümü kırıcı değişiklik değil, iyileştirme niteliğinde. **Starter kit'ler yenilendi.** React, Vue ve Livewire için yeni kit'ler, artık güncelleme almayan Breeze ve Jetstream'in yerini alıyor. Yeni bir proje başlatırken bu kitleri seçmek için bir neden daha oluştu. **`once()` yardımcı fonksiyonu.** Laravel 11 ile gelen, bir closure'ı tek bir kez çalıştırıp sonucunu önbelleğe alan bu yardımcı, başlangıçta küçük görünüyor ama sık başvurduğum kalıplardan birini temizliyor. Aynı isteğin farklı noktalarında tekrar hesaplanmasını önlemek için daha önce statik değişken ya da özel çözümler yazıyordum. ```php $result = once(fn () => someExpensiveComputation()); ``` **Bağımlılıklar güncellendi.** PHP 8.2 alt sınırı korunurken desteklenen üst sürüm 8.5'e genişledi. Bu beklenen bir adım; Laravel zaman zaman EOL (End of Life) yaklaşan PHP sürümleri için desteği keserek ekibi ve kullanıcıları ileriye itiyor. ## Güncelleme deneyimi [Laravel 11'den](/tr/blog/laravel-11-ile-gelen-yeni-ince-iskelet-yapisi/) 12'ye geçişi bir projede denedim. `composer require laravel/framework:^12.0` çalıştırdıktan sonra çıkan uyumsuzlukların büyük bölümü üçüncü parti paketlerden kaynaklandı; Laravel'in kendisinden değil. Bu ayrımı not etmek gerekiyor: framework güncelleme maliyeti çoğu zaman framework'ün kendisi değil, ekosistem uyumudur. Popüler paketler genellikle birkaç gün içinde güncelleniyor. Daha az aktif paketlerde bazen haftalarca beklemek ya da fork almak gerekebiliyor. ```bash composer require laravel/framework:^12.0 --update-with-dependencies ``` Büyük bir uygulama için bu güncellemeyi bir sprint'e sığdırmak mümkün. Küçük bir proje için ise tek oturumda tamamlanabilir bir iş. ## Sürüm ritmi ve uzun vadeli karar Bir framework seçerken sürüm ritmini artık ilk kriterler arasında değerlendiriyorum. Hızlı evrim her zaman iyi değil; bazen öngörülebilirlik daha değerli. Laravel bu dengeyi son birkaç yılda iyi kurdu. Yeni bir proje başlatırken Laravel 12 ile başlamak mantıklı. Mevcut Laravel 11 projesinde acele etmek gerekmiyor, ama geçiş yol haritasını şimdiden çıkarmak iyi bir pratik. Framework'ün yayım takvimini takip etmek, teknik borç biriktirmemeye karşı en basit savunmalardan biri. ## Tailwind CSS 4: yeni motor ve yapılandırma - Kaynak: https://muhammetsafak.com/tr/blog/tailwind-css-4-yeni-motor-ve-yapilandirma/ Tailwind CSS 4, Ocak 2025'te kararlı sürüm olarak yayımlandı. [Beş yıl önce utility-first yaklaşımı benimsediğimde](/tr/blog/tailwind-css-utility-first-yaklasimina-neden-gectim/) çevremden ciddi itiraz gördüm; şimdi aynı insanlar Tailwind kullanıyor. Büyük sürüm geçişleri her zaman bir şeyler kırar, her zaman bir şeyler kazandırır. Burada neyin kırıldığına ve neyin kazanıldığına sakin bir bakış atmak istiyorum. Tailwind 3'ten 4'e geçişin en görünür değişikliği `tailwind.config.js` dosyasının artık zorunlu olmaması. Bu dosyayı yıllarca yazdım, okudum, başkalarının projelerinde ezdim. Gitmesine üzülmüyorum. ## Oxide motoru: ne değişti Tailwind 4'ün altında **Oxide** adı verilen yeni bir derleme motoru çalışıyor. Performans açısından kritik parçaları Rust ile yazılmış bu motor, önceki motora kıyasla full build'lerde beş kata kadar, incremental build'lerde ise yüz katın üzerinde daha hızlı olduğunu iddia ediyor. Geliştirme ortamında bu farkı gerçekten hissediyorsunuz. Büyük bir projeyi Tailwind 3 ile derlerken beklediğiniz süre, 4'te neredeyse kaybolmuş oluyor. Production derlemeleri de bundan nasibini alıyor, ancak orada süre zaten birkaç saniyeydi; asıl fark geliştirme döngüsünde. ## CSS-öncelikli yapılandırma Tailwind 4'ün en büyük paradigma değişikliği yapılandırmanın CSS içine taşınması. Artık tema özelleştirmeleri JavaScript dosyasında değil, doğrudan CSS'te `@theme` direktifi aracılığıyla tanımlanıyor; eklentiler ise `@plugin` ile yükleniyor. ```css @import "tailwindcss"; @theme { --color-brand: oklch(55% 0.2 250); --font-display: "Inter", sans-serif; --spacing-18: 4.5rem; } ``` Bu yaklaşımın birkaç sonucu var. Birincisi, yapılandırma artık CSS değişkeni (CSS custom property) olarak çalışıyor; bu hem tarayıcıda hem JavaScript'te erişilebilir demek. İkincisi, `tailwind.config.js` dosyasını kaldırmak, araç zincirini (toolchain) biraz daha sadeleştiriyor. Üçüncüsü ise mevcut projelerde geçiş maliyeti oluşturuyor. ## Geçiş maliyeti v3'ten v4'e geçişte en çok dikkat edilmesi gereken noktalar şunlar: - `@tailwind base/components/utilities` yönergeleri kaldırıldı, yerini `@import "tailwindcss"` aldı. - `theme()` ve `screen()` fonksiyonlarının yerini CSS değişkenleri alıyor. - Birçok JIT (Just-In-Time) varsayılanı değişti; özellikle `ring`, `shadow` ve border renk varsayılanları. - Özel eklentiler `plugin()` API'si yerine CSS veya JavaScript ile yazılabiliyor, ama API hâlâ var. Tailwind ekibinin sunduğu `@tailwindcss/upgrade` CLI aracı bu geçişin büyük bölümünü otomatize ediyor. Küçük bir projede çalıştırdım; çoğu değişiklik otomatik uygulandı, kalan birkaç manuel adım da belgelenmiş durumdaydı. ```bash npx @tailwindcss/upgrade ``` Büyük bir projede tam güvenmeden önce bu aracın çıktısını gözden geçirmek gerekiyor. Otomatik geçişler genellikle doğru, ama projeye özgü özelleştirmeler zaman zaman gözden kaçabiliyor. ## Vite eklentisi ve CSS-first import Tailwind 4 artık PostCSS eklentisinin yanı sıra birinci sınıf bir Vite eklentisi sunuyor. Vite kullanan projelerde PostCSS yapılandırması yerine bu eklentiyi kullanmak daha temiz: ```js // vite.config.js import tailwindcss from "@tailwindcss/vite"; export default { plugins: [tailwindcss()], }; ``` Bu konfigürasyonla CSS dosyanızdaki `@import "tailwindcss"` satırı yeterli; ayrı bir PostCSS config dosyasına gerek kalmıyor. ## Yeni yardımcı sınıflar v4'te gelen birkaç yeni yardımcı sınıf dikkat çekici. `field-sizing-content` ile `
    ``` JavaScript tarafı: ```javascript $('#yorumFormu').on('submit', function(e) { e.preventDefault(); // Sayfanın yenilenmesini önlüyoruz var yorum = $('#yorumAlani').val(); $.ajax({ url: '/yorum-ekle', method: 'POST', data: { yorum: yorum }, dataType: 'json', success: function(yanit) { if (yanit.basarili) { $('#mesaj').text('Yorumunuz eklendi.').show(); $('#yorumAlani').val(''); } }, error: function() { $('#mesaj').text('Bir hata oluştu, tekrar deneyin.').show(); } }); }); ``` `e.preventDefault()` satırı çok önemli; olmadan tarayıcı formu normal şekilde gönderir ve sayfa yenilenir. ## Sunucu tarafı PHP tarafında isteği alıp JSON döndürmek yeterli: ```php true]); } else { echo json_encode(['basarili' => false, 'mesaj' => 'Yorum boş olamaz.']); } exit; } ``` `Content-Type` başlığını `application/json` olarak ayarlamak iyi bir alışkanlık: ```php header('Content-Type: application/json'); ``` Bu başlığı koymayı unutmak ciddi bir sorun yaratmıyor ama tarayıcı ve istemci kütüphaneleri yanıtı doğru türde işlemek için bu bilgiye güveniyor. Özellikle jQuery'nin `dataType: 'json'` seçeneği bu başlıkla tutarlı olmalı; tutarsızlık debugging sırasında kafa karıştırabilir. ## $.get ve $.post kısayolları Sık kullanılan HTTP yöntemleri için kısayollar var: ```javascript // GET isteği $.get('/api/makaleler', function(yanit) { console.log(yanit); }); // POST isteği $.post('/api/yorum-ekle', { yorum: 'Merhaba' }, function(yanit) { console.log(yanit); }); ``` Bunlar `$.ajax`'ın kısaltılmış halleri. Basit durumlarda kullanışlı, ama hata yönetimine ihtiyaç duyulduğunda `$.ajax` daha esnekdir. ## Yükleme göstergesi Kullanıcıya isteğin sürdüğünü bildirmek iyi bir pratik: ```javascript $.ajax({ url: '/api/veri', beforeSend: function() { $('#yukleniyor').show(); }, complete: function() { $('#yukleniyor').hide(); }, success: function(yanit) { // işlem } }); ``` `beforeSend` istek başlamadan, `complete` ise başarılı ya da başarısız olsun isteğin bitiminde çalışır. Bir tuzak: `complete` yerine `success` içinde göstergeyi kapatmak. Sunucu bir hata döndürdüğünde `success` çalışmıyor; gösterge ekranda kalıyor. `complete` her iki durumda da çalıştığından kapatma işlemini buraya koymak doğru. ## Güvenlik: CSRF koruması AJAX isteklerinde sıklıkla atlanan bir konu: CSRF (Cross-Site Request Forgery) koruması. PHP tarafında Laravel ya da başka bir framework kullanıyorsanız, POST istekleri için genellikle bir token doğrulaması bekleniyor. HTML'deki meta etiketteki token'ı AJAX isteğiyle birlikte göndermek gerekiyor: ```html ``` ```javascript $.ajaxSetup({ data: { _token: $('meta[name="csrf-token"]').attr('content') } }); ``` `$.ajaxSetup` ile bir kez tanımlayınca tüm `$.ajax` çağrıları bu alanı otomatik ekliyor. Token olmadan gönderilen POST isteği Laravel'de `TokenMismatchException` fırlatıyor; neden hata aldığını anlamak biraz zaman alabiliyor. ## Şimdiye kadar öğrendiklerim AJAX ile sayfa yenileme olmadan çalışan bir arayüz yapmak kullanıcı deneyimini gözle görülür biçimde iyileştirdi. İlk uyguladığımda "işte bu" dedim; sayfanın titrememesi küçük bir detay gibi görünse de fark büyük. jQuery bu işi kolaylaştırdı ama altında ne döndüğünü anlamak da önemli. `XMLHttpRequest`'in üstündeki bu soyutlamanın değerini görmek için birini elle yazmayı denemek gerekiyor. Bir kez yazdıktan sonra jQuery'nin neden bu kadar yaygınlaştığı daha iyi anlaşılıyor. AJAX'ı öğrenirken en çok zorlandığım konu, yanıtı gerçek veri gibi değil bir olay (event) gibi düşünmekti. "Şu veriyi getir, sonra şunu yap" yerine "bu istek tamamlandığında şu fonksiyon çalışsın" şeklinde düşünmek gerekiyor. PHP'den gelen biri için bu zihniyet değişikliği zaman alıyor; çünkü PHP'de her satır bir öncekinin bitmesini bekliyor. JavaScript'te ise beklemek yerine "ne zaman hazır olursa" diyor ve geri çekiliyor. Bu farkı içselleştirince `success` callback'inin neden o yapıda göründüğü çok daha net oluyor. ## PHP'de namespace ile kodu düzenli tutmak - Kaynak: https://muhammetsafak.com/tr/blog/phpde-namespace-ile-kodu-duzenli-tutmak/ Composer ve PSR-4 otomatik yüklemeyi oturttuktan sonra bir sorunla daha yüzleşmek gerekiyor: isim çakışmaları (name collisions). Kendi yazdığım `User` sınıfı ile kullandığım bir kütüphanenin `User` sınıfı aynı projede bir arada bulunmak zorunda kalırsa ne olur? PHP bunu çözmeden önce bu sorunla sık sık karşılaşılırdı. **Ad alanı (Namespace)**, sınıfları, fonksiyonları ve sabitleri mantıksal gruplar altında organize etmenizi ve isim çakışmalarını önlemenizi sağlayan PHP 5.3 ile gelen bir özelliktir. PHP 5.3 çok eskide kaldı ama namespace kullanımı ekosistemde ancak [Composer](/tr/blog/composera-gecis-php-bagimliliklarini-elle-yonetmeyi-birakmak/) ve [PSR standartlarıyla](/tr/blog/psr-4-ve-otomatik-yukleme-include-zincirinden-kurtulmak/) birlikte yaygınlaştı. ## Temel kullanım Bir dosyanın en üstüne `namespace` bildirimi yazılır: ```php **Artık geçerli değil · 2026-10-05** (aşağıdaki paragraf için) — Aşağıdaki `composer.json` örneği dahil, bu yazıdaki `guzzle/guzzle` örnekleri artık geçerli bir öneri değil: paket [Packagist'te](https://packagist.org/packages/guzzle/guzzle) "abandoned" olarak işaretli ve yazarı yerine `guzzlehttp/guzzle` paketini öneriyor. Güncel adla bir örnek için [Guzzle ile HTTP istemcisi](/tr/blog/ucuncu-parti-api-tuketmek-guzzle-ile-http-istemcisi/) yazıma bakabilirsiniz. Basit bir örnek: ```json { "require": { "monolog/monolog": "1.7.*", "guzzle/guzzle": "3.8.*" } } ``` `composer install` çalıştırdığınızda Composer bu paketleri [Packagist](https://packagist.org)'ten indirir ve `vendor/` klasörüne yerleştirir. Bir de `composer.lock` dosyası oluşturur; bu dosya kullandığınız tam sürümleri kaydeder. Takım çalışmasında veya sunucuya taşırken `composer install` aynı sürümleri kurar, beklenmedik sürüm farklarının önüne geçer. Paketi güncellemek için: ```bash composer update monolog/monolog ``` Ya da tüm bağımlılıkları güncellemek için sadece `composer update`. ## Otomatik yükleme (Autoloading) Composer'ın en büyük kazancı paket kurmanın yanı sıra **autoloading** sağlamasıdır. Artık her dosyaya `require_once` yazmak zorunda değilsiniz. `composer.json`'a bir `autoload` bölümü ekleyip projenizin kaynak klasörünü belirtiyorsunuz: ```json { "require": { "monolog/monolog": "1.7.*" }, "autoload": { "psr-4": { "App\\": "src/" } } } ``` Ardından `composer dump-autoload` çalıştırıyorsunuz. Bu noktadan itibaren `src/` altındaki `App\` ad alanıyla (namespace) yazılmış her sınıf otomatik olarak bulunur. Projenizin giriş dosyasına tek satır eklemeniz yeterli: ```php ` containment ve `?` existence gibi operatörlerde ve önceden bilmediğiniz/çok sayıda anahtarda parlar. Generated column ve expression index ise bilinen, sabit birkaç anahtarda equality ve range için doğru araçtır. Sizin durumunuz ikincisi. 2. **Sizin durumunuz "birkaç sabit anahtar" → GIN overkill.** GIN index büyüktür, yazma sırasında güncellemesi pahalıdır ve range/ordering'e (`BETWEEN`, `ORDER BY`) yaramaz. Sadece iki anahtar için tüm blob'u index'lemek, ihtiyacınızdan büyük ve yavaş bir yapı kurmaktır. ## Ne yapmalı 1. **En hafif çözüm: expression index.** Değeri ifade üzerinden doğrudan index'lersiniz — ne şema değişikliği ne de ekstra kolon gerekir. Sorgunuz index'teki ifadeyle birebir aynı ifadeyi kullandığı sürece planner onu kullanır. ```sql CREATE INDEX idx_meta_status ON items ((metadata->>'status')); CREATE INDEX idx_meta_region ON items ((metadata->>'region')); -- Sorgu ifadeyle birebir eşleşmeli: SELECT * FROM items WHERE metadata->>'status' = 'active'; ``` 2. **Generated column ne zaman: değeri gerçek kolon olarak da istiyorsanız.** STORED bir generated column + b-tree index; planner istatistikleri (n_distinct, histogram) daha sağlıklı olur, sorgu daha okunur ve join'lerde kolona doğrudan başvurabilirsiniz. Bedeli: her anahtar için bir şema değişikliği ve storage tekrarı. ```sql ALTER TABLE items ADD COLUMN status text GENERATED ALWAYS AS (metadata->>'status') STORED; CREATE INDEX ON items (status); ``` 3. **Cast tuzağına dikkat.** `->>'` her zaman `text` döner. Sayısal bir alanda range yapacaksanız (`(metadata->>'age')::int > 18`), cast'i hem index tanımında hem sorguda birebir aynı yazmalısınız — yoksa index kullanılmaz. NULL'lara ve boş değerlere de dikkat edin. 4. **GIN'i saklı tutun, silmeyin.** Anahtarlar dinamikse, [kullanıcı serbest metadata giriyorsa](/tr/sor-bakalim/esnek-urun-nitelikleri-icin-nosql-mu-postgresql-mi/) ya da `metadata @> '{"tags":["x"]}'` gibi containment yapıyorsanız GIN doğru araçtır — o zaman da yalnızca containment yapıyorsanız `jsonb_path_ops` varyantı daha küçük ve hızlıdır. **Sonuç:** Ben olsam iki sabit anahtar için iki expression index açar, `EXPLAIN (ANALYZE, BUFFERS)` ile planın Seq Scan yerine Index Scan'e düştüğünü doğrulardım. Bu değerleri raporlarda ya da join'lerde de sık kullanıyorsam generated column'a terfi ettirirdim. GIN'i ancak anahtarlar öngörülemez hale gelirse ya da containment sorgularına geçersem devreye alırdım. Sabit anahtarda GIN, çekiçle ceviz kırmaktır. ## Kanal tampon boyutu seçimi backpressure sorunlarını gizler mi, nasıl karar vermeliyim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/kanal-tampon-boyutu-backpressure-sorunlarini-gizler-karar-vermeliyim/ Kısa cevap: Evet, büyük bir buffer backpressure'ı gizler. ## Kısa cevap Buffer'ı küçük tutun, bloklamanın Kafka poll döngüsüne kadar yansımasına izin verin ve tüketici sayısını ölçülen consumer lag'e göre artırın — buffer boyutuna göre değil. ## Neden 1. **Backpressure'ın tanımı zaten bloklamadır.** [Unbuffered ya da küçük buffer'lı bir kanalın](/tr/blog/goda-eszamanlilik-goroutine-ve-channel-pratigi/) amacı, yavaş consumer'ın producer'ı bloklamasıdır — hissetmek istediğiniz sinyal budur. 10.000'lik buffer bu sinyali susturur; bir throughput problemini bir latency + bellek problemine çevirir ve sorunu sadece geciktirir. 2. **Buffer throughput eklemez, yalnızca burst yutar.** Sürekli üretim hızı > sürekli tüketim hızı ise hiçbir buffer boyutu sizi kurtarmaz: dolar ve yine bloklarsınız, sadece daha geç. Buffer yalnızca ortalama tüketim ortalama üretime yetiştiği, ama gelişin düzensiz/burst olduğu durumda anlamlıdır. 3. **Kafka'da gerçek backpressure = poll'u yavaşlatmak.** Kanal bloklandığında consumer döngünüz bir sonraki mesajı çekmeyi bırakır; Kafka tarafında consumer lag büyür. Bu tam olarak istediğiniz sinyaldir ve üstelik broker'da durable'dır. On binlerce mesajı bellekte tamponlamak, in-flight işi commit edilen offset'ten koparır: offset işlenmeden önce commit ediliyorsa (auto-commit gibi) process çökmesinde o mesajları kaybedersiniz; offset işlem tamamlandıktan sonra commit ediliyorsa (bu örnekteki gibi) mesajlar kaybolmaz, sadece yeniden işlenir. ## Ne yapmalı 1. **Boyut kuralı: 0 ya da küçük (worker sayısının 1-2 katı).** Amaç scheduling jitter'ını yumuşatmaktır, hız uyumsuzluğunu saklamak değil. Bounded bir worker havuzu + küçük kanal, hem paralelliği verir hem bloklamayı geri yansıtır. ```go jobs := make(chan Event, len(workers)) // buffer ≈ worker sayısı, devasa değil for i := 0; i < len(workers); i++ { go func() { for e := range jobs { process(e) } }() } for msg := range consumer.Poll() { jobs <- decode(msg) // kanal doluysa burada bloklar → poll yavaşlar → Kafka lag artar consumer.Commit(msg) // işlenene kadar commit etmeyerek at-least-once koruyun } ``` 2. **Sinyali ölçün, yoksa gizli kalır.** `len(ch)/cap(ch)`, Kafka consumer lag ve işleme latency'sini metrik olarak dışa verin. Kanal sürekli cap'e yakın seyrediyorsa consumer'lar yetişemiyordur — cevap buffer'ı büyütmek değil, worker sayısını artırmak (ya da işleme adımını hızlandırmak). 3. **Sinyali Kafka'da tutun, kanalda değil.** Lag broker'da kalıcıdır ve dashboard'unuzda görünür; bellekteki buffer ise çökünce yok olur. Backpressure'ın nerede biriktiğini seçebiliyorsanız, dayanıklı ve gözlemlenebilir yeri seçin. **Sonuç:** Ben olsam 10.000'lik buffer'ı hemen küçültüp kanal kapasitesini worker sayısına yakın bir yere çekerdim. Bloklamanın poll döngüsüne kadar yansımasına izin verip Kafka consumer lag'i birincil backpressure metriğim yapardım. Yük altında lag kalıcı olarak artıyorsa worker havuzunu ya da işleme hızını büyütürdüm — asla tamponu değil. Büyük buffer bir çözüm değil, problemin görünmez olduğu penceredir. ## Birçok modele bağlanan yorumlar için polymorphic ilişki mi yoksa ayrı tablolar mı; index ve foreign key açısından hangisi daha sağlıklı? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/bircok-modele-baglanan-yorumlar-polymorphic-iliski-ayri-tablolar/ Kısa cevap: Polymorphic'in tek büyük bedeli gerçek foreign key kuramamaktır. ## Kısa cevap 3 stabil tipiniz varsa ve bütünlük sizin için kritikse tek tabloda "exclusive arc" desenini (nullable tipli FK'ler + CHECK) tercih ederdim; saf morph'ta kalacaksanız morph map kullanmak şart. ## Neden 1. **Polymorphic'in gerçek bedeli: referential integrity yok.** `commentable_id` üç farklı tabloya işaret ettiği için veritabanı bir foreign key uygulayamaz. Bir post silindiğinde ona bağlı yorumlar orphan kalır; cascade delete ve tutarlılık tamamen uygulama koduna (observer, event, cron) kalır. Bu, DB'nin en güçlü garantisini elle taklit etmek demektir. 2. **Ayrı tablolar: temiz bütünlük, ama çoğalma.** Gerçek FK, cascade, tipe özel constraint'ler — hepsi DB tarafından garantili. Bedeli: N tablo, şema ve kod tekrarı, migration yükü ve "bir kullanıcının tüm yorumları" gibi sorgular için UNION. 3. **Orta yol — exclusive arc (tek tablo + tipli FK'ler).** Tek `comments` tablosu; `post_id`, `product_id`, `ticket_id` hepsi nullable, gerçek FK'li ve bir CHECK ile tam olarak biri dolu. Tek tablonun rahatlığı + gerçek foreign key. Bedeli: yeni tip eklemek yeni bir kolon, yani şema değişikliği. ```sql CREATE TABLE comments ( id BIGSERIAL PRIMARY KEY, body TEXT NOT NULL, post_id BIGINT REFERENCES posts(id) ON DELETE CASCADE, product_id BIGINT REFERENCES products(id) ON DELETE CASCADE, ticket_id BIGINT REFERENCES tickets(id) ON DELETE CASCADE, CONSTRAINT one_target CHECK ( (post_id IS NOT NULL)::int + (product_id IS NOT NULL)::int + (ticket_id IS NOT NULL)::int = 1 ) ); CREATE INDEX ON comments (post_id) WHERE post_id IS NOT NULL; CREATE INDEX ON comments (product_id) WHERE product_id IS NOT NULL; ``` 4. **Index tarafı iki yaklaşımda da kolay.** Morph'ta `(commentable_type, commentable_id)` composite index (Laravel'in varsayılanı) ["bu varlığın yorumları" sorgusunu](/tr/blog/eloquent-iliskileri-hasmany-belongsto-ve-eager-loading/) iyi karşılar. Arc'ta her FK için partial index yeterlidir. Yani index, kararın belirleyicisi değil. ## Ne yapmalı 1. **Morph kullanacaksanız morph map olmadan yapmayın.** Varsayılan olarak Laravel `commentable_type`'a tam sınıf adını (`App\Models\Post`) yazar. `enforceMorphMap` ile bunu kısa anahtara (`'post'`) çevirin: index ve storage küçülür, en önemlisi sınıf adını yeniden adlandırdığınızda veritabanı bozulmaz. 2. **Karar sürücüsü: tip sayısı stabil mi, bütünlük ne kadar kritik.** Tipler nadiren değişiyor ve integrity kritikse (ödeme, ticket gibi) arc veya ayrı tablolar. Tipler sürekli ekleniyor, esneklik integrity'den önemliyse morph + app-level bütünlük. **Sonuç:** Ben olsam 3 stabil tip ve gerçek FK ihtiyacıyla exclusive arc'ı seçerdim — tek tablo kalır, cascade delete'i veritabanına bırakırım. Tiplerin sayısı belirsiz ve sürekli büyüyecekse saf morph'a geçer, `enforceMorphMap` ile anahtarlarımı sabitler ve orphan temizliğini bir observer ile kod tarafında garantiye alırdım. Kararı "hangisi şık" değil, "referential integrity'yi DB'ye mi bırakacağım" sorusuyla verirdim. ## Eşzamanlı HTTP çağrıları için ham Fiber mı, ReactPHP/AMPHP mı, yoksa Swoole mu seçmeliyim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/eszamanli-http-cagrilari-ham-fiber-reactphp-amphp-swoole/ Kısa cevap: Ham Fiber tek başına bu iş için size hiçbir şey kazandırmaz. ## Kısa cevap Fan-out HTTP'de gerçek seçim, FPM içinde kalacaksanız curl_multi/Guzzle Pool, ergonomik async isterseniz AMPHP v3, mimariyi baştan kuracaksanız Swoole arasında. ## Neden 1. **Ham Fiber bir primitive'tir, çözüm değil.** Fiber sadece cooperative scheduling verir; kendi başına ne event loop ne de non-blocking I/O sağlar. HTTP çağrılarını gerçekten paralel çalıştırmak için soket katmanını, scheduler'ı ve promise mantığını sizin yazmanız gerekir — ki AMPHP ve ReactPHP zaten tam olarak bunu yapmak için var. Ham Fiber'a inmek, bu kütüphaneleri yeniden icat etmektir. 2. **AMPHP v3 = Fiber üstüne kurulu ergonomik async/await.** Zaten arka planda Fiber kullanır ama size temiz bir `async`/`await` API'si ve `amphp/http-client` verir. Callback cehennemi yok. Standart PHP kurulumunda çalışır; işler karmaşıklaşınca (birbirine bağlı çağrılar, streaming) burada ölçeklenir. 3. **ReactPHP olgun ama promise/callback stilinde.** Aynı işi görür, üretimde yıllardır sağlam. Ancak yeni kodda AMPHP'nin async/await'i çok daha okunur; ben yeni projede AMPHP'yi tercih ederim. 4. **Swoole ayrı bir dünyadır.** Extension ister, blocking I/O'yu hook'lar ve FPM modelini bırakıp long-running worker'a geçmenizi gerektirir. Throughput yüksektir ama operasyonel maliyet, [state sızıntısı riski](/tr/sor-bakalim/laravel-octane-frankenphp-bellek-sizintisi-nasil-debug-edilir/) ve bazı extension'larla uyumsuzluk da yüksektir. Sırf 200 çağrı için bu bedeli ödemem. ## Ne yapmalı 1. **En sıkıcı çözüm çoğu zaman doğru olanı: curl_multi / Guzzle Pool.** Yalnızca N tane HTTP çağrısını eşzamanlı yapmak istiyorsanız, `curl_multi` (veya onu saran Guzzle Pool) standart PHP-FPM içinde gerçek paralel istek yapar. Yeni bir extension kurulumu gerekmez, zaten var olan curl kullanılır; runtime değişikliği yok, deployment aynı kalır. 2. **Asıl mesele runtime değil, o 200 sayısı.** Aynı anda 200 açık bağlantı hem sizi hem upstream'i yorar. 20-50 pencereli bir concurrency cap koyun, her çağrıya timeout verin, partial failure'ı ele alın (biri düşünce hepsi düşmesin). ```php use GuzzleHttp\Client; use GuzzleHttp\Pool; use GuzzleHttp\Psr7\Request; $client = new Client(['timeout' => 3.0]); $requests = static function (array $urls) { foreach ($urls as $u) { yield new Request('GET', $u); } }; $pool = new Pool($client, $requests($urls), [ 'concurrency' => 25, // aynı anda en fazla 25 açık bağlantı 'fulfilled' => fn($res, $i) => $results[$i] = (string) $res->getBody(), 'rejected' => fn($reason, $i) => $errors[$i] = $reason, // partial failure ]); $pool->promise()->wait(); ``` **Sonuç:** Ben olsam FPM'de kalıp Guzzle Pool ile başlar, concurrency'yi 20-50 arasında sabitler, timeout ve partial failure'ı düzgün yönetirdim — bu bugün deploy edilebilir en basit çözüm. Kod tabanı gerçekten async'e evrilirse AMPHP v3'e geçerdim. Swoole'u yalnızca servisi long-running bir modele taşıma kararı zaten masadaysa düşünürdüm. Ham Fiber'ı ise kütüphane yazmıyorsanız hiç düşünmezdim. ## Bir junior'a, işini onun yerine yapmadan nasıl mentorluk yaparım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/juniora-isini-onun-yerine-yapmadan-mentorluk-yaparim/ Kısa cevap: Klavyeye uzanma refleksini öldürmeniz gereken şey tam olarak budur. ## Kısa cevap Mentorluğun işi bugünü hızlandırmak değil; [junior'ı sizsiz üretken hale getirmektir](/tr/blog/mentorluk-ve-bilgi-paylasimi-baskasini-buyuturken-buyumek/). O yüzden ölçütünüz "ne kadar hızlı ship ettim" değil, "her hafta bana biraz daha az ihtiyaç duyuyor mu" olmalı. ## Ne yapmalı 1. **Elinizi klavyeden fiziksel olarak çekin.** Gerekirse ellerinizin üstüne oturun ve onun sürmesine izin verin. Sessizliğin verdiği rahatsızlık, öğrenmenin bedelidir. Devraldığınız her seferinde ona "nasılsa seni ben kurtarırım" diye öğretmiş olursunuz. 2. **Söylemeyin, sorun.** "Ne denedin? Bu satırın ne yapmasını bekliyorsun? Sence nerede kırılıyor?" Sokratik sorular hata ayıklama modelini inşa eder; hazır cevap ise sadece bir bilgiyi transfer eder ve ertesi gün buharlaşır. 3. **Mücadeleye süre koyun (timebox).** Verimli mücadelenin bir sınırı vardır. Baştan anlaşın: "30 dakika takıldıysan bana haber ver." Çok kısa süre hüsran ve öğrenilmiş çaresizlik doğurur; çok uzun süre günü çöpe atar. Bu eşiği kişiye göre kalibre edin. 4. **Geri alınabilir hatalar yapmasına izin verin.** Prod'a gitmeyecek ve yıkıcı olmayan bir şeyse, yanlış yaklaşımın sonuna kadar gitmesine izin verin — kendi yaptığı hatadan çıkan ders, size bakarak aldığı tavsiyeden çok daha kalıcıdır. Sert müdahaleyi yalnızca geri alınamaz veya güvensiz şeyler için saklayın. 5. **"Ne yazılacağı"na değil, "nasıl düşünüleceği"ne pair olun.** Klavyeyi ele aldığınız anlarda muhakemenizi sesli anlatın: bir stack trace'i nasıl okuyorsunuz, hipotezi nasıl kuruyorsunuz, neden önce X'i kontrol ediyorsunuz. Transfer edilebilir beceri budur, kodun kendisi değil. 6. **Süreci övün, ayrıntıyı async'e taşıyın.** Sadece çalışan kodu değil, iyi muhakemeyi takdir edin. Ufak nitpick'leri PR review'a bırakın ki denemek için kesintisiz akışı olsun. Sürekli omzunun üstünden bakmak öğrenmeyi değil, kaygıyı büyütür. 7. **"Bilmiyorum" demeyi güvenli kılın.** Bir junior takıldığını saklıyorsa öğrenemez. Kendi hatalarınızı ve bilmediklerinizi açıkça paylaşın; "ben de bunu her seferinde dokümana bakarak yapıyorum" demek, mükemmel görünme baskısını kaldırır. Psikolojik güven, tüm mentorluğun üzerine kurulduğu zemindir. 8. **Görünür bir ilerleme çıpası kurun.** Haftada 15-20 dakikalık bir 1:1 yapın; "bu hafta ne öğrendin, nerede takıldın, gelecek hafta neyi tek başına denemek istiyorsun" diye ilerleyin. Bu, hem siz hem o için bağımsızlaşmanın somut bir ölçüsü olur ve öğrenmeyi kişinin kendi sorumluluğuna taşır. **Sonuç:** Ben olsam kendimi tek bir soruyla ölçerdim: "Bu hafta bana geçen haftakinden daha az mı ihtiyaç duyuyor?" Kısa vadede yavaşlarsınız — ama o geçici yavaşlama, mentorluğun tam olarak amacıdır. Klavyeyi devralarak kazandığınız her gün, ekibe bağımlı bir kişi bırakır; sabrettiğiniz her gün ise bağımsız bir mühendis yetiştirir. ## Servislerimi önce RED metrikleriyle mi yoksa USE metrikleriyle mi enstrümante etmeliyim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/servislerimi-red-metrikleriyle-use-metrikleriyle-enstrumante-etmeliyim/ Kısa cevap: Bu ya/ya da bir seçim değil. ## Kısa cevap RED istek-güdümlü yüzeyler için, USE kaynaklar için. Her iki fleet'in de sonunda ikisine ihtiyacı var; sadece ağırlık farklı ve başlangıç sırası farklı. ## Neden 1. **RED = Rate, Errors, Duration.** İstek/servis bakış açısıdır. "Kullanıcılarıma iyi hizmet veriliyor mu?" sorusunu yanıtlar. Laravel API'nin HTTP endpoint'leri için birebir bu; oradan başlayın. 2. **USE = Utilization, Saturation, Errors.** Kaynak bakış açısıdır (CPU, bellek, kuyruk derinliği, connection pool). "Darboğaz hangi kaynakta?" sorusunu yanıtlar. Kapasite ve doygunluk kararlarınızı bu besler. 3. **Worker fleet ikisinin karıştığı yerdir.** Bir queue worker istek/cevap değildir, dolayısıyla saf RED garip durur. Çözüm: job'ı "istek" gibi modelleyin — rate = saniyedeki job sayısı, errors = başarısız job'lar, duration = işleme süresi. Böylece RED worker'a da uyar. 4. **USE, özellikle kuyruk derinliği için, öncü göstergedir.** Worker'larda kuyruk backlog'u (saturation), gecikme daha yükselmeden "yetersiz kapasitedesin" der. DB connection pool doygunluğu da klasik gizli katildir; USE olmadan onu geç fark edersiniz. 5. **RED semptom, USE sebep alarmıdır.** İkisi arasındaki asıl fark burada işe yarar: kullanıcıyı gerçekten etkileyen şey RED'dir, o yüzden sayfa çağıran (paging) alarmları RED üzerine kurun (hata oranı, p99 gecikme). USE metriklerini ise alarm için değil, ["neden" sorusunu yanıtlayan teşhis panoları](/tr/sor-bakalim/mikroserviste-dagitik-izleme-opentelemetry-ile-nasil-kurulur/) için kullanın — bir kaynak doygunluğa yaklaşıyorsa uyarı verir, ama gece sizi USE değil, bozulan RED aramalı. ```yaml # RED (histogram) — hem API hem worker (job'ı istek gibi say) http_request_duration_seconds_bucket{route="/checkout", le="0.5"} job_processing_duration_seconds_bucket{queue="emails", le="2"} # USE (gauge) — kaynak doygunluğu queue_depth{queue="emails"} db_pool_in_use_connections{pool="primary"} ``` ## Ne yapmalı 1. **API'de önce RED — çünkü SLO'lara birebir oturur.** Kullanıcıya dönük SLO'larınız gecikme + hata oranıdır; bu doğrudan RED demektir. Önce bunu enstrümante edin, sizi gece arayan da budur. 2. **Cardinality'yi patlatmayın.** Endpoint başına label sorun değil, ama `user_id` gibi sınırsız bir label Prometheus'u şişirir. Label setlerini sınırlı tutun; yoksa metrik altyapısının kendisi sorun olur. 3. **Duration'ı histogram olarak toplayın, ortalama tutmayın.** RED'in "D"si ortalama gecikme değil, p95/p99 anlamına gelir; ortalama, kuyruğun en kötü kullanıcı deneyimini gizler. Prometheus'ta `histogram` kullanın ve panolarda `histogram_quantile` ile persentil çıkarın. **Sonuç:** Ben olsam API'de RED ile başlar (SLO'ya en hızlı değer bu), worker'larda job'ı istek gibi modelleyip yine RED kurar, sonra her iki tarafa da USE'u (özellikle kuyruk derinliği ve pool doygunluğu) eklerdim. Sıra RED → USE; ama ikisi de olmadan resmin yarısını görürsünüz. Not: Google'ın "dört altın sinyali" (latency, traffic, errors, saturation) bu iki modeli birleştiren güzel bir köprüdür — takıldığınızda oradan da bakabilirsiniz. ## Container'daki Go worker'ım SIGTERM almıyor ve graceful kapanmıyor, bu bir PID 1 sorunu mu? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/containerdaki-go-workerim-sigterm-almiyor-pid-1-sorunu/ Kısa cevap: Büyük olasılıkla evet, bu bir PID 1 sorunu. ## Kısa cevap Ama iki ayrı hata aynı anda var: sinyal büyük ihtimalle shell-form `CMD` yüzünden worker'a hiç ulaşmıyor, ayrıca kodunuz `os.Interrupt` (SIGINT) dinleyip SIGTERM'ü kaçırıyor. ## Neden 1. **Shell form sizi /bin/sh -c'nin çocuğu yapar.** `CMD ./worker` (shell form) worker'ı `sh`'nin altında çalıştırır; PID 1 olan `sh` ise SIGTERM'ü çocuğuna iletmez. Bu, sinyalin worker'a hiç ulaşmamasının bir numaralı sebebidir. Çözüm exec form: `CMD ["./worker"]` ya da `ENTRYPOINT ["/app/worker"]`. ```dockerfile # SIGTERM'ü doğrudan binary'ye iletir ENTRYPOINT ["/app/worker"] ``` 2. **PID 1'in varsayılan sinyal davranışı yoktur.** Doğrudan PID 1 olsanız bile kernel, PID 1 için varsayılan sinyal dispozisyonlarını uygulamaz; handler'ı açıkça kurmak zorundasınız; Go'nun varsayılanına bırakırsanız SIGTERM programın drain edilmeden çıkmasına yol açar. Sadece `os.Interrupt` dinliyorsanız SIGTERM'ü kaçırırsınız — Kubernetes'in deploy'da gönderdiği sinyal tam olarak SIGTERM'dür. ```go ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer stop() // ctx'i worker döngüsüne geçirin; iptal gelince yeni iş çekmeyi durdurun, // devam eden işi bitirin, çıkın. ``` 3. **entrypoint script'i de bunu bozar.** Kabuk bir entrypoint kullanıyorsanız son satır mutlaka `exec "$@"` olmalı. `exec` olmadan `"$@"` çalıştırırsanız kabuk PID 1 olarak kalır ve sinyali yine yutar. ## Ne yapmalı 1. **Sinyal → context iptal → drain zincirini kurun.** SIGTERM'de worker context'ini iptal edin, yeni job çekmeyi bırakın, in-flight işi bitirin ve çıkın. Bu, `terminationGracePeriodSeconds` (varsayılan 30s) içinde tamamlanmalı; aşarsanız SIGKILL yersiniz. Uzun işler varsa grace period'u buna göre ayarlayın. 2. **Çocuk process açıyorsanız zombie reaping gerekir.** Worker başka process'ler fork ediyorsa PID 1 olarak onları reap etmeniz gerekir; bu durumda `tini`'yi image'a kendiniz eklemeniz gerekir (Docker'da bunu `docker run --init` sağlar, Kubernetes'in buna denk yerleşik bir bayrağı yoktur). Tek bir Go binary için genellikle exec form + doğru sinyal yakalama yeter, tini'ye ihtiyaç olmaz. 3. **Önce teşhisi doğrulayın, tahmin etmeyin.** Container içinde PID 1'in gerçekten binary'niz mi yoksa `sh` mi olduğunu görmek beş saniyelik iştir: `docker exec ps -o pid,comm`. PID 1'de `sh` görüyorsanız teşhis kesindir. Ayrıca image'ın SIGTERM beklediğinden emin olun; `STOPSIGNAL SIGTERM` zaten varsayılandır ama farklı bir şeye ayarlanmışsa worker'ınız hiç beklemediği bir sinyal alıyor olabilir. 4. **Kubernetes'te trafiği önce kesin.** Graceful shutdown [sadece sinyal yakalamak değildir](/tr/sor-bakalim/autoscaling-sirasinda-sigterm-ile-graceful-shutdown/). Pod sonlandırılırken readiness probe'u düşürüp bir `preStop` hook (kısa bir sleep) koyarak, SIGTERM gelmeden önce pod'un Service endpoint'lerinden çıkarılmasını sağlayın. Aksi halde drain ederken bile yeni istek/job almaya devam edebilirsiniz. **Sonuç:** Ben olsam önce `docker exec ... ps` ile PID 1'i doğrular, `Dockerfile`'ı exec form'a çevirir, kodda `os.Interrupt` yerine `signal.NotifyContext(..., syscall.SIGTERM, syscall.SIGINT)` kullanır ve grace period içinde drain edildiğini bir deploy ile doğrulardım. tini'yi yalnızca worker gerçekten çocuk process fork ediyorsa eklerdim; aksi halde gereksiz katman olur. Sıralama: önce sinyal ulaşsın, sonra kod onu yakalasın, en son da trafiği zamanında kesin. ## API hata gövdelerimi RFC 7807 problem+json biçimine mi taşımalıyım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/api-hata-govdelerimi-rfc-7807-problem-json-tasimaliyim/ Kısa cevap: Evet, standartlaştırın. ## Kısa cevap Ama iki şart: 7807 yerine onu güncelleyen (obsolete eden) RFC 9457'yi baz alın ve bunu her endpoint'e elle değil, tek bir merkezi katmanda zorunlu kılın. ## Neden 1. **7807 artık RFC 9457.** Aynı şekil, 2023'te güncellendi. Yeni işlerde 9457'yi referans alın. Alanlar aynı: `type` (URI), `title`, `status`, `detail`, `instance` — ve istediğiniz kadar ekstra (extension) alan koyabilirsiniz. 2. **Asıl kazanç alan isimleri değil, tek tip zarftır.** İstemci ekipleri artık her serviste tek bir şekli parse eder. Sizin derdiniz zaten tam olarak buydu; standardın bütün değeri de burada. Yani evet, yapmaya değer. ## Ne yapmalı 1. **Content-Type'ı doğru set edin: application/problem+json.** İstemciler ve ara proxy'ler bunu normal bir gövdeden ayırt edebilsin. Bu başlığı unutmak, standardı yarım uygulamaktır. 2. **type stabil bir makine anahtarıdır — sözleşme gibi davranın.** Bir URN ya da bir dokümantasyon URL'i kullanın. İstemci `title`/`detail` gibi insan-okunur alanlara değil, `type`'a göre dallanır. `detail` içine stack trace veya iç ayrıntı sızdırmayın; orası [kullanıcıya dönük insan-okunur bir cümledir](/tr/blog/api-hata-sozlesmesi-istemciye-anlamli-hata-dondurmek/). ```json { "type": "https://api.example.com/problems/insufficient-funds", "title": "Insufficient funds", "status": 422, "detail": "Cüzdan 7f3a bakiyesi 12.00, gereken 50.00.", "instance": "/wallets/7f3a/withdrawals/9910" } ``` 3. **Tek yerde zorunlu kılın — exception handler / middleware.** Laravel'de (11'den beri `bootstrap/app.php` içindeki `withExceptions()->render()`, öncesinde `Handler`'ın `render` katmanı), Go'da merkezi bir "error → problem" mapper'da üretin. Her endpoint'te ayrı ayrı yazarsanız zamanla drift başlar; standardın anlamı da kaçar. 4. **Validation hataları için extension gerekir.** 9457 alan-bazlı hata listesi tanımlamaz. Doğrulama için bir `errors` dizisi extension'ı ekleyin ve bu şekli bir kez tüm servislerde sabitleyin — yoksa yine servis başına farklılaşır. 5. **Geçişi geriye dönük uyumlu planlayın.** Mevcut istemciler eski gövdeyi bekliyor olabilir. Sunucu tarafında yeni biçime geçmek kolay ama istemciler bir gecede güncellenmez. Yeni `problem+json` biçimini bir API sürümüne bağlayın ya da geçiş penceresinde her iki alanı da (örneğin eski `message` ile yeni `detail`) bir süre birlikte döndürün; istemciler taşındıkça eskisini kaldırın. 6. **Her şeyi standarda boğmayın.** problem+json hata gövdeleri içindir; başarılı yanıtların şeklini değiştirmez. Ayrıca dokümantasyonu da otomatikleştirin: OpenAPI şemanızda `type` kataloğunu referans olarak tanımlayın ki istemciler hangi hata tiplerini bekleyeceklerini sözleşmeden görsün. **Sonuç:** Ben olsam 9457'yi baz alır, `type` için versiyonlanabilir bir URI şeması belirler, üretimi tek bir exception handler'a toplar ve validation için ortak bir `errors` extension'ı tanımlardım. Migrasyonu da tek seferde değil, `type` kataloğunu doldururken ve istemcileri taşırken kademeli yapardım. Standardın değeri disiplinde: tek zarf, tek üretim noktası, `type`'ı sözleşme gibi yönetmek. ## Heap erişimini önlemek ve index-only scan almak için covering index'e (INCLUDE) ne zaman geçmeliyim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/index-only-scan-covering-indexe-ne-zaman-gecmeliyim/ Kısa cevap: Sorgu gerçekten sıcaksa (yüksek frekans, düşük gecikme beklentisi) ve heap erişimi ölçülmüş bir maliyetse geçin. ## Kısa cevap Ama bir uyarı var: covering index tek başına index-only scan garantisi vermez — bu, tablonun visibility map durumuna bağlıdır. ## Neden 1. **INCLUDE, kolonları yalnızca leaf sayfalara koyar.** `INCLUDE (status, total)` bu kolonları B-tree'nin sıralama anahtarına eklemez; yani karşılaştırma maliyeti getirmezler ama sorguyu heap'e gitmeden karşılamak için orada dururlar. Amacınız tam olarak bu. 2. **Index-only scan visibility map'e bağlıdır.** Postgres heap'i yalnızca sayfa "all-visible" olarak işaretliyse atlar. Yazma yoğun bir tabloda bu harita vacuum'lar arasında bayatlar; o zaman covering index olsa bile EXPLAIN'de `Heap Fetches: N` görürsünüz. Yani asıl kaldıraç bazen index değil, autovacuum'u sıkılaştırmaktır. ```sql CREATE INDEX idx_orders_customer ON orders (customer_id) INCLUDE (status, total); EXPLAIN (ANALYZE, BUFFERS) SELECT status, total FROM orders WHERE customer_id = $1; -- Hedef: "Index Only Scan ... Heap Fetches: 0" ``` 3. **Bu bir yazma/alan takasıdır.** Daha geniş leaf sayfaları = daha büyük index, daha fazla WAL, daha yavaş yazma, cache'te tutulacak daha çok veri. Bu maliyeti yalnızca gerçekten sıcak okumalar hak eder; her sorgu için covering index dökmeyin. 4. **INCLUDE kolonları yalnızca projeksiyon içindir.** Bunu unutmayın: `INCLUDE` ile eklenen kolonlar index taramasında arama koşulu olarak kullanılamaz, sadece `SELECT` çıktısını karşılamak için oradadır. Eğer o kolon üzerinde de filtreleyecekseniz onu anahtara (belki [bileşik index'e](/tr/sor-bakalim/postgresql-composite-index-kolon-sirasi-nasil-secilir/)) koymanız gerekir; ihtiyacınız sadece "geri döndürmek" ise INCLUDE doğru yerdir. ## Ne yapmalı 1. **Kolonu anahtara eklemektense INCLUDE'u tercih edin.** `status`, `total`'ı index anahtarına koymak iç düğümleri şişirebilir ve ihtiyacınız olmayan bir sıralama dayatır. INCLUDE ağacı ince tutar, sadece leaf'i genişletir — sorguyu karşılamak için ihtiyacınız olan tam da bu. 2. **EXPLAIN (ANALYZE, BUFFERS) ile doğrulayın.** "Index Only Scan" ve "Heap Fetches: 0" görene kadar iş bitmiş sayılmaz. Heap fetch yüksek kalıyorsa çözüm daha fazla index değil, `VACUUM` (ya da autovacuum eşiklerini düşürmek) olabilir. İndex'in gerçekten kullanılıp kullanılmadığını `pg_stat_user_indexes` ile takip edin; kimsenin dokunmadığı bir covering index sadece yazma yükü demektir. **Sonuç:** Ben olsam önce `EXPLAIN (ANALYZE, BUFFERS)` ile heap erişiminin gerçekten baskın maliyet olduğunu doğrular, sonra `customer_id` index'ini `INCLUDE (status, total)` ile yeniden oluşturur ve `Heap Fetches: 0` görene kadar autovacuum'u bu tablo için sıkılaştırırdım. Zaman içinde index'in şiştiğini görürseniz `REINDEX CONCURRENTLY` ile toparlayın. Kısacası: bu iki kolon gerçekten sıcak bir sorgunun read-only yükü ise covering index kazandırır; değilse ölçmeden index eklemek çoğu zaman sadece yazma yolunu yavaşlatır. ## Birbirine bağımlı birden fazla işi, ilk hata hepsini iptal edecek şekilde errgroup ile mi yürütmeliyim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/birbirine-bagimli-birden-fazla-isi-ilk-hata-hepsini-iptal-edecek-sekilde/ Kısa cevap: Evet, geçin. ## Kısa cevap Bu tam olarak `errgroup.WithContext`'in çözmek için var olduğu senaryo; burada overkill değil, doğru araç. `sync.WaitGroup` size "hepsini bekle" verir, sizin istediğiniz ise "biri düşerse hepsini bırak". errgroup, `golang.org/x/sync/errgroup` altında yaşayan küçük ve olgun bir bağımlılıktır; bu tür fan-out'lar için taşımaya kesinlikle değer. ## Neden 1. **errgroup.WithContext deyimi budur.** `WithContext`, gruba bir türetilmiş context verir ve goroutine'lerden biri `nil` olmayan bir hata döndürdüğü anda bu context'i iptal eder. Yani ilk hata, kalan çağrılara "durun" sinyalini otomatik gönderir; sizin [elle sinyalleşme kurmanıza](/tr/blog/goda-eszamanlilik-goroutine-ve-channel-pratigi/) gerek kalmaz. 2. **İptal işbirlikçidir — çağrılarınız ctx'e uymak zorunda.** Burası en sık atlanan nokta. `ctx` iptal olsa bile, HTTP çağrınız `http.NewRequestWithContext` ile bu context'i taşımıyorsa iptalin hiçbir etkisi olmaz; çağrı bitene kadar çalışmaya devam eder. Aynısı DB sorguları için de geçerli. Context'i tabana kadar geçirmezseniz errgroup'un yarısı boşa gider. ```go g, ctx := errgroup.WithContext(ctx) results := make([]Result, len(ids)) for i, id := range ids { i, id := i, id // Go 1.22 öncesi döngü değişkeni yakalama g.Go(func() error { r, err := lookup(ctx, id) // ctx içeride http.NewRequestWithContext'e gider if err != nil { return fmt.Errorf("lookup %s: %w", id, err) } results[i] = r return nil }) } if err := g.Wait(); err != nil { return nil, err // yalnızca İLK hata } ``` 3. **g.Wait() yalnızca ilk hatayı döner.** Diğer goroutine'lerin hataları düşürülür. Beş çağrının hepsinin hatasını toplamak istiyorsanız errgroup yanlış araçtır; o zaman `WaitGroup` + kanal ya da `errors.Join` gerekir. Ama sizin ihtiyacınız "ilki yeter, gerisini iptal et" olduğu için birebir uyuyor. 4. **Wait() yine de tüm goroutine'ler dönene kadar bloklar.** İptal "durun" der ama `Wait`, her goroutine gerçekten geri dönene kadar beklemeyi sürdürür. Ctx'i yok sayan bir çağrınız varsa yine en yavaşı kadar beklersiniz — bu yüzden 2. madde kritik. ## Ne yapmalı 1. **Paylaşılan sonuçlara senkronizasyonsuz yazmayın.** Her goroutine kendi `results[i]` indeksine yazıyorsa güvenlidir (ayrı bellek hücreleri). Ama ortak bir `map`'e yazarsanız data race olur — `-race` ile yakalanır. Slice indeksi kullanın ya da mutex koyun. 2. **Fan out büyürse SetLimit.** Beş çağrı için sınırsız concurrency sorun değil, ama bu sayı büyürse `g.SetLimit(n)` ile eşzamanlı goroutine sayısını kapatın; upstream'i ezmeyin. 3. **Üst context'e bir deadline koyun.** İptal, yalnızca çağrılardan biri hata dönerse tetiklenir. Hiçbiri hata dönmüyor ama biri asılı kalıyorsa (yavaş upstream, yanıt vermeyen soket), `errgroup` sonsuza kadar bekler. Bunun için `WithContext`'e verdiğiniz context'i `context.WithTimeout` ile sarın; deadline dolduğunda ctx iptal olur, ctx'e uyan tüm çağrılar geri döner ve isteğiniz belirli bir üst sınırda biter. **Sonuç:** Ben olsam `errgroup.WithContext`'e geçer, üstüne bir request-level timeout koyar, ctx'i her HTTP/DB çağrısına kadar geçirir ve sonuçları per-index slice'a yazardım. Go 1.22+ kullanıyorsanız döngü değişkeni yakalama satırına da gerek yok. Kısacası: hem "biri düşerse bırak" hem de "kimse dönmezse zamanaşımına uğra" garantisini birlikte kurun. ## İşlediğim satırları aynı anda güncellerken chunk yerine chunkById mı kullanmalıyım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/isledigim-satirlari-ayni-anda-guncellerken-chunk-yerine-chunkbyid-mi-kullanmaliyim/ Kısa cevap: Evet, `chunkById()` kullanın. ## Kısa cevap Gördüğünüz atlama, `chunk()`'ın OFFSET tabanlı sayfalamasının bilinen bir tuzağıdır ve sizinki tam ders kitabı örneği. Tek bir koşul var: cursor kolonunu (genelde `id`) asla güncellememeniz. ## Neden 1. **`chunk()` neden atlıyor.** `chunk()` her sayfa için `LIMIT/OFFSET` ile ayrı bir sorgu atar. İlk 500 satırı işleyip `status`'ü `processed` yapınca bu satırlar `where status = pending` filtresinden düşer; ikinci sayfada `OFFSET 500` artık daralmış bir sonuç kümesine uygulanır ve tam ortadaki bir dilim hiç görülmeden atlanır. 2. **`chunkById()` nasıl çözer.** OFFSET yerine keyset (seek) sayfalama yapar: `WHERE id > ? ORDER BY id LIMIT n`. Bir sonraki sayfa daima son işlenen id'den devam eder; işlenenler zaten cursor'ın gerisinde kaldığı için "kayma" diye bir şey olmaz. 3. **En hızlısı çoğu zaman iterasyon değildir.** Güncelleme deterministikse (satır başına hesaplama yoksa) satır satır dönmeye hiç gerek yok; tek bir set-based sorgu hepsinden hızlıdır ve atomiktir: ```sql UPDATE orders SET status = 'processed' WHERE status = 'pending'; ``` ## Ne yapmalı 1. **`chunk()` yerine `chunkById()` geçin.** Filtre kolonunu güncellemeye devam edebilirsiniz; kayma ortadan kalkar: ```php Order::where('status', 'pending') ->chunkById(500, function ($orders) { foreach ($orders as $order) { $order->update(['status' => 'processed']); } }); ``` 2. **Kritik kural: cursor kolonunu güncellemeyin.** `status`'ü güncellemek sorun değil, çünkü o cursor değil. Ama sıraladığınız kolonu (id) döngü içinde değiştirirseniz seek mantığı bozulur ve yine tutarsızlık yaşarsınız. 3. **Bellek için `lazyById()` düşünün.** Aynı seek mantığını kullanır ama closure yerine tek bir `LazyCollection` döner; `foreach` ile akıcı yazmak isterseniz, üstelik büyük tablolarda RAM'i şişirmeden, daha temiz olur. 4. **Lock ve deadlock'a dikkat.** Büyük batch update'lerde chunk boyutunu makul tutun (500–1000 iyi bir başlangıç) ve gerekiyorsa her chunk'ı kendi transaction'ında işleyin; tek dev transaction hem lock süresini hem deadlock riskini büyütür. Deadlock'u tespit etme ve önleme kurallarını [ayrı bir kayıtta](/tr/sor-bakalim/veritabani-deadlock-tespiti-ve-onleme-kurallari/) toplamıştım. **Sonuç:** Ben olsam satır başına gerçek bir iş varsa `chunkById()` (ya da akıcılık için `lazyById()`) kullanır ve cursor kolonuna asla dokunmazdım. Ama yaptığınız iş sadece bir kolonu güncellemekse, iterasyonu tamamen bırakıp tek bir set-based `UPDATE`'e geçerdim — hem daha hızlı hem de skip riski sıfır. ## readonly value object'lerimde "withX" tarzı kopyalama metotlarını nasıl temiz yazarım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/readonly-value-objectlerimde-withx-tarzi-kopyalama-metotlarini-nasil-temiz-yazarim/ Kısa cevap: `readonly`'de mutasyon yoktur; kopyalarken property'yi değiştirmeye çalışmak yerine yeni bir instance dönmelisiniz. ## Kısa cevap Aldığınız hata tam da bunu söylüyor — `$this->amount = ...` yasaktır, çünkü readonly property yalnızca kurulduğu scope'ta bir kez set edilebilir. `readonly`'nin domain modellerine ne kazandırdığını [PHP 8.1 yazısında](/tr/blog/php-8-1-yolda-enumlar-ve-readonly-ile-daha-guvenli-modeller/) anlatmıştım; withX deseni de o garantinin doğal sonucu. ## Neden 1. **Kök neden.** readonly property'yi ilk atamadan sonra clone içinde bile (sıradan bir metotta) yeniden yazamazsınız; runtime bunu bilinçli olarak engeller. Yani withX'in içinde `$this`'i değiştirmek imkânsız. (Küçük bir düzeltme: `readonly` PHP 8.2 değil, **PHP 8.1** ile geldi.) 2. **PHP 8.3+: `__clone` yalnızca deep-clone içindir.** 8.3 ile readonly property'leri `__clone` içinde yeniden başlatabilirsiniz, ama bu withX'e dışarıdan yeni bir değer taşımaz; asıl amacı iç içe nesneleri (örneğin bir `DateTimeImmutable`) derin kopyalamaktır. withX'i bununla çözmeye çalışmayın. 3. **İki yolun doğrulama davranışı farklıdır.** `new self` her kopyayı constructor'dan geçirdiği için doğrulamayı (negatif tutar, geçersiz currency) bedavaya alırsınız. `clone($this, [...])` ise constructor'ı çağırmaz; invariant kontrolünüz varsa clone yolunda onu elle tetiklemeniz gerekir. ## Ne yapmalı 1. **Taşınabilir ve temiz desen: `new self` + named arguments.** Her withX, constructor'ı yeni değerlerle çağırır; böylece tüm invariant'lar tekrar doğrulanır. PHP 8.1'den beri her yerde çalışır: ```php public function withAmount(int $amount): static { return new self(amount: $amount, currency: $this->currency); } ``` 2. **Alan çoğaldıkça named arguments tekrarı kırar.** Çok alanlı bir VO'da her withX'te sadece değişeni yazar, gerisini `$this->...` ile taşırsınız; named arguments sıraya bağımlılığı ortadan kaldırır ve okunurluğu korur. 3. **PHP 8.5'teyseniz wither'ı tek satıra indirin.** 8.5, kopyalarken property override etmenizi sağlar: ```php public function withAmount(int $amount): static { return clone($this, ['amount' => $amount]); } ``` **Sonuç:** Ben olsam PHP 8.2/8.3'te `new self` + named arguments ile giderdim — hem taşınabilir hem de her kopyayı doğrulamadan geçiriyor. 8.5'e geçtiyseniz ve doğrulama basitse `clone($this, [...])` daha zarif; ama invariant'larınız katıysa, o güveni kaybetmemek için ben yine constructor tabanlı yolu tercih ederdim. ## Senior'dan staff'a geçişte işin doğası nasıl değişiyor? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/seniordan-staffa-geciste-isin-dogasi-nasil-degisiyor/ Kısa cevap: İş "iyi kod yazmak"tan "organizasyonun teknik kararlarını doğru yöne çevirmek"e kayar. ## Kısa cevap Etkinizi artık kendi commit'lerinizle değil, başkalarının çıktısıyla ölçersiniz — ve terfinizin ertelenmesi çoğu zaman tam bu etkinin görünür olmamasındandır. Bir alt basamakta, orta seviyeden senior'a geçerken beklenen "daha geniş etki"yi [şu kayıtta](/tr/sor-bakalim/orta-seviyeden-seniora-gecerken-beklenen-daha-genis-etki-yi-nasil-gosteririm/) ele almıştım; staff'ta aynı eğri bir kez daha, bu kez takım sınırının dışında kuruluyor. ## Neden 1. **Kapsam büyür.** Senior iyi tanımlanmış bir problemi, çoğunlukla tek bir takım içinde, sağlamca çözer. Staff ise sahipsiz, muğlak, takımlar arası problemleri seçer ve önce onları çözülebilir hâle getirir. "Hangi problemi çözelim?" sorusunun cevabı artık sizin işiniz. 2. **Etki dolaylılaşır.** Kendi yazdığınız kod azalır; işin özü başkalarını unblock etmek, standart koymak, doğru soruyu sormak olur. Bunu ego meselesi yapmayın — kaldıraç, "en çok kodu ben yazdım"da değil, "on kişinin işini hızlandırdım"dadır. 3. **Terfi mekaniği: staff verilmez, gösterilir.** Çoğu şirkette staff'a "terfi edilmez", zaten o seviyede çalıştığınız kanıtlanır. Ertelenme genelde "etki görünmüyor" ya da "kapsam yeterince büyük değil" demektir. ## Ne yapmalı 1. **En çok kullandığınız araç editör değil, doküman olsun.** Bir tech design/RFC yazmak, trade-off'ları açıkça ortaya koymak ve farklı takımları aynı yöne hizalamak günlük işe döner. Yetkiye değil, argümana dayanarak ikna etmeyi öğrenirsiniz. 2. **Yargınızı kullanın ve "hayır" demeyi öğrenin.** Değer, sadece ne yaptığınızda değil, neyi yapmamaya karar verdiğinizde ortaya çıkar. Doğru problemi seçmek, çoğu zaman "bunu şimdi yapmayalım" diyebilmektir; hem de görünürlüğü olan bir problemi seçmek. 3. **Teknik derinliği bırakmayın.** Tuzak, tamamen "toplantı insanı"na dönüşüp kodun kokusunu unutmaktır. Kararlarınızın ağırlık taşıması için sistemin içine yeterince inebiliyor olmanız gerekir; güveni oradan kazanırsınız. 4. **Somut artifact biriktirin ve bir sponsor edinin.** Yazdığınız tasarımları ve çözdüğünüz org-seviyesi problemleri görünür bir yerde toplayın; seviyeyi kanıtlayan şey anlatı değil, bu kayıtlardır. **Sonuç:** Ben olsam önümüzdeki dönemde org seviyesinde, sahipsiz bir problemi bilerek sahiplenir, çözümü yazılı bir tech strategy ile ortaya koyar ve etkiyi kendi çıktımla değil, hızlandırdığım takımların çıktısıyla gösterirdim. Ayrıca yöneticimle "staff sayılacak kapsam tam olarak nedir?" konusunu yazılı olarak netleştirirdim; muğlaklık, terfinin en sık ertelenme sebebidir. ## Correlation ID'yi HTTP isteğinden kuyruğa düşen job'lara kadar nasıl taşımalıyım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/correlation-idyi-http-isteginden-kuyruga-dusen-joblara-kadar-nasil-tasimaliyim/ Kısa cevap: Correlation ID'yi mesajın business gövdesine değil, taşıma metadata'sına (SQS message attribute) koyun ve geçtiği her sınırda context'e enjekte edin. ## Kısa cevap Kendi header'ınızı uydurmayın — W3C `traceparent` kullanın ki ileride OpenTelemetry ile uçtan uca trace'e bedavaya bağlanasınız. Laravel ile Go arasında OTel'i uçtan uca nasıl kurduğumu [dağıtık izleme kaydında](/tr/sor-bakalim/mikroserviste-dagitik-izleme-opentelemetry-ile-nasil-kurulur/) anlatmıştım; buradaki correlation ID, o kurulumun kuyruk sınırından geçen hâli. ## Neden 1. **ID iş verisi değildir; yeri payload değil attribute'tur.** SQS'e job'ı atarken `MessageAttributes` içine yazarsanız serileştirdiğiniz payload şeması bozulmaz ve worker mesaj gövdesini parse etmeden ID'yi okuyabilir. 2. **Standart seçin: W3C Trace Context.** `traceparent` iki dilde de hazır kütüphanelerle parse edilir; kendi formatınızı uydurursanız her serviste yeniden yazmak zorunda kalır ve OTel ekosisteminden kopmuş olursunuz. 3. **Attribute retry ve DLQ'da hayatta kalır.** Message attribute mesajla birlikte taşınır; job retry edilse ya da DLQ'ya düşse bile ID kaybolmaz — bu, en çok ihtiyaç duyacağınız anda (hata) ipin kopmamasını sağlar. ## Ne yapmalı 1. **Kaynağı tek yerde yakalayın.** Laravel'de bir inbound middleware, gelen istekte `traceparent` var mı diye baksın; yoksa üretsin. Bu değeri hem request context'ine hem de logger'ın kalıcı context'ine koyun; artık o istekteki her log satırı ID taşır. 2. **Go worker'da yeniden hydrate edin.** Mesajı alınca attribute'u bir carrier'a doldurup context'e extract edin, sonra logger'ı bu ID ile besleyin: ```go carrier := propagation.MapCarrier{} for k, v := range msg.MessageAttributes { carrier[k] = aws.ToString(v.StringValue) } ctx = otel.GetTextMapPropagator().Extract(ctx, carrier) cid := trace.SpanContextFromContext(ctx).TraceID().String() logger := slog.With("trace_id", cid) ``` 3. **Log formatını hizalayın.** Her iki tarafta da structured JSON log kullanın ve aynı alan adını (`trace_id`) yazın. Alan adları tutmuyorsa OpenSearch/CloudWatch'ta tek sorguyla iki servisi birleştiremezsiniz. **Sonuç:** Ben olsam ID'yi baştan W3C `traceparent` olarak üretir, SQS message attribute'unda taşır, iki tarafta da OTel propagator ile inject/extract eder ve structured logger'a `trace_id` olarak enjekte ederdim. Böylece hem grep'lenebilir bir correlation ID'niz olur hem de aynı çizgide gerçek distributed tracing'e geçmeniz için ekstra iş kalmaz. ## Container'ı non-root çalıştırınca mount edilen storage dizinine yazamıyorum, izinleri nasıl çözerim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/containeri-non-root-calistirinca-mount-edilen-storage-dizinine-yazamiyorum-izinleri-nasil/ Kısa cevap: Sorun izin bitlerinde değil, UID eşleşmesinde. ## Kısa cevap Container'daki kullanıcının sayısal UID'si, mount edilen dizinin sahibiyle uyuşmuyor; root iken çalışmasının sebebi de root'un (UID 0) her şeye yazabilmesiydi. Çözüm `chmod 777` değil, sahipliği hizalamak. İmajı non-root ve mümkün olduğunca dar kurmanın diğer yüzünü [çok aşamalı build ve distroless kaydında](/tr/sor-bakalim/docker-imaj-boyutunu-multi-stage-ve-distroless-ile-kucultmek/) ele almıştım. ## Neden 1. **Kök neden: UID, kullanıcı adı değil.** Linux izinlerinde önemli olan sayısal UID/GID'dir. Image'daki `app` kullanıcısı UID 1000 olabilir; ama mount ettiğiniz dizin host'ta başka bir UID'ye aitse kernel yazmayı reddeder. İsim eşleşmesi hiçbir şey ifade etmez. 2. **Named volume mü, bind mount mu?** Named volume Docker tarafından yönetilir ve ilk oluşumda image'daki mount noktasının sahiplik/izinlerini kopyalar (çoğunlukla root çıkar ama bu garanti değildir); bind mount ise host dizininin sahipliğini olduğu gibi taşır. İkisinde de çözüm farklı. 3. **`chmod 777` neden çözüm değil.** Bu bir güvenlik gerilemesidir ve non-root'a geçme amacınızı boşa çıkarır. Group-writable (`g+w`) izin ile doğru grup üyeliği zaten yeterlidir; sorun izin bitlerinin darlığı değil, sahipliğin yanlış yerde olmasıdır. ## Ne yapmalı 1. **Bind mount'ta build-time UID'yi hizalayın.** Dockerfile'da `ARG UID`/`ARG GID` ile kullanıcıyı host'unuzun UID'siyle oluşturun, kodu `COPY --chown=app:app` ile kopyalayın. Böylece entrypoint hilesine bile gerek kalmadan sahiplik baştan doğru olur. Yeni dosyaların grubunu korumak için dizine setgid bit'i (`chmod g+s`) verin. 2. **Named volume'de entrypoint'te hedefli chown + drop yapın.** Container'ı root olarak başlatın, yalnızca yazılabilir olması gereken dizinleri chown edin, sonra kullanıcıya düşün: ```bash #!/bin/sh # entrypoint (root ile başlar, sonra app'e düşer) chown -R app:app storage bootstrap/cache exec su-exec app "$@" ``` 3. **Kalıcı state'i doğru yere koyun.** Birden çok replica çalıştıracaksanız `storage/`'ı paylaşımlı bir dosya sistemi üzerinden değil, session/cache için Redis'e ve dosyalar için S3 gibi bir object storage'a taşıyın; volume izin derdi tamamen kaybolur. **Sonuç:** Ben olsam build-time `ARG UID` ile kullanıcıyı sabitler, `COPY --chown` kullanır ve named volume'ler için entrypoint'te yalnızca `storage` ve `bootstrap/cache`'i chown edip `su-exec` ile app'e düşerdim. Uzun vadede ise kalıcı veriyi mümkün olduğunca S3/Redis'e taşıyıp container'ı gerçekten stateless tutardım. ## OpenAPI şemasını önce mi yazmalıyım yoksa koddan mı üretmeliyim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/openapi-semasini-once-mi-yazmaliyim-yoksa-koddan-mi-uretmeliyim/ Kısa cevap: Asıl sorununuz "spec-first mi, code-first mi" değil — drift'in kendisi. ## Kısa cevap Hangi yönü seçerseniz seçin, spec ile kodu CI'da birbirine karşı doğrulamadığınız sürece ikisi yine ayrışacak. Yön bir tercih; doğrulama ise zorunluluk. Sözleşmeyi önden yazmanın tasarım tarafını [contract-first yazısında](/tr/blog/api-tasarimini-sozlesme-oncelikli-contract-first-yurutmek/) anlatmıştım; buradaki eksik parça o sözleşmeyi kodun üstünde tutan otomatik kapı. ## Neden 1. **İki yaklaşımın gerçek farkı.** Code-first'te (Scramble, L5-Swagger) şemayı controller'lardan üretirsiniz — Scramble bunu annotation yazmaya gerek kalmadan otomatik yapar, L5-Swagger ise annotation'lara dayanır; implementasyona sadık kalır ama tasarımı koda gömer, review'ı zorlaştırır ve annotation gürültüsü birikir. Contract-first'te önce `openapi.yaml`'ı yazarsınız, kodu ona uydurursunuz; tasarımı önden konuşturur ama disiplin ister. 2. **Drift'i bitiren tek şey doğrulamadır.** Yön ne olursa olsun, bir contract test olmadan yayınladığınız şema ile gerçek response yine kayar. Sizin ticket'larınız tam bu boşluktan geliyor — kimse elle senkron tutmayı sürdüremez. 3. **Maliyeti kabul edin.** Contract-first bir öğrenme eğrisi ve YAML disiplini ister; code-first hızlı başlar ama tasarım review'ı zayıf kalır. Karar, ekibinizin sözleşmeyi elle sürdürecek olgunlukta olup olmamasıyla ilgili. ## Ne yapmalı 1. **Partner'ınız varsa contract-first seçin.** Sözleşmeyi önce yayınlarsınız; partner Prism gibi bir mock server ile sizi beklemeden geliştirir, siz de implementasyonu sözleşmeye kilitlersiniz. Sözleşme tartışması koda dökülmeden biter. 2. **Laravel'i spec'e bağlayın.** `openapi.yaml`'ı repo'ya koyun, PR'da Spectral ile lint edin ve Spectator ile her endpoint'in gerçek response'unu şemaya karşı doğrulayın: ```php $this->getJson('/api/orders/42') ->assertValidResponse(200); // openapi.yaml'a göre şema doğrulaması ``` 3. **Tek doğru kaynağı (SSOT) belirleyin.** `openapi.yaml` tek gerçek olsun; yayınladığınız dokümanlar da, partner'ların ürettiği client'lar da CI'da o dosyadan türesin. İki kaynağınız varsa drift garantidir. **Sonuç:** Ben olsam contract-first giderdim: `openapi.yaml` SSOT olur; PR'da Spectral lint çalışır; testlerde Spectator ile request/response doğrulanır; docs ve partner client'ları CI'da bu dosyadan üretilir. Kritik olan yön değil, o yeşil contract test — drift tespitini kod review'ın insafına değil pipeline'a yıktığınız an ticket'lar kesilir. ## Sadece 'pending' satırları taranan bir kuyruk tablosunda partial index kullanmalı mıyım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/sadece-pending-satirlari-taranan-bir-kuyruk-tablosunda-partial-index-kullanmali-miyim/ Kısa cevap: Evet, partial index tam da bu senaryonun ders kitabı örneğidir. ## Kısa cevap `WHERE status = 'pending'` koşullu bir index yalnızca birkaç bin satırı tutar; küçük olur, belleğe sığar, taraması hızlı ve güncellemesi ucuzdur — milyonlarca ölü satıra hiç dokunmadan geçer. Index'in hangi kolonları, hangi sırayla içermesi gerektiğini [composite index kolon sırası kaydında](/tr/sor-bakalim/postgresql-composite-index-kolon-sirasi-nasil-secilir/) ayrıca ele almıştım; buradaki mesele o index'i ayrıca daraltmak. ## Neden 1. **Partial index yalnızca eşleşen satırları indeksler.** `CREATE INDEX ... WHERE status='pending'` ile index milyonlarca değil birkaç bin girdi tutar. Bu, onu neredeyse tamamen cache'te tutar ve her insert/update'te güncellenmesi gereken ağacı küçültür. 2. **Status üzerine düz index'ten kesinlikle daha iyidir.** Düşük kardinaliteli `status` kolonuna düz bir index'in büyük kısmı işe yaramaz `done` değerleridir; planner çoğu zaman onu es geçip seq scan yapar. Bu erişim deseni için partial index net üstündür. 3. **Aynı mantık her çarpık predicate için geçerlidir.** Ders yalnızca kuyruklara özgü değil: `WHERE deleted_at IS NULL` (soft-delete), `WHERE processed = false` gibi tablonun küçük bir azınlığını hedefleyen her sorguda partial index aynı kazancı verir. "Az sayıda satırı, çok sayıda ölü satırın arasından" çektiğiniz her yerde aklınıza gelsin. ## Ne yapmalı 1. **ORDER BY kolonunu index'e koyun.** Worker'lar tipik olarak `WHERE status='pending' ORDER BY created_at FOR UPDATE SKIP LOCKED` yapar. Sıralama anahtarını index'e dahil edin: `(created_at) WHERE status='pending'`. Böylece hem index sırasında tarama alırsınız hem de `SKIP LOCKED` ile kilitli satırları atlayıp sıradaki işi anında çekersiniz. SQL şöyle: ```sql CREATE INDEX idx_jobs_pending ON jobs (created_at) WHERE status = 'pending'; -- worker'ın çekiş sorgusu: SELECT id, payload FROM jobs WHERE status = 'pending' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1; ``` 2. **Predicate'i sorguda literal geçin.** Sorgunuzun `WHERE`'i, index predicate'inin (`status='pending'`) mantıksal olarak kapsadığı bir koşul olmalı. `status = $1` gibi parametreyle sorarsanız planner index'i seçemeyebilir; koşulu literal `'pending'` olarak yazın ki eşleşme kanıtlanabilsin. 3. **Churn ve bloat'a dikkat.** Bir satır `pending`'den `done`'a döndüğünde partial index'ten düşer — bu iyi. Ama yoğun update trafiği bloat üretebilir; autovacuum'un sağlıklı çalışması önemli. İyi haber: index küçük olduğu için vacuum'u da ucuzdur. **Sonuç:** Ben olsam çekiş predicate'i üzerine, `ORDER BY` kolonunu da içerecek şekilde bir partial index kurar ve onu `FOR UPDATE SKIP LOCKED` ile eşlerdim. Tek dikkat noktası: sorgularınızın `'pending'`'i literal geçmesi ki planner index'i gerçekten seçsin. `EXPLAIN (ANALYZE, BUFFERS)` ile index scan aldığınızı bir kez doğrulayın; sonra milyonlarca `completed` satırın birikmesine rahatça aldırmazsınız. ## İç içe geçmiş servis çağrılarında context iptalini doğru şekilde nasıl yayarım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/ic-ice-gecmis-servis-cagrilarinda-context-iptalini-dogru-sekilde-nasil-yayarim/ Kısa cevap: İptal ancak context'i gerçekten aşağı geçirirseniz yayılır. ## Kısa cevap `r.Context()`'ten başlayıp her downstream çağrıya (`db.QueryContext`, `http.NewRequestWithContext`) o context'i vermeniz gerekir; zincirin herhangi bir yerinde `context.Background()` kullanır veya context'i düşürürseniz iş çalışmaya devam eder. Aynı disiplinin bir başka yüzünü — ctx'i gözetmeyen işlerin arkada birikmesini — [goroutine sızıntıları kaydında](/tr/sor-bakalim/goroutine-sizintilarini-uzun-sure-calisan-bir-go-servisinde-nasil-tespit-edip/) ele almıştım. ## Neden 1. **En sık sebep: context'in koparılması.** Bir yerde `context.Background()`/`context.TODO()` kullanılmış ya da Context'siz metot çağrılmıştır: `db.Query` yerine `db.QueryContext`, `http.Get` yerine `req` üzerine context konmuş `client.Do`. Hata neredeyse her zaman burada. 2. **İptal işbirlikçidir; kısayollar onu hiç görmez.** net/http, client bağlantıyı kestiğinde `r.Context()`'i iptal eder — ama bu sinyalin bir etkisi olması için çağrının onu gözlemesi gerekir. `http.Get`/`http.Post` gibi kısayollar context almaz. Blocking bir çağrı yoksa da iptal kendiliğinden fark edilmez. 3. **İptal DB'ye kadar inmeyebilir.** `database/sql` + pgx ile context iptal edildiğinde driver varsayılan olarak alttaki bağlantıyı kapatır; sorgunun Postgres tarafında da gerçekten iptal edilmesi içinse `CancelRequestContextWatcherHandler`'ı ayrıca yapılandırmanız gerekir, bu varsayılan davranış değildir. İptal DB'ye kadar inmezse sorgu boşuna dönmeye devam eder. ## Ne yapmalı 1. **`r.Context()`'ten başlayın ve aşağı taşıyın.** Context'i `service → repository → driver` boyunca geçirin. Struct'a saklamayın; her fonksiyona ilk argüman olarak verin (`ctx context.Context`). İskelet şöyle: ```go func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // client kopunca iptal olur ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := h.svc.Do(ctx, id); err != nil { if errors.Is(err, context.Canceled) { return // client gitti, sessizce çık } http.Error(w, "internal", http.StatusInternalServerError) } } // repository: row := db.QueryRowContext(ctx, "SELECT ...", id) // dış çağrı: req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) ``` 2. **Çağrı yerlerini grep'leyin.** `context.Background()`, `context.TODO()`, `db.Query`, `http.Get` geçen satırları tarayın; kopma noktası büyük ihtimalle bunlardan biridir. 3. **DB: `QueryContext`/`ExecContext` kullanın.** Driver'ınızın context iptalini nasıl ele aldığını doğrulayın; gerekiyorsa Postgres tarafında iptali de yapılandırın. 4. **Dışarı giden HTTP: `NewRequestWithContext`.** İsteği bu context'le kurun ki client, iptal ve deadline'a uysun. 5. **Fazla iptal etmeyin — yaşaması gerekeni ayırın.** Bir side-effect isteğin ömründen bağımsız tamamlanmalıysa (outbox'a yazma, event yayınlama), yeni bir context türetin: Go 1.21+ ile `context.WithoutCancel(ctx)`, ya da kendi timeout'una sahip taze bir context. Aksi halde client'ın kopması side-effect'lerinizi de öldürür. 6. **İptalin yanına timeout ekleyin.** Her dış çağrı için `context.WithTimeout` koyun; böylece bağlantı hiç kopmasa bile takılan bir bağımlılık sizi sonsuza kadar bekletmez. 7. **Kendi goroutine'leriniz ve döngüleriniz de ctx'i gözlemlesin.** Uzun süren döngülerde `select { case <-ctx.Done(): return ctx.Err() ... }` ile çıkış kontrolü koyun. Handler içinde başlattığınız goroutine'lere de aynı `ctx`'i verin, yoksa istek gitse bile arka planda çalışmaya devam ederler. **Sonuç:** Ben olsam tüm çağrı yerlerini denetlerdim: içeri `r.Context()`, her katmanda `*Context`'li metotlar ve yalnızca kopmadan sağ çıkması gereken birkaç side-effect için `WithoutCancel`. Bir kez bu disiplini oturttuğunuzda client bağlantıyı kestiği an Postgres sorgusu ve dış çağrı da kendiliğinden düşer; boşa yanan CPU ve bağlantı da onlarla birlikte kaybolur. ## Integer cent olarak sakladığım para alanları için custom cast mı yoksa accessor/mutator mı kullanmalıyım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/integer-cent-olarak-sakladigim-para-alanlari-icin-custom-cast-mi-yoksa/ Kısa cevap: Custom cast (`CastsAttributes` implement eden bir sınıf) kullanın. ## Kısa cevap Cast'lar tam olarak value object ↔ kolon eşlemesi için tasarlandı; accessor/mutator daha eski ve tek kolonluk bir VO için daha hantal bir araçtır. `Money` gibi bir değer nesnesini modern PHP'de nasıl tasarladığımı [değer nesneleri yazısında](/tr/blog/modern-phpde-deger-nesneleri-value-object-tasarlamak/) anlatmıştım; buradaki soru o nesneyi Eloquent'e hangi kancadan bağlayacağınız. ## Neden 1. **Custom cast bu iş için biçilmiş kaftandır.** `get()`/`set()` içeren tek bir cast sınıfı yazar, onu her model ve her kolonda yeniden kullanırsınız. Accessor'lar model başına, attribute başına metotlardır; aynı Money mantığını her modelde tekrar yazmak zorunda kalırsınız. 2. **Çok kolonlu senaryoyu cast temiz çözer.** Tutar ve currency iki ayrı kolonsa, cast `get()`/`set()` içindeki `$attributes` dizisi üzerinden ikisini birden okuyup yazabilir. `set()`, tek attribute yerine `['amount_cents' => ..., 'currency' => ...]` gibi bir dizi döndürebilir. Accessor/mutator ile iki kolonu tek objeye map edip geri iki kolona yazmak çirkinleşir. İleride currency ekleyecekseniz bu tek başına cast'ı seçtirir. 3. **Cast query builder'a uygulanmaz.** Cast yalnızca model attribute'larına giriş/çıkışta çalışır; `->where('price', $money)` gibi bir sorgu cast'tan geçmez. Filtrelerde hâlâ ham cent geçmeniz gerekir (`->where('price', $money->cents())`). Bu, insanların en sık takıldığı noktadır ve tercihinizden bağımsız olarak geçerlidir. ## Ne yapmalı 1. **Depolamayı integer cent bırakın.** `set()` içinde saklanacak int cent'i döndürün, `get()` içinde `Money::fromCents($value)` kurun. Model size her zaman bir `Money` verdiği için cent temsili dışarı sızmaz; kod tabanının geri kalanı `/100` görmez. Tek kolonluk çekirdek şöyle: ```php /** @implements CastsAttributes */ final class MoneyCast implements CastsAttributes { public function get($model, string $key, $value, array $attributes): ?Money { return $value === null ? null : Money::fromCents((int) $value); } public function set($model, string $key, $value, array $attributes): ?int { return $value?->cents(); } } // modelde: protected function casts(): array { return ['price' => MoneyCast::class]; } ``` 2. **Money'yi immutable tutun ve ona bir `cents()` verin.** VO'yu `readonly` yapın; para aritmetiğini (float'tan kaçınarak) ve formatlamayı orada tutun — Blade'de değil. Eşitlik (`equals`) ve currency uyuşmazlığı kontrolü de VO'nun işidir, cast'ın değil. Sorgularda kullanmak üzere `cents()`'i dışa açın. 3. **Kenar durumları unutmayın.** Nullable kolon için `get()`/`set()` içinde `null`'ı ele alın; cast her hydration'da çalıştığı için ucuz kalsın. Parametreleri (varsayılan currency gibi) `:` ile cast parametresi olarak verebilirsiniz; parametreler cast'ın constructor'ına ulaşır. **Sonuç:** Ben olsam custom cast + DB'de integer cent + immutable `Money` VO üçlüsünü kurardım; currency'yi de aynı cast içinden ikinci kolon olarak yönetirdim. Accessor/mutator'ı yalnızca tek modele özgü, VO'ya bile değmeyen basit bir hesaplanmış alan için saklardım. Böylece cent temsili modelin arkasında kalır ve geri kalan her katman yalnızca `Money` görür. ## Tip güvenli koleksiyonlar için PHPStan generics mi kullanmalıyım, yoksa her tür için ayrı sınıf mı yazmalıyım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/tip-guvenli-koleksiyonlar-icin-phpstan-generics-mi-kullanmaliyim-yoksa-her-tur/ Kısa cevap: Çoğu durum için PHPStan generics (generic docblock'lar ve array şekilleri) kullanın; elle yazılmış concrete koleksiyon sınıfına yalnızca davranış ya da runtime garanti gerektiğinde geçin. ## Kısa cevap Sırf tip için her türe ayrı sınıf yazmak boşa boilerplate'tir. PHP'nin motorunda tip bildirimlerinin nereye kadar gittiğini [skaler tip bildirimleri yazısında](/tr/blog/php-7-de-skaler-tip-bildirimleri-ve-donus-tipleri/) anlatmıştım; bir dizinin eleman tipi tam da o sınırın dışında kalır ve boşluğu analiz zamanında PHPStan doldurur. ## Neden 1. **PHP'de runtime generics yok — PHPStan generics tamamen statiktir.** `@param list $orders` size sıfır runtime maliyetiyle analiz zamanında tip güvenliği verir. İstediğiniz tam olarak budur; interpreter hiçbir şey kontrol etmez, PHPStan CI'da yakalar. 2. **Sadece sınıflar değil, fonksiyonlar da generic olabilir.** Tip taşıyan bir yardımcıya ihtiyacınız varsa (`first`, `map` gibi) sınıf yazmadan fonksiyonu `@template` ile generic yapabilirsiniz — `@param list $items` alıp `@return T` döndüren bir fonksiyon, tipi çağıran tarafta korur. Bu, koleksiyon etrafındaki çoğu ihtiyacı sınıf açmadan karşılar. 3. **Bu güvenlik yalnızca PHPStan gerçekten çalıştığı sürece vardır.** Annotation'ların runtime karşılığı olmadığı için analiz durduğunda kontrol de durur: annotation'lar çürür ve size sahte güven verir. Concrete alt sınıf ise runtime maliyeti ve boilerplate getirir; ikisinin arasındaki seçim tam olarak burada yapılır. ## Ne yapmalı 1. **Tipli dizilerle başlayın.** Koleksiyonları öylece dolaştırmak için imzalarda `list` veya `array` kullanın. Yeni sınıfa gerek yok; yanlış tip geçen çağrı PHPStan tarafında hemen kırmızıya döner. En ucuz ve en hızlı kazanç budur. 2. **Davranış istediğinizde tek bir generic Collection yazın.** `->map()`, `->filter()`, "boş değil" veya "benzersiz" gibi invariant'lar istiyorsanız `@template`'li TEK bir `Collection` sınıfı yazın — her tür için ayrı değil. Metotların dönüşü de tip taşıdığı için IDE ve PHPStan zinciri boyunca tipi korur. İskelet şöyle görünür: ```php /** * @template T of object */ final class TypedCollection { /** @var list */ private array $items = []; /** @param T $item */ public function add(object $item): void { $this->items[] = $item; } /** @return list */ public function all(): array { return $this->items; } } /** @var TypedCollection $orders */ ``` 3. **Concrete alt sınıfı yalnızca gerçek gerekçeyle açın.** `OrderCollection extends Collection` yazmayı ya domain okunabilirliği için ya da veri type-checked sınırınızın dışından (JSON, DB, kullanıcı girdisi) geldiğinde `add()` içinde `instanceof` ile runtime kontrol koymak için yapın. Gerekçesini net koyun. 4. **PHPStan'ı katı ve zorunlu koşun.** Level 10/max koşun (`checkGenericClassInNonGenericObjectType` ayarı PHPStan 2.0'da kaldırıldı, artık açmanıza gerek yok) ve PHPStan'ı CI'da zorunlu tutun. Generics ancak bu koşulda işe yarar. **Sonuç:** Ben olsam varsayılan olarak `list` docblock'ları + katı PHPStan ile giderdim; `map/filter` gibi davranış gerektiğinde tek bir generic `Collection` eklerdim; concrete alt sınıfları yalnızca güven sınırında, dışarıdan gelen veriyi runtime'da doğrulamak gerektiğinde yazardım. Böylece boilerplate'i minimumda tutar, güvenliğin çoğunu neredeyse bedavaya alırsınız. Unutmayın: bu güvenlik yalnızca PHPStan gerçekten çalıştığı sürece vardır; onu CI'da zorunlu tutmazsanız hiçbir annotation sizi korumaz. ## Orta seviyeden senior'a geçerken beklenen "daha geniş etki"yi nasıl gösteririm? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/orta-seviyeden-seniora-gecerken-beklenen-daha-genis-etki-yi-nasil-gosteririm/ Kısa cevap: "Daha geniş etki" daha çok kod ya da daha büyük feature değildir; etkinizin, tek başınıza yazdığınız kodun sınırlarını aşıp etrafınızdaki insanları ve sistemleri daha iyi hale getirmesidir. ## Kısa cevap Scope, satır sayısı değil etki yarıçapıdır. Burada kod değil davranış konuşuyor; değiştirmeniz gereken şey teslim ettiğiniz iş miktarı değil, işi nasıl sahiplendiğiniz. Unvanla birlikte gelmeyen bu sorumluluğun ne olduğunu [kıdem yazısında](/tr/blog/kidem-unvanla-gelmeyen-sorumluluk/) uzun uzun anlatmıştım. ## Neden 1. **Aradaki fark task değil, belirsizliktir.** Orta seviye net tanımlı task'leri iyi kapatır. Senior, bulanık ve tanımsız bir problemi alır: kendisi tanımlar, parçalara böler, insanlar arasında koordine eder ve belirsizliği yönetir. "Bana ne yapacağımı söyle"den "şu problemi ben alıyorum"a geçiş budur. 2. **Çarpan etkisi tek kişilik hızdan büyüktür.** Senior'ın kodu ekip tarafından kopyalanır. Bir kişinin yaptığı iş kadar, sizin sayenizde beş kişinin daha hızlı yaptığı iş de sizindir. 3. **Senior'lık doğru cevabı bilmek değildir.** Doğru soruyu sorup kararı yazılı savunabilmektir; "ne yapayım?" diye gelen biri, ne kadar iyi kod yazarsa yazsın karar masasında yer almaz. 4. **Sessizce iyi iş yapmak tek başına terfi getirmez.** Görünmeyen etki, terfi konuşulurken var sayılmaz — ve etkiyi teknik değil iş diliyle anlatmak, onu görünür kılmanın en kısa yoludur. ## Ne yapmalı 1. **Ticket değil, sonuç sahiplenin.** Bir sonraki bulanık işte lead'e gidip "ben bunu sahipleneyim mi?" diye sorun; evet aldığınızda haftalık kısa bir güncellemeyle ilerleyişi görünür tutun. Bu iki cümle çoğu insanın hiç yapmadığı şeydir. 2. **Çarpan olun.** Öğreten code review'lar, izlenen desenler, junior'ları açan dokümanlar — kendi throughput'unuzu değil ekibin throughput'unu artırın. 3. **Kararı tradeoff'larla getirin.** Lead'e "ne yapayım?" diye gitmek yerine "iki seçenek var, artıları/eksileri şunlar, ben B'yi öneriyorum çünkü…" diye gidin. Kısa design doc'lar, ADR'lar yazın. 4. **Operasyonel sahiplik alın.** Bir alanın on-call hikâyesini, toil'ini ve incident'lerini sahiplenin. Etkiyi teknik değil iş diliyle anlatın: "p99 latency'yi %40 düşürdüm" değil, "checkout'taki gecikmeyi düşürüp sepet terk oranını azalttım". Güvenilirlik, en görünür scope alanlarından biridir. 5. **Etkinizi görünür kılın.** Yaptıklarınızı bir "brag doc"ta biriktirin ve metriklerle bağlayın. En pratik adım ise şu: lead'inizden "sizin gözünüzde 'scope' gösteren somut bir örnek nedir?" diye net bir örnek isteyin, sonra onu bir projeye çevirin. **Sonuç:** Ben olsam terfiyi soyut bir hedef olarak kovalamaz, somut bir hamleye çevirirdim: ekipte kimsenin sahiplenmediği, herkesin şikâyet ettiği bulanık bir problemi seçip bir çeyrek boyunca uçtan uca üstlenirdim — tanımını, çözümünü ve ekibe yayılmasını. Terfi dosyanız tam olarak budur. Scope bir gün size verilmez; siz doldurmaya başlayınca fark edilir. ## Loglarımda WARN ile ERROR seviyelerini ne zaman kullanacağıma dair net bir kural nasıl belirlerim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/loglarimda-warn-ile-error-seviyelerini-ne-zaman-kullanacagima-dair-net-bir/ Kısa cevap: Seviyeyi "log satırının içeriği" değil, "birinin harekete geçmesi gerekiyor mu" sorusu belirlemeli. ## Kısa cevap ERROR = kırdığınız bir söz; WARN = sistemin kendi kendine toparladığı beklenmedik bir durum. Yani seviyeyi satırın kaynağı değil, sonucu belirlesin. Gürültünün bir de hacim tarafı var; logların diski doldurup krize dönüştüğü senaryoyu [log storm kaydında](/tr/sor-bakalim/log-storm-ve-disk-dolma-krizini-onlemek/) ayrıca ele almıştım. ## Neden 1. **Eksen aksiyon alınabilirliktir.** ERROR, bir isteğin veya job'ın sizin hatanızla başarısız olduğu ve birinin bakması gereken durumdur. WARN ise beklenmedik ama sistemin tolere ettiği bir şeydir: retry tuttu, fallback devreye girdi, soft limit'e yaklaşıldı. "Bu satır gece 3'te fire olsa birini uyandırır mıyım?" sorusuna evet diyorsanız ERROR'dır. 2. **Beklenen hata bug değildir.** 404, validation hatası, kullanıcının yanlış girdisi — bunlar sizin kodunuzun kırılması değil, INFO ya da en fazla WARN'dır. Kaba kural: 4xx client'ın sorunudur, 5xx sizindir. ERROR'ı yalnızca kendi kodunuzun verdiği sözü tutamadığında kullanın. 3. **Retry ve fallback farklı sonuçlar üretir.** Tekrar denenecek tek bir başarısız deneme WARN'dır; geçici bir titreme aksiyon gerektirmez. Ama tüm retry'lar tükendiğinde veya mesaj dead-letter'a düştüğünde artık ERROR'dır — çünkü iş kalıcı olarak yarım kaldı. 4. **ERROR ile FATAL/critical de aynı şey değildir.** FATAL, sürecin devam edemediği durumdur (config eksik, boot başarısız); ERROR ise bir isteğin/job'ın başarısızlığıdır, süreç ayaktadır. ## Ne yapmalı 1. **Alert'i yalnızca ERROR'a bağlayın, onu da oranla.** Uyarılarınız ERROR (ve fatal) üzerinden tetiklensin; tek satıra değil eşik/oran üzerinden ("5 dakikada 50 ERROR"). WARN dashboard'u besler, pager'ı değil. 2. **Aynı hatayı beş kez loglamayın.** Go'da her katmanda hem error return edip hem loglarsanız aynı başarısızlık beş satır olur. Kararın verildiği sınırda (handler) bir kez loglayın; alt katmanlar sadece error'ı wrap edip döndürsün. Laravel'de aynısı için `Log::error`'ı her yere serpmek yerine `report()` çağırın ve seviyeyi exception handler'ın `level()` metoduyla exception türüne göre ayarlayın. ```go if err := publish(ctx, msg); err != nil { if attempt < maxRetries { log.Warn("publish failed, will retry", "attempt", attempt, "err", err) return retry } // retry'lar tükendi: artık gerçekten aksiyon gerekiyor log.Error("publish failed permanently, sent to DLQ", "err", err) } ``` 3. **Her ERROR satırını aksiyon alınabilir yapın.** Her ERROR satırı, birinin gecenin bir yarısı bakacağını varsayarak yeterli context taşımalı: request/trace id, ilgili kimlikler ve wrap'lenmiş hata zinciri. Aksiyon alınamayan bir ERROR zaten ERROR değildir. **Sonuç:** Ben olsam ekibe tek cümlelik bir kural verirdim: "Bu log gece 3'te fire olduğunda birinin uyanması gerekiyorsa ERROR, gerekmiyorsa WARN." Buna ek olarak hatayı yalnızca sınırında bir kez loglama disiplinini koyar, alert'leri ERROR oranına bağlardım. Bu iki değişiklik gürültünün çoğunu tek başına eritir; kalanını da birkaç hafta boyunca yanlış seviyedeki logları düzelterek temizlersiniz. Amaç sıfır ERROR değil; her ERROR'ın gerçekten bir anlam taşıması. ## Container'daki healthcheck'i Dockerfile'a mı yazmalıyım yoksa Kubernetes probe'larına mı bırakmalıyım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/containerdaki-healthchecki-dockerfilea-mi-yazmaliyim-yoksa-kubernetes-probelarina-mi-birakmaliyim/ Kısa cevap: Kubernetes'te readiness/liveness'ı probe'lar sahiplenir; kubelet, imajdaki `HEALTHCHECK` durumunu tasarım gereği hiç okumaz. ## Kısa cevap Ama HEALTHCHECK'i Compose/düz Docker için tutmakta fayda var — çöp değil, sadece farklı bir runtime içindir. Kafa karışıklığı anlaşılır: iki katman da sağlığı sahipleniyormuş gibi görünür, ama belirli bir runtime'da gerçekten yalnızca biri dikkate alınır. Önce imajın gerçekte hangi runtime'da koşacağını netleştirin; cevap kendiliğinden oradan çıkar. Probe'ların kendi içindeki ayrımı — liveness neyi, readiness neyi ölçmeli — [health check tasarımı kaydında](/tr/sor-bakalim/health-check-tasarimi-liveness-ve-readiness-ayrimi/) ayrıntılandırmıştım. ## Neden 1. **Kubernetes, Dockerfile HEALTHCHECK'i tasarım gereği yok sayar.** kubelet kendi liveness/readiness/startup probe'larını çalıştırır ve imajın HEALTHCHECK sonucunu hiç okumaz. Yani k8s'te bu satır ölü ağırlıktır — zararsız ama trafiği belirleyen şey değildir. 2. **Kavramların sahibi Kubernetes tarafıdır.** Readiness (trafik geçişi), liveness (restart) ve startup (yavaş boot) kavramlarının hepsi Kubernetes probe kavramlarıdır. "Bu pod servis verebilir mi?" kararını sahiplenen katman budur. 3. **Docker/Compose HEALTHCHECK'e uyar.** Lokal geliştirmede Compose'un `depends_on: condition: service_healthy` özelliği ve düz Docker/Swarm, container'ı "healthy" olarak işaretlemek için HEALTHCHECK'i kullanır. Yani işe yaramaz değildir; sadece farklı bir tüketiciye hizmet eder. 4. **HEALTHCHECK'in bir bedeli var.** `CMD curl` imajda `curl` gerektirir (distroless'ta yoktur). Shell formundaki bir HEALTHCHECK ayrıca her interval'da bir süreç başlatır; sonucu hiç okunmayan bir container'da bu boşa iştir. ## Ne yapmalı 1. **Probe'ları Deployment/Pod spec'inde tanımlayın.** Trafiği ve restart'ı asıl belirleyen katman burasıdır; readiness, liveness ve startup'ı burada açıkça yazın. ```yaml # Kubernetes'te trafiği ve restart'ı asıl belirleyen katman budur: readinessProbe: httpGet: { path: /readyz, port: 8080 } periodSeconds: 5 livenessProbe: httpGet: { path: /healthz, port: 8080 } periodSeconds: 10 ``` 2. **Mantığı çoğaltmayın, endpoint'i paylaşın.** Aynı `/healthz` (liveness) ve `/readyz` (readiness) HTTP endpoint'lerini açın; hem Dockerfile HEALTHCHECK'ini hem de k8s probe'larını bunlara yöneltin. Tek implementasyon, iki tüketici. 3. **HEALTHCHECK'i ya doğru biçimde yazın ya da düşürün.** `CMD curl` yerine binary'nizin kendi `healthcheck` subcommand'ını exec edin; imaj yalnızca k8s'te koşacaksa HEALTHCHECK'i tamamen atın. ```dockerfile # Yalnızca Compose/düz Docker altında da koşacaksa anlamlı: HEALTHCHECK --interval=10s --timeout=2s --retries=3 \ CMD ["/app/server", "healthcheck"] ``` 4. **Probe'ları ucuz ve doğru tutun.** Aynı altın kural: liveness bağımlılıksız olsun, readiness bağımlılıkları kontrol etsin ama sonucunu birkaç saniye cache'lesin. Aksi hâlde probe'lar veritabanınızı DDoS'lar. **Sonuç:** Ben olsam readiness/liveness/startup'ı Kubernetes probe'ları olarak tanımlardım — çünkü trafiğe ve restart'a karar veren katman odur. HEALTHCHECK'i Dockerfile'da yalnızca aynı imaj Compose/düz Docker altında da koşuyorsa, ikisini de aynı `/healthz` + `/readyz` endpoint'lerine bağlayarak tutardım. İmaj sadece k8s içinse, "bir şey yapıyor" yanılsamasını önlemek için HEALTHCHECK'i düşürürdüm. ## Public API'mde bir endpoint'i kullanımdan kaldırırken sunset sürecini nasıl yönetmeliyim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/public-apimde-bir-endpointi-kullanimdan-kaldirirken-sunset-surecini-nasil-yonetmeliyim/ Kısa cevap: Para ödeyen partner'ların kullandığı bir endpoint'i asla sessizce silmeyin. ## Kısa cevap Duyurulmuş, tarihli ve ölçülen bir sunset yürütün — iletişim kurun, response içinde sinyal verin, kullanımı sıfıra izleyin, sonra kaldırın. Buradaki temel hata, kaldırmayı bir mühendislik olayı gibi görmektir; oysa bu asıl olarak bir iletişim sürecidir ve route'u nihayet silen kod değişikliği, tüm işin en son ve en küçük adımıdır. Bu kararın bir üst katmanını, yani endpoint yerine sürümü emekliye ayırmayı [API'de sürümleme kararları yazısında](/tr/blog/api-tasariminda-surumleme-versioning-kararlari/) tartışmıştım. ## Neden 1. **Tarih yoksa aciliyet yoktur.** Aciliyet yoksa kimse göç etmez; partner'a somut bir replacement ve gerçekçi bir pencere vermeden yapılan "yakında kaldıracağız" duyurusu hiçbir davranışı değiştirmez. 2. **Duyuruyu insanlar okumaz, makineler okur.** E-posta ve changelog tek başına yetmez; sinyal yanıtın içine girmezse entegrasyonun kendisi kaldırmadan haberdar olmaz. 3. **Göremediğiniz şeyi emekli edemezsiniz.** Kimin hangi sıklıkta çağırdığını bilmeden verilen bir kesme tarihi kumardır; ölçüm olmadan "kullanım sıfıra yaklaştı" cümlesini kuramazsınız. 4. **Sözleşme gerçekliği takvimden önce gelir.** Göçün ortasındaki büyük bir ödeyen partner'ın production'ını kırmak, düzenli bir deprecation takvimine uymaktan çok daha pahalıya patlar. ## Ne yapmalı 1. **Kesin bir tarih ve göç yolu ile duyurun.** E-posta + changelog + dokümantasyon; partner'a somut bir replacement ve gerçekçi bir pencere verin (ödeyen B2B için haftalar/aylar, günler değil). 2. **Response'un kendisinde sinyal verin.** İki ayrı başlık ve iki ayrı RFC var: `Sunset` (RFC 8594) kaynağın ne zaman yanıt vermez hâle geleceğini, `Deprecation` (RFC 9745) ne zaman kullanımdan kaldırıldığını söyler. Üçüncü olarak göç dokümanına bir `Link` başlığı ekleyin. Üçünü de ölmeye mahkûm endpoint'in her yanıtına koyun; böylece e-postayı görmezden gelen insanlar değil, makineler ve loglar da görür. Dikkat: `Deprecation` bir boolean değil — RFC 9745 değerin bir Date olmasını şart koşuyor (structured field; `@` ile başlayan Unix zaman damgası). ```http HTTP/1.1 200 OK Deprecation: @1780272000 Sunset: Sat, 31 Oct 2026 23:59:59 GMT Link: ; rel="deprecation"; type="text/html" ``` 3. **Tüketici bazında kullanımı ölçün.** Çağrıları API key/partner bazında loglayın; kimin hâlâ çağırdığını ve ne sıklıkta olduğunu tam olarak bilmeli ve o partner'lara doğrudan ulaşmalısınız. 4. **Kesmeden önce brownout yapın.** Tarihe yaklaşırken kısa, planlı kesintiler uygulayın (birkaç dakika `410`/`503` dönün, süreyi kademeli artırın). Bu, her başlığı ve e-postayı görmezden gelen entegrasyonları etki hâlâ geri alınabilirken yüzeye çıkarır. 5. **Kaldırırken doğru statüyü dönün.** Sessiz bir `500` değil; replacement'ı açıklayan bir gövdeyle `410 Gone` (veya `404`) dönün. Bu açıklayıcı yanıtı, kaldırmadan sonra uzun süre ayakta tutun. 6. **Bir eskalasyon valfi tutun.** Göçün ortasındaki büyük bir ödeyen partner için geçici bir allowlist/uzatma, onların production'ını kırmaktan iyidir. **Sonuç:** Ben olsam önce partner bazında kullanımı enstrümante ederdim, sonra her yanıta başlıkları koyarak tarihli bir sunset yayınlar, tarihe yakın birkaç brownout yapar ve ancak kullanım sıfıra yaklaşınca keserdim — üstelik notu her seferinde kaçıran o tek partner için elle bir uzatma valfi bırakarak. Public API'de itibar, tek bir sessiz kaldırmayla kaybedilir. ## Sürekli güncellenen bir sessions tablosunda staging'de Index Scan alan sorgu production'da neden Seq Scan'e düşüyor, EXPLAIN ile nasıl teşhis ederim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/surekli-guncellenen-bir-sessions-tablosunda-stagingde-index-scan-alan-sorgu-productionda/ Kısa cevap: Aynı sorgunun farklı plan alması, planner'ın maliyet tahmininin ortamlar arasında farklı olması demektir. ## Kısa cevap Çok yazılan bir `sessions` tablosunda bunun nedeni neredeyse her zaman bayat istatistik ve tablo bloat'udur — "eksik index" değil. Bir index eklemeden veya sorguyu yeniden yazmadan önce şunu içselleştirin: plan, maliyet modelinin bir çıktısıdır ve maliyet modeli, kendisini besleyen istatistikler kadar iyidir. Girdileri düzeltirseniz plan çoğu zaman kendini toparlar. İstatistik tazelendikten sonra hâlâ index'ten şüphe ediyorsanız, kolon sırasının plana etkisini [PostgreSQL composite index kaydında](/tr/sor-bakalim/postgresql-composite-index-kolon-sirasi-nasil-secilir/) ayrıntılandırmıştım. ## Neden 1. **Yüksek devir hızlı tabloda istatistik bayatlar.** `sessions` sürekli INSERT/UPDATE/DELETE alır; autovacuum/autoanalyze buna yetişemeyince `n_distinct` ve histogramlar yanlış kalır. Planner "10 satır" derken gerçekte 2 milyon geliyorsa, planner yanıltılıyordur ve Seq Scan'i bu yüzden seçer. 2. **Tablo ve index bloat'u planı çevirir.** Sürekli update'lerden kalan dead tuple'lar heap'i ve index'leri şişirir; index canlı satırlara oranla o kadar büyür ki planner (haklı olarak) Seq Scan'i daha ucuz bulur. 3. **Veri dağılımı gerçekten farklı olabilir.** Staging'de küçük/tekdüze veri vardır; staging'de seçici olan bir predicate production'da satırların çoğuyla eşleşiyorsa Seq Scan orada gerçekten daha ucuzdur. Bu bir bug değildir — plan veriye göre doğrudur; planner'ı değil, sorguyu/index'i düzeltin. 4. **PostgreSQL'de native query hint yoktur.** Yani planı zorla çevirecek bir kaçış kapısı aramanın anlamı yok; asıl çözüm istatistik/bloat/index'i düzeltmektir. ## Ne yapmalı 1. **`EXPLAIN` değil, `EXPLAIN (ANALYZE, BUFFERS)` çalıştırın.** Düz `EXPLAIN` yalnızca tahmini gösterir; production'da `EXPLAIN (ANALYZE, BUFFERS)` ile tahmini ve gerçek satır sayısını yan yana görün. 2. **İstatistiği tazeleyin, ölü satırı ölçün.** `ANALYZE sessions;` çalıştırın; `pg_stat_user_tables`'ta `last_autoanalyze` ve `n_dead_tup`'a bakın ve bu tabloya özel daha agresif autovacuum düşünün. ```sql -- Teşhis: tahmin vs gerçek EXPLAIN (ANALYZE, BUFFERS) SELECT ... FROM sessions WHERE ...; -- Taze istatistik ve ölü satır kontrolü ANALYZE sessions; SELECT n_live_tup, n_dead_tup, last_autoanalyze FROM pg_stat_user_tables WHERE relname = 'sessions'; -- Bu tabloya özel daha agresif autovacuum ALTER TABLE sessions SET (autovacuum_vacuum_scale_factor = 0.02, autovacuum_analyze_scale_factor = 0.01); ``` 3. **Aurora'ya özgü noktaları hesaba katın.** Aurora reader/writer ayrımı ve staging'i production istatistikleriyle birebir taklit edememe gerçeği vardır. `sessions` için `autovacuum_vacuum_scale_factor`/`analyze_scale_factor`'ı düşürün; hep aktif oturumları filtreliyorsanız partial index düşünün. 4. **İlk refleks hint olmasın.** Önce istatistiği ve bloat'ı düzeltin; `random_page_cost`'u (varsayılan 4.0) düşürmek de planner'ı index taramasına doğru iter; ama bunu istatistik düzeltildikten sonraki bir ayar adımı olarak görün, çözüm olarak değil. **Sonuç:** Ben olsam önce production'da `EXPLAIN (ANALYZE, BUFFERS)` çekerdim, sonra tabloyu `ANALYZE` edip `n_dead_tup`'a bakardım; taze istatistikle plan çoğu zaman geri döner. `sessions` doğası gereği çok yazılan bir tabloysa, autovacuum'unu tabloya özel ayarlar ve aktif-oturum predicate'i için partial/covering index eklerdim. ## Goroutine sızıntılarını uzun süre çalışan bir Go servisinde nasıl tespit edip önlerim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/goroutine-sizintilarini-uzun-sure-calisan-bir-go-servisinde-nasil-tespit-edip/ Kısa cevap: Sayının istikrarlı biçimde tırmanması, bir channel/lock/IO üzerinde sonsuza dek bloklanmış ve iptal yolu olmayan goroutine'ler demektir. ## Kısa cevap Önce ölçün ve dökümü alın, sonra spawn noktasının sözleşmesini düzeltin. İçgüdü, artan sayıyı yüke bağlamaktır; ama sızıntının bir spike'ta olmayan bir imzası vardır: asla geri inmez. Koda dokunmadan önce bu ikisini ayırın, çünkü tamamen farklı çözümler gerektirirler. Buradaki channel ve `select` sözleşmesinin temelini [Go'da eşzamanlılık yazısında](/tr/blog/goda-eszamanlilik-goroutine-ve-channel-pratigi/) anlatmıştım; sızıntı, o sözleşmenin tutulmadığı yerde ortaya çıkar. ## Neden 1. **Kök neden neredeyse her zaman eksik iptaldir.** Okuyucusu gitmiş bir channel'a yazmaya çalışan goroutine, hiç kapanmayan bir `for range ch`, ya da context/timeout'suz bir HTTP çağrısı. Her goroutine'in bir çıkış yolu olmalı: `ctx.Done()`, kapanan bir channel veya sınırlı iş. 2. **Takılmış bir ağ okuması goroutine'i süreç ölene dek tutar.** Context aşağı taşınmadığında ve ağ çağrılarında timeout olmadığında `select` hiçbir zaman geri dönmez; goroutine park eder ve orada kalır. 3. **Sınırsız spawn modelinde sızıntının tavanı yoktur.** Event başına sınırsız `go func()` fırlatan bir servis, sızıntı başladığında hiçbir üst sınıra çarpmaz — sayı yükle birlikte tırmanmaya devam eder. ## Ne yapmalı 1. **Önce sayıyı doğrulayın.** `runtime.NumGoroutine()` değerini bir metrik olarak dışa açın. Sayı yükle birlikte monoton artıyor ve boşta bile geri inmiyorsa, bu bir spike değil sızıntıdır. Bu ayrım tüm teşhisin temelidir. 2. **pprof goroutine dökümü kanıttır.** `net/http/pprof`'u bağlayın ve `/debug/pprof/goroutine?debug=2` (ham stack'ler) ya da `debug=1` (gruplanmış) çıktısını alın. Binlerce kopyası olan stack, hepsi aynı channel recv/send veya `select` üzerinde park etmiş hâlde, tam da sızan noktadır. ```bash # Üretimde zirve anında park etmiş stack'i bul: curl -s 'http://localhost:6060/debug/pprof/goroutine?debug=2' | less ``` 3. **Context'i her yere taşıyın.** `context.Context`'i aşağıya geçirin, `select` içinde ona uyun ve tüm ağ çağrılarına timeout koyun (`http.Client.Timeout`, DB için query context). Takılmış bir ağ okuması üzerinde park eden goroutine, süreç ölene dek sızar. ```go func worker(ctx context.Context, jobs <-chan Job) { for { select { case <-ctx.Done(): // her goroutine'in çıkış yolu return case j, ok := <-jobs: if !ok { return } process(ctx, j) } } } ``` 4. **Sızıntıyı test edin.** `go.uber.org/goleak`'i `TestMain` içinde kullanın; test bitince hayatta kalan goroutine varsa test'i düşürsün. Yeni sızıntıları spawn noktası bazında, daha üretime çıkmadan yakalamanın en ucuz yolu budur. 5. **Eşzamanlılığı sınırlayın.** Event başına sınırsız `go func()` yerine worker pool / semaphore kullanın. Sınırsız spawn modelindeki bir sızıntının tavanı yoktur; havuz, hasarı en baştan sınırlar. **Sonuç:** Ben olsam `NumGoroutine()`'i hemen bir dashboard'a ve pprof endpoint'ini servise bağlardım; zirvede bir `goroutine?debug=2` dökümü alıp binlerce kopyalı park etmiş stack'i bulur, o spawn noktasının `ctx.Done()`'a uymasını sağlardım. Sonra `goleak`'i ekleyip bir daha regresyon olmamasını garantilerdim. Sızıntı bir "gizem" değildir; döküm size satırı gösterir. ## Eloquent'te N+1'i erken yakalamak için Model::preventLazyLoading'i yalnızca lokalde mi yoksa production'da da mı açmalıyım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/eloquentte-n-1i-erken-yakalamak-icin-model-preventlazyloadingi-yalnizca-lokalde-mi/ Kısa cevap: Lokal ve CI'da tam katı (throw) çalıştırın; production'da açık tutun ama fatal fırlatmak yerine log'layan bir handler'a bağlayın. ## Kısa cevap Endişeniz haklı — ham hâliyle production'a soğuk açmak, latent bir bug'ı anında outage'a çevirir. Buradaki tuzak, bunu ikili bir "lokal mı, production mı" anahtarı gibi düşünmektir. İşe yarayan sürüm, aynı ayarın ortama göre farklı davranmasıdır — bir geliştiricinin düzeltebileceği yerde gürültülü, gerçek bir kullanıcının 500 yiyeceği yerde sessiz. Bu ayarın yakaladığı sorunun kendisini, yani eager loading'in ne zaman gerektiğini [Eloquent ilişkileri yazısında](/tr/blog/eloquent-iliskileri-hasmany-belongsto-ve-eager-loading/) anlatmıştım. ## Neden 1. **Ayar gerçek bir kullanıcı isteğinde fırlatabiliyor.** `Model::preventLazyLoading()` herhangi bir lazy load denemesinde `LazyLoadingViolationException` fırlatır. Geliştirmede harikadır; ama test'lerinizin görmediği bir yolda gerçek bir kullanıcıya 500 döndürürse felakettir. 2. **Bu üç katı ayardan biridir.** `preventSilentlyDiscardingAttributes` ve `preventAccessingMissingAttributes` ile birlikte `Model::shouldBeStrict()` altında paketlenir. `shouldBeStrict()` üçünü birden açar; hepsini bilinçli açın, körlemesine değil. Diğer ikisi production'da daha düşük risklidir; ama gerçek bir kullanıcı isteğinde fırlatabilen tek ayar `preventLazyLoading`'dir, ayrı handler muamelesini o hak eder. 3. **Yalnızca lazy load'ları yakalar, her N+1'i değil.** Döngü içinde çalışan, ilişki lazy-load'u olmayan bir sorgu bu mekanizmaya takılmaz. Bu yüzden test'lerinizde Telescope/Clockwork veya sorgu sayısı assertion'ları hâlâ gerekir. ## Ne yapmalı 1. **Ortama göre koşullayın.** `AppServiceProvider::boot()` içinde `Model::preventLazyLoading(! $this->app->isProduction())` deyin. Böylece production dışında her yerde katı, production'da ise throw kapalı olur. 2. **Production'da throw yerine handler ile log'layın.** `Model::handleLazyLoadingViolationUsing(...)` ile ihlali Sentry/log'a model + ilişki adıyla raporlayın ama isteği çökertmeyin. N+1'i production telemetrisinde görmeye devam eder, kullanıcıyı cezalandırmazsınız. ```php // app/Providers/AppServiceProvider.php public function boot(): void { Model::preventLazyLoading(! $this->app->isProduction()); Model::handleLazyLoadingViolationUsing(function ($model, $relation) { report(new \RuntimeException( "Lazy loading tespit edildi: ".$model::class."::".$relation )); // fırlatma yok — istek normal akışına devam eder }); } ``` 3. **Kademeli açın.** Production'da doğrudan throw'a geçmeden önce birkaç hafta log-only modda tutun; telemetri temizlendikten sonra istersen throw'a terfi ettirin. **Sonuç:** Ben olsam lokal + CI'da tam katı, production'da ise ilk haftalarda log-only handler ile açardım. Telemetri temizlendikten sonra bile production'ı log-only bırakmak çoğu ekip için yeterli; oradaki N+1'leri görüyorsunuz ama kimseyi 500 ile vurmuyorsunuz. Asıl kural: production'a katı fırlatmayı asla soğuk açmayın — önce ölçün, sonra sıkın. ## PHP durum sabitlerimi backed enum'a taşımak kod tabanıma ne kazandırır, neyi zorlaştırır? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/php-durum-sabitlerimi-backed-enuma-tasimak-kod-tabanima-ne-kazandirir-neyi/ Kısa cevap: Hâlâ aktif dokunduğunuz bir modül için bu geçiş net kazançtır — ama bedava değildir ve big-bang bir rewrite olmamalıdır. ## Kısa cevap Enum'lar sihirli değnek değil; sabitlere göre neyi değiştirdiklerini bilerek girmezseniz, kazandığınız tip güvenliğini sınırlarda patlayan hatalarla geri ödersiniz. Sabit, enum ve readonly arasında hangisinin hangi işe düştüğünü [PHP'de sekiz aracın karar ağacında](/tr/blog/php-sabit-enum-readonly-sekiz-aracin-karar-agaci/) tek tek ayırmıştım; bu kayıt oradaki "backed enum" dalının pratikteki bedelini anlatıyor. ## Neden 1. **Tip güvenliği ve okunabilirlik kazanırsınız.** `int` yerine `OrderStatus $status` imzası hem niyeti anlatır hem de PHPStan/Psalm'ın geçersiz değeri kod çalışmadan yakalamasını sağlar; IDE autocomplete tüm case'leri size verir. Ham sabitlerle bunların hiçbiri mümkün değildir. 2. **Davranış değerle birlikte yaşar.** Enum'a `->label()`, `->isFinal()`, `->canTransitionTo()` gibi metotlar koyabilirsiniz. Böylece kod tabanına yayılmış onlarca `switch`/`match` bloğu tek bir yere toplanır — asıl bakım kazancı buradadır. 3. **Backed enum DB ve serialization uyumunu korur.** `enum OrderStatus: int` aynı integer değerlerini taşıdığı sürece DB kolonu da JSON çıktısı da değişmez. Göçü güvenli yapan tam olarak budur. 4. **Bedeli sınırlardaki hidrasyondur.** DB'den veya request'ten gelen ham int'i `OrderStatus::from()` ile cast etmeniz gerekir ve `from()` bilinmeyen değerde exception fırlatır. Eskiden sessizce geçen bozuk/legacy bir status artık patlar. 5. **Enum genişletilemez.** Runtime'da case eklenemez, inheritance yoktur, dinamik instantiation yapılamaz. Tenant'a özel dinamik durumlar gerekiyorsa enum yanlış araçtır; orada bir lookup tablosu daha doğrudur. ## Ne yapmalı 1. **Enum'u sabitlerin yanına, aynı integer değerleriyle koyun.** Kolonu `varchar`'a çevirmeye kalkmayın; davranışı da enum'un içine taşıyın. Laravel'de Eloquent'in native enum cast'i işin büyük kısmını halleder. ```php enum OrderStatus: int { case Pending = 1; case Paid = 2; case Shipped = 3; case Cancelled = 9; public function isFinal(): bool { return in_array($this, [self::Shipped, self::Cancelled], true); } } // Eloquent modelinde: protected $casts = ['status' => OrderStatus::class]; ``` 2. **Güvenilmez sınırlarda `tryFrom()` kullanın.** Request'ten veya legacy tablodan gelen değerlerde `from()` yerine `tryFrom()` çağırıp `null` durumunu açıkça ele alın; böylece geçiş sırasında beklenmedik bir exception üretmezsiniz. 3. **Göç kademeli olmalı.** Enum'u sabitlerin yanına koyun, eski sabitleri bir sürüm boyunca deprecated alias olarak tutun, çağrı yerlerini parça parça taşıyın. **Sonuç:** Ben olsam kademeli giderdim — aynı integer değerleriyle backed enum, sınırlarda `tryFrom()`, eski sabitler bir sürüm boyunca deprecated alias olarak. Önce enum'u ekleyip Eloquent cast'ini bağlar, sonra bir PR'da bir servis olacak şekilde çağrı yerlerini taşırdım. Riskli olan tek nokta legacy verideki "yetim" status değerleridir; onları göçten önce bir `SELECT DISTINCT status` ile taradığınızda geçiş sizi hiç şaşırtmaz. ## API sunucumu Cloudflare Tunnel arkasına alıp 443'ü internete kapatmalı mıyım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/api-sunucusunu-cloudflare-tunnel-arkasina-mi-almali/ Kısa cevap: Evet, teknik olarak sağlam bir desen — ama ben olsam sizin yerinizde mevcut kurulumunuzda kalırdım. ## Kısa cevap Cloudflare'in Zero Trust tarafı da private bir origin'i internete hiç açmadan tünel üzerinden yayınlamayı zaten bu şekilde öneriyor. Sizin senaryonuzda uygulama `127.0.0.1:8080`'de çalışır, `cloudflared` tüneli isteği oraya taşır; 443 hiçbir zaman internete çıkmamış olur ve SSL yönetimi tamamen Cloudflare'e devredilir. Yine de "kurdum, gerisi gelir" diyebileceğiniz bir şey değil — bilmeniz ve kabul etmeniz gereken birkaç nokta var. Cloudflare'e ne kadarını devrettiğinizin diğer yüzünü, edge tarafındaki cache ve invalidation kararlarını konuştuğumuz [Workers KV kaydında](/tr/sor-bakalim/cloudflare-workers-kv-ile-edge-cache-ve-invalidation/) ele almıştım. ## Neden 1. **Tek yol Cloudflare olur.** Tünel açıldığında origin'e tek giriş kapısı Cloudflare'dir; bir CF kesintisinde devreye girecek bir fallback'in olmaz. Bu bağımlılığı bilerek almalısınız. 2. **Cloudflare'in zaman aşımı sınırları tünelde de geçerli.** Edge'deki request timeout tünelden geçen isteklere de uygulanır; tünel ayrıca kendi origin-taraflı zaman aşımlarını (connectTimeout, tlsTimeout, keepAliveTimeout) ekler. Uzun süren işleri senkron tutmayın, queue'ya alın. 3. **Gerçek istemci IP'si bir katman daha uzaklaşır.** Loopback'e inen bir istekte kaynağı yalnızca proxy başlıklarından okuyabilirsiniz; bu da uygulama tarafında ek bir güven ayarı gerektirir. ## Ne yapmalı 1. **Süreci düzgün yönetin.** Tünel istemcisini (`cloudflared`) systemd altında `autorestart` açık şekilde çalıştırın. Aynı tünel token'ı ile iki replica koşturarak kesintisizliği artırabilirsiniz — ama bu artık bir ölçekleme konusu, başlangıçta gerekmez. 2. **Uygulamayı asla `0.0.0.0`'a bind etmeyin.** Tünel modelinde uygulamanın yalnızca loopback'ten (`127.0.0.1:8080`) erişilebilir olması gerekir; dışarıya açık bir port kalmamalı. Şu anki supervisor konfigürasyonundaki `--host=0.0.0.0 --port=443` tam da bu yüzden değişmeli. 3. **Trusted proxy'leri mutlaka ayarlayın.** Laravel'in `TrustProxies` ayarını yapmazsanız tüm istekler loopback'ten geliyormuş gibi görünür; gerçek istemci IP'sini kaybedersiniz; rate limit'ler ve loglar bozulur. **Sonuç:** Tek yol bağımlılığını ve kesintisizliği Cloudflare'in HA'sına emanet etmeyi göze alıyorsanız, tünel en temiz çözüm. Ama ben olsam sizin mevcut `Cloudflare → Caddy → App` kurulumunuzda kalıp, origin'i bir Security Group kuralıyla yalnızca Cloudflare IP aralıklarına kilitler ve buna authenticated origin pulls eklerdim — bu, 443'ü efektif olarak dışarıya kapatırken tek-yol bağımlılığına girmediğiniz için daha esnek olur. ## Hata oranı artınca otomatik rollback yapan (self-healing) deploy altyapısını nasıl tasarlarım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/hata-artisinda-otomatik-rollback-ve-kendi-kendini-iyilestiren-deploy/ Kısa cevap: Bir insanı dashboard başında bekletme — metrikleri doğrudan deploy'un içine göm. Doğru desen, otomatik analizle çalışan **progressive delivery**. ## Kısa cevap Yaşadığın şey net: pipeline yeşil yanıyor ama "yeşil" sadece deploy'un teknik başarısı; üretimdeki gerçek sağlık değil. İkisini birbirine bağlaman lazım. Bu bağın alt katmanı olan trafik geçiş stratejisini — canary mi, blue-green mi — [fintek API'si için deploy stratejisi kaydında](/tr/sor-bakalim/canary-mi-blue-green-mi-fintek-api-icin-deploy-stratejisi/) ayrıca konuşmuştuk. ## Neden 1. **Anomali ancak kısmi trafikte ucuza yakalanır.** Yeni sürümü hemen %100'e açarsan hata oranındaki artışı gördüğünde bütün trafik zaten etkilenmiş olur; canary ağırlığındaki bir pencere aynı sinyali küçük bir kitleyle verir. 2. **Rollback yeni bir deploy değil, hazır olana geçiştir.** Deployer release'leri saklar, Kubernetes eski ReplicaSet'i tutar; bu yüzden geri dönüşü otomatikleştirmek güvenlidir. 3. **Eşik yanlış kurulursa sistem kendi kendini sürekli geri alır.** Gürültüye takılan bir health-gate flapping üretir; makul bir eşik ve minimum örnek sayısı olmadan otomasyon zarar verir. 4. **Otomatik rollback kodu geri alır, veriyi değil.** Migration geriye uyumlu değilse geri dönüş şemayı tutarsız bırakır — ve otomasyon nedeni de anlamaz, yalnızca kanamayı durdurur. ## Ne yapmalı 1. **Deploy sonrası bir canary ağırlığında "bake" süresi tut.** Yeni sürümü hemen %100'e açma; bir süre kısmi trafikte beklet ve bu pencerede Prometheus/New Relic'ten 5xx oranı, latency ve kritik iş metriklerini bir eşiğe/baseline'a karşı sorgula. Anomali bu pencerede yakalanır. 2. **Eşik aşılırsa otomatik olarak son kararlı sürüme dön.** Health-gate breach olunca pipeline insan beklemeden `rollback` tetikler ve ekibi **uyarır**. İnsan gece nöbetinde değil, sadece bilgilendirilir. 3. **Health-gate'i araçlara bağla.** k8s'te Argo Rollouts / Flagger bu canary analizini + rollback'i otomatik yürütür; Deployer'da deploy sonrası metrik polling yapıp breach'te `deploy:rollback` çağıran bir health-gate adımı ekle. 4. **Guardrail'leri doğru kur, yoksa flapping olur.** Makul bir eşik seç (gürültüye takılıp sürekli rollback yapmasın), minimum örnek sayısı şart koş ve **sadece deploy'u** geri al — data migration'ı değil. Bu yüzden migration'lar geriye uyumlu olmalı. **Sonuç:** Ben olsam canary + otomatik metrik analizi + son-kararlıya auto-rollback kurar, insanı nöbetçi değil **uyarılan** taraf yapardım — self-healing olan deploy, kişi değil. Otomatik rollback sorunu durdurur ama nedeni anlamaz; o yüzden bunu suçlu aramayan bir post-mortem kültürüyle tamamla — kalıcı düzeltme oradan gelir. ## Fintek API'si için Canary mi, Blue-Green deployment mı? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/canary-mi-blue-green-mi-fintek-api-icin-deploy-stratejisi/ Kısa cevap: Yüksek riskli bir fintek API'da rutin sürümler için **Canary**'yi varsayılan yapın; anında çevirebileceğiniz/geri alabileceğiniz büyük cutover'lar için **Blue-Green**'i elinizde tutun. ## Kısa cevap Asıl mesele şu: ikisi de aynı problemi farklı maliyetle çözüyor — yeni sürümü canlı trafikle, ama kontrollü şekilde sınamak. İkisinin de ön koşulu olan geriye uyumlu şema geçişini — expand/contract ve dual-write'ı — [canlı tabloda sıfır kayıplı şema geçişi kaydında](/tr/sor-bakalim/canli-tabloda-dual-write-ile-sifir-kayipli-sema-gecisi/) adım adım anlatmıştım. ## Neden 1. **Canary en küçük blast radius'u verir.** Gerçek dünya hatalarını çok küçük bir kullanıcı kitlesiyle yakalarsınız. Bir fintekte rutin sürümün doğal seçimi budur. 2. **Blue-Green en hızlı rollback'i en pahalı altyapıyla satar.** Rollback neredeyse anlık (geri çevirin) ve temiz bir pre-prod test ortamı verir; ama altyapıyı ikiye katlar ve %100'ü aynı anda riske atar. 3. **Belirleyici nokta veritabanı.** Her iki stratejide de eski ve yeni sürüm bir süre **aynı anda** çalışır. Bu yüzden şema değişiklikleri geriye uyumlu (expand/contract) olmak zorunda — migration backward-compatible değilse ne Canary ne Blue-Green sizi kurtarır; eski kod yeni şemada patlar. ## Ne yapmalı 1. **Rutin sürümlerde Canary'yi varsayılan yapın.** %1 trafik → hata oranı, latency ve iş metriklerini izleyin → kademeli ramp. 2. **Blue-Green'i büyük cutover'lara saklayın.** Tam bir ikinci ortam ayağa kaldırıp trafiğin tamamını anında oraya çevirmenin bedelini, ancak değişim bunu hak ettiğinde ödeyin. 3. **Her migration'ı geriye uyumlu yazın.** Şemayı expand/contract ile ilerletin ki eski ve yeni sürüm aynı anda çalışırken hiçbiri patlamasın. 4. **Kararı sürüm başına verin.** Blue-Green ≈ anlık geri dönüş ama yüksek maliyet; Canary hızlı (ağırlığı geri alın) ve ucuz. Para hareketi olan bir API'da bu denge, "ne sıklıkta ve ne kadar riskli deploy ediyorsunuz" sorusuyla çözülür. **Sonuç:** Ben olsam günlük sürümler için Canary'yi standart yapardım — en düşük risk, en küçük etki alanı. Büyük, riskli cutover'lar (major sürüm, altyapı taşıması) için Blue-Green'in instant rollback'ini saklardım. Ama strateji ne olursa olsun değişmeyen kural: her migration'ı geriye uyumlu yazın. Şema disiplinini kurmadan hiçbir deploy stratejisi sizi güvende tutmaz. ## CI/CD'de OIDC ve IAM Role ile şifresiz (secretless) AWS erişimini nasıl kurarım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/ci-cd-de-secret-yonetimi-ve-oidc-ile-sifresiz-erisim/ Kısa cevap: GitHub Secrets'taki uzun ömürlü AWS anahtarları tam olarak **yok etmeniz** gereken şey — sızar, rotasyonu zordur, sürekli açık duran kalıcı kimliklerdir. OIDC bunları ortadan kaldırır. ## Kısa cevap Asıl mesele: kalıcı bir anahtarı bir yerde saklamak yerine, her çalışmada anlık üretilen geçici bir kimlik kullanmak. Workflow'un kendisini kurmayı [GitHub Actions yazısında](/tr/blog/biri-github-actions-mi-dedi/) anlatmıştım; buradaki tek değişiklik, o pipeline'a artık hiçbir anahtar gömmemek. ## Neden 1. **Uzun ömürlü anahtar, sürekli açık duran kalıcı bir kimliktir.** Sızar, rotasyonu zordur, merkezî takibi güçtür; GitHub Secrets bunu bir yere kadar çözer ama anahtarın kendisi hâlâ bir yerde durmaya devam eder. 2. **OIDC, "bu workflow gerçekten sizin reponuzdan mı geliyor?" sorusunu kriptografik olarak cevaplatır.** AWS, GitHub'ın imzaladığı token'lara güvenir; trust policy'deki koşullar tutmadığı sürece bir fork ya da rastgele bir PR bu role'ü üstlenemez. 3. **Workflow runtime'da geçici STS credential alır.** Çalışma anında, kısa ömürlü GitHub OIDC token'ını AWS STS ile takas eder; varsayılan olarak bir saat içinde geçersiz olan geçici credential'lar üretir. Hiçbir yerde saklanan kalıcı secret yok — sızsa bile çok kısa ömürlü. ## Ne yapmalı 1. **GitHub Actions'ı AWS IAM'de OIDC identity provider olarak tanımlayın.** Bu, AWS'in GitHub'ın imzaladığı token'lara güvenmesini sağlayan tek seferlik kurulumdur. 2. **Trust policy'si dar scope'lu bir IAM role oluşturun.** Role'ün trust policy'sinde `sub` koşulunu belirli **repo + branch + environment**'a sabitleyin. Böylece role'ü yalnızca sizin tanımladığınız kaynak alabilir. 3. **Least privilege + prod için environment koruması uygulayın.** Role'ü minimum yetkiyle sınırlayın. Prod erişimi için GitHub Environments + zorunlu reviewer kullanın. Aynı yaklaşım her bulutta var: GCP ve Azure'da da Workload Identity Federation aynı işi görür. **Sonuç:** Ben olsam AWS için OIDC + dar scope'lu IAM role kurardım — sıfır saklanan anahtar, sıkı trust koşulları, prod'da environment koruması. Bulut dışı secret'lar (DB şifresi gibi) için bunları GitHub Secrets'a koymak yerine runtime'da bir secrets manager'dan çekin. Kural net: standing credential'ı imkânsız kılmak için kimliği anlık ve kısa ömürlü üretin. ## CI/CD'de veritabanı göçlerini (migrations) güvenli nasıl çalıştırırım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/ci-cd-de-veritabani-goclerini-guvenli-calistirmak/ Kısa cevap: Asıl hata, şema değişikliğini eski kod hâlâ canlıyken deploy'a sıkı sıkıya bağlamak — çözüm, geriye uyumlu (expand/contract) migration'ları kod deploy'undan **ayırmak**. ## Kısa cevap Problemi netleştirelim: ya eski kod yeni şemayla karşılaşıp patlıyor, ya da büyük bir `ALTER` tabloyu kilitleyip pipeline'ı timeout'a düşürüyor. İkisi de aynı kökten — kuplaj. Aynı geçişin canlı tablo tarafını, yani veri kaybetmeden dual-write ile şema değiştirmeyi [ayrı bir kayıtta](/tr/sor-bakalim/canli-tabloda-dual-write-ile-sifir-kayipli-sema-gecisi/) ele almıştım; buradaki soru o geçişi CI/CD adımlarına nasıl dağıtacağınız. ## Neden 1. **Eski kod yeni şemayı görmek zorunda kalıyor.** Deploy sırasında bir süre boyunca canlıda olan kod, henüz kendisi için yazılmamış bir şemayla konuşur; kolonu tanımadığı ya da beklediği kolonu bulamadığı anda hata vermeye başlar. 2. **Büyük ve kilitleyici bir `ALTER` deploy adımının içine sığmaz.** Milyonlarca satırlık bir tabloda bazı şema değişiklikleri dakikalar sürer. PostgreSQL 17'de bir kolonun tipini değiştirmek ya da volatile `DEFAULT`'lu kolon eklemek tabloyu `ACCESS EXCLUSIVE` kilidi altında baştan yazar; düz, nullable bir `ADD COLUMN` yeniden yazımı atlar ama yine de o kilidi alır. MySQL 8.4'te `ADD COLUMN` varsayılan olarak `INSTANT`'tır, ama her işlem öyle değildir. Ağır iş deploy adımının içindeyse pipeline timeout'a düşer, tablo da o süre boyunca kilitli kalır. 3. **Kök neden kuplaj.** Şema değişikliği ile kod deploy'u aynı ana bağlandığı sürece bu iki hata da kaçınılmazdır; çözüm de tek tek semptomları değil, bu bağı gevşetmektir. ## Ne yapmalı 1. **Expand/contract disiplinini benimse.** Aynı release'de bir kolonu ekleyip onu gerektiren kodu deploy etme; asla rename/drop'u kullanan release ile aynı anda yapma. Önce kolonu **nullable** ekle, sonra "hem eskiye hem yeniye yazan" kodu deploy et, en son ayrı bir release ile eskiyi kaldır. 2. **Backfill'i request yolundan ve deploy adımından çıkar.** Büyük tabloyu doldurma işini ayrı, **throttle'lı** bir adımda yap — deploy adımının transaction'ı içinde değil. Aksi halde tek bir dev `UPDATE` hem kilitler hem timeout'a sokar. 3. **Migration'ı kendi pipeline aşaması yap, timeout baskısı olmadan.** Şema değişikliğini app deploy'undan ayrı bir stage'e al. Büyük/kilitleyici değişiklikler için `pt-online-schema-change` / `gh-ost` gibi online-DDL araçlarıyla **bant dışı** çalıştır; tabloyu kilitlemeden kolonu ekler. 4. **İdempotent ve geri alınabilir yaz, deploy'u migration'a gate'le.** Migration tekrar çalıştığında bozulmasın, geri dönüşü olsun. Deploy'u migration başarısına bağla; ama yıkıcı adımları (drop, rename) yeni kod tamamen yayıldıktan sonraki bir follow-up release'e bırak. **Sonuç:** Ben olsam expand/contract'ı kural yapar, migration'ları app deploy'undan ayırır ve büyük/kilitleyici değişiklikleri online araçlarla kritik yolun dışında çalıştırırdım. Şema değişikliğini hiçbir zaman "deploy'un ortasında, eski kod canlıyken" yapma — onu kademeli ve geriye uyumlu yürüt; pipeline timeout'u da, eski kodun patlaması da o zaman ortadan kalkar. ## DeployerPHP zero-downtime deploy'da OPcache'ten gelen 500 hatalarını nasıl engellerim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/deployerphp-ile-sifir-kesinti-deploy-ve-opcache-temizligi/ Kısa cevap: Symlink çevirmek atomik ama yeterli değil — flip'ten sonra PHP-FPM'i graceful **reload** etmeniz lazım; OPcache'i hiçbir release'ten sonra eski koda yapışık bırakmayın. ## Kısa cevap Yaşadığınız şey klasik **OPcache cache poisoning**: OPcache, derlenmiş bytecode'u dosya yoluna göre keyler. Yani sorun deploy aracınızda değil, kodun bellekte nasıl tutulduğunda — Octane kullanıyorsanız aynı mesele bir kat daha derinleşiyor; kalıcı süreç modelini [Octane yazısında](/tr/blog/laravel-octane-kalici-surecle-gelen-performans/) temelden anlatmıştım. ## Neden 1. **`current` symlink'inin kendi yolu hiç değişmez.** Bu yüzden worker'lar bir önceki release'in bytecode'unu sunmaya devam eder; bazı worker'lar eski, bazıları yeni realpath'i görünce dosya yolları karışır ve 500 patlar. 2. **`opcache.revalidate_freq`'i ayarlamak tek başına çözüm değil.** Aynı dosya yolu değiştiğinde işe yarar; ama release'ler arası yol farkında revalidate sizi kurtarmaz. Release sınırında garantili olan reset/reload'dur. 3. **Octane bambaşka bir hikaye.** Octane'de kod diskten değil **bellekten** sunulur; uzun ömürlü worker'lar eski sınıfları RAM'da tutar. Bu yüzden OPcache'i ne yaparsanız yapın, worker'ları yenilemedikçe eski kod ayakta kalır. ## Ne yapmalı 1. **En temiz yol: symlink'ten sonra PHP-FPM'i graceful reload edin.** `kill -USR2 ` ya da `systemctl reload php-fpm` — bu, FPM'in yeni `realpath`'i görmesini sağlar ve OPcache'i tazeler. **Reload, restart değil:** mevcut istekler tamamlanır, yeni worker'lar yeni kodu derler, kesinti olmaz. Deployer'da bunu `deploy:symlink` adımına hook'layın. 2. **Alternatif: `opcache_reset()`'i deploy hook'undan çağırın.** FPM soketine `cachetool` ile bağlanıp cache'i resetlemek, web'den bir reset endpoint'i tetiklemekten daha temiz — CLI ve FPM ayrı OPcache tutar, yani `php artisan` ile resetlemek FPM'i etkilemez. Reload bunu zaten kapsadığı için çoğu zaman gerek kalmaz. 3. **Octane'deyseniz symlink flip'ten sonra `php artisan octane:reload` çalıştırın.** Worker'ları bu komutla yenilemeniz şart; aksi halde bellekteki eski sınıflar yerinde kalır ve OPcache tarafında yaptığınız hiçbir şey işe yaramaz. **Sonuç:** Akış net olsun — symlink'i çevirin → PHP-FPM'i graceful reload edin (Octane'deyseniz `octane:reload`) → isterseniz `opcache_reset`. Bunu Deployer'da symlink adımına bağlayın ve bir release'ten sonra OPcache'i asla eski koda yapışık bırakmayın. Octane'in bellekte kod tutmasının deploy tarafındaki bedeli ayrı bir hub yazısının konusu; oradaki kalıcı-süreç modelini iyi tanırsanız bu reload adımını atlamazsınız. ## Docker imaj boyutunu multi-stage build ve distroless ile nasıl küçültürüm? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/docker-imaj-boyutunu-multi-stage-ve-distroless-ile-kucultmek/ Kısa cevap: 1.2 GB imaj, tüm build toolchain'ini üretime taşıyor — hem yavaş hem de geniş bir saldırı yüzeyi. Multi-stage build ikisini birden çözer; final aşamaya sadece artefaktı ve runtime'ı koyarsın. ## Kısa cevap Problemin kaynağı tek: imajda olmaması gereken şeyler (derleyici, paket yöneticisi, shell) üretime sızıyor. Katmanların nasıl oluştuğunu ve `.dockerignore` mantığını [Docker yazısında](/tr/blog/docker-sanallastirma-teknolojisi-kurulum-kullanim/) temelden anlatmıştım; buradaki optimizasyon o zeminin üstüne oturuyor. ## Neden 1. **Bedel iki katlı: hız ve saldırı yüzeyi.** 1.2 GB'lık bir imaj her build'de upload/download süresini şişirir; taşıdığı derleyici, bash ve paket yöneticisi de üretimde hiç ihtiyaç duyulmayacak bir saldırı yüzeyi bırakır. 2. **Multi-stage'in mantığı: `builder` aşaması üretir, final aşaması sadece kopyalar.** İlk aşamaya derleyiciyi ve bağımlılıkları koy, artefaktı üret. Final aşamaya yalnızca binary/uygulama + çalışması için gereken runtime'ı `COPY --from=builder` ile al. Build araçları imaja hiç girmez. 3. **Go ile PHP'nin tabanı aynı yere inmez.** Go binary'si kendi kendine yeter (statik linklenir), yani birkaç MB'lık bir imaj çıkar. PHP'de ise derlenmiş binary yok; PHP runtime + extension'lara ihtiyacın var, dolayısıyla hedef boyut da farklı olmak zorunda. ## Ne yapmalı 1. **Go için final aşamayı `scratch` veya `distroless/static` yap.** Distroless shell ve paket yöneticisi içermez — saldırı yüzeyi küçülür; debug gerekince `:debug` varyantını kullan. 2. **PHP'de hedefin `php:8.x-fpm-alpine` olsun.** Build aşamasında `composer install --no-dev` çalıştır, final aşamaya yalnızca `vendor/` + uygulamayı kopyala. Yine de yüzlerce MB değil, onlu MB seviyesine inersin. 3. **Boyut kadar güvenliği de sıkılaştır.** Konteyneri **non-root** çalıştır, `.dockerignore` ile gereksiz dosyaları dışla, base imajı digest ile pinle, bağımlılık aşamasını registry layer cache'ine bıraktır ki her build'de yeniden çekilmesin. **Sonuç:** Ben olsam multi-stage + Go'da `distroless/static`/`scratch`, PHP'de `fpm-alpine` kullanır; non-root çalışır ve `.dockerignore`'u sıkı tutardım. 1.2 GB'lık imaj böylece onlu MB seviyesine iner — CI/CD hızlanır, saldırı yüzeyi daralır. İmaj katmanlarının nasıl çalıştığını ve `.dockerignore` mantığını hub yazısında temelden anlattım; oradaki kurulum bu optimizasyonun zeminini oturtur. ## Gitflow'dan Trunk-Based'e geçerken feature flag mimarisini nasıl konumlandırırım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/gitflow-mu-trunk-based-mi-ve-feature-flag-mimarisi/ Kısa cevap: Gitflow'da merge'lerin acı vermesi ve release'lerin haftalar sürmesi tam olarak uzun ömürlü dalların **sürüklenmesinden** kaynaklanıyor. Trunk-Based bunu küçük değişiklikleri sürekli main'e alarak çözer; ama "main'deki bitmemiş özellik kullanıcıya ulaşamasın" şartını feature flag'ler sağlar. ## Kısa cevap Mesele şu: kodu sık merge etmek istiyorsunuz ama yarım işin canlıya kaçmasını da istemiyorsunuz. Bu çelişkinin köprüsü flag'ler. Dal, merge ve PR disiplininin üretimdeki karşılığını [Git rehberinde](/tr/blog/production-git-senior-pratik-rehberi/) topluca ele almıştım; buradaki geçiş o disiplinin üstüne kurulur. ## Neden 1. **Uzun ömürlü dallar problemin kendisi.** `feature`/`develop`/`release` dalları zamanla ana daldan uzaklaşır; conflict ve haftalara yayılan release tam buradan doğar. 2. **Flag, deploy'u release'den ayırır.** Kodu istediğiniz an deploy edersiniz (deploy), özelliği hazır olunca açarsınız (release). Bu ayrım size flag bazında canary/kademeli açılım da kazandırır — aynı sürümü %5 kullanıcıya açıp izleyebilirsiniz. 3. **Temizlenmeyen flag'ler yeni bir borç yaratır.** Ölü flag'ler kendileri birer teknik borca dönüşür ve kod tabanını çorbaya çevirir; yani flag'i kalıcı bir yapı olarak görmek, çözdüğü problemin yerine bir yenisini koyar. ## Ne yapmalı 1. **Uzun ömürlü dalları kaldırın, değişikliği sürekli main'e alın.** Trunk-Based'de değişiklikleri küçük parçalar halinde **sürekli** main'e alırsınız; dal ömrü kısaldıkça conflict de release süresi de kendiliğinden düşer. 2. **Bitmemiş işi flag arkasında "karanlıkta" tutun.** Tamamlanmamış özelliği prod'da kapalı bir flag'in arkasına koyun, kodu yine de günlük main'e merge edin. Özellik bitip test edilince flag'i açarsınız. Böylece kod canlıda olur ama kullanıcıya görünmez. 3. **Disiplin şart: kısa ömürlü dallar, güçlü CI, flag hijyeni.** Dallar bir-iki gün yaşasın, her merge'de CI sağlam çalışsın. En kritiği **flag hijyeni**: ölü flag'leri temizleyin. **Sonuç:** 20 kişilik, Continuous Delivery hedefleyen bir ekip için doğru model Trunk-Based + feature flag'ler. Ben olsam dalları kısa tutar, her şeyi flag arkasında merge eder ve flag'leri **geçici** kabul edip biter bitmez temizlerdim. Trunk-Based'e tam olarak ne zaman ve hangi ön koşullarla geçtiğimi sade.dev'de ayrıntısıyla anlattım. ## Go CLI aracını GoReleaser ve Homebrew ile çoklu platforma nasıl dağıtırım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/go-cli-aracini-goreleaser-ve-homebrew-ile-dagitmak/ Kısa cevap: Bu tam olarak GoReleaser'ın çözdüğü problem — bir `.goreleaser.yaml` + tag'de tetiklenen tek bir workflow, hem cross-compile'ı hem GitHub Release'i hem de Homebrew tap formülünü otomatik halleder. ## Kısa cevap Sizin yapmanız gereken tek şey paketleme zincirini elinizden bırakmak; gerisini GoReleaser üstlenir. Aracın kendisini yazma tarafını [Go'da ilk CLI aracımı anlattığım yazıda](/tr/blog/goda-ilk-cli-aracimi-yazmak/) ele almıştım; buradaki soru o binary'yi kullanıcının makinesine nasıl ulaştıracağınız. ## Neden 1. **Çoklu platform elle yönetilecek bir iş değil.** `darwin/arm64`, `darwin/amd64`, `linux/amd64`, `linux/arm64` — her hedefi elle derleyip arşivlemek, checksum üretmek ve Release'e yüklemek her sürümde tekrar eden bir emektir; GoReleaser bunu tek bir konfigürasyona indirir. 2. **Tap ayrı bir repo olduğu için varsayılan token yetmez.** Formül `homebrew-tap` reposunda yaşar; varsayılan `GITHUB_TOKEN` başka repoya yazamaz, o yüzden ayrı bir token şart. 3. **Bozuk config en kötü anda, release anında patlar.** Tag atıldıktan sonra fark edilen bir konfigürasyon hatası sürümü yarıda bırakır; bu yüzden doğrulamayı release'e değil PR'a taşımak gerekir. ## Ne yapmalı 1. **`.goreleaser.yaml`'da build matrisini tanımlayın.** `darwin/arm64`, `darwin/amd64`, `linux/amd64`, `linux/arm64` hedeflerini yazın. İsterseniz `universal_binaries` ile macOS için tek bir evrensel artefakt üretin; M1/M2/Intel ayrımı kullanıcı tarafında kaybolur. 2. **Tag push'unda tetiklenen bir GitHub Actions workflow'u kurun.** `on: push: tags: ['v*']` ile `goreleaser release` çalışsın: cross-compile eder, arşivleri + checksum'ları üretir, GitHub Release'i yayımlar. 3. **`homebrew_casks:` bloğuyla tap'i otomatik güncelleyin.** Bu blok, cask'i ayrı bir `homebrew-tap` reposuna commit eder. Kullanıcı sonra `brew install seninadin/tap/cli` ile kurar. İhtiyacınız olan: tap reposuna push yetkisi olan bir PAT. (Eski `brews:` bloğu GoReleaser v2.10'dan beri deprecated; hâlâ çalışıyor ama yeni kurulumlarda `homebrew_casks:` kullanın.) 4. **Sürüm bilgisini binary'ye gömüp config'i CI'da doğrulayın.** `ldflags` ile version/commit/date stamp'leyin ki `cli --version` doğru bilgi versin. Pipeline'a `goreleaser check` ekleyin — config bozuksa release anına kalmadan, daha PR'da yakalarsınız. **Sonuç:** Tag atın, gerisini GoReleaser yapsın — Releases + tap formülü tek komutla çıkar, manuel paketleme tamamen ortadan kalkar. Bu, Go ekosisteminde fiili standart; siz sadece tag disiplinini ve ayrı tap token'ını kurarsanız akış kendi kendine döner. CLI'yı paketleyip dağıtmanın daha geniş resmini hub yazısında ele aldım; oradaki dağıtım modelini bu workflow'a oturtursunuz. ## Hassas finansal veriyi şifrelerken anahtarı .env'de tutmak neden riskli — KMS/Vault ne sağlar? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/hassas-finansal-veriyi-sifrelemek-ve-anahtar-yonetimi-kms/ Kısa cevap: Alanı AES-256 ile şifrelemek doğru, ama anahtar uygulamanın *yanındaki* `.env`'de duruyorsa sunucuya ulaşan saldırgan hem veriyi hem anahtarı alır — şifreleme size neredeyse hiçbir şey kazandırmaz. KMS/Vault'un olayı anahtarı veriden ayırmaktır. ## Kısa cevap Sorunun özü şu: şifreli veri ve onu açan anahtar aynı yerde duruyorsa, o ikisi pratikte tek bir sırdır. Güvenlik, anahtarı veriden fiziksel/yetkisel olarak koparmaktan gelir. Aynı "sır nerede duruyor" sorusu imza doğrulamada da karşınıza çıkar; webhook'larda HMAC ve idempotency tarafını [ayrı bir kayıtta](/tr/sor-bakalim/webhook-mukerrer-bildirim-idempotency-ve-hmac/) ele almıştım. ## Neden 1. **`.env`'deki anahtar tek noktada toplanmış risktir.** Sunucuyu ele geçiren (ya da bir backup/`.env` sızıntısı yaşatan) saldırgan, şifreli DB ile anahtarı aynı anda eline geçirir. Ayrıca anahtar dosyada düz metin durur, rotasyonu zor ve kimin ne zaman çözdüğüne dair hiçbir iz yoktur. 2. **KMS/Vault master anahtarı uygulamaya hiç vermez.** Böylece DB'yi ya da bir yedeği ele geçirmek düz metni teslim etmez — çünkü master anahtar hiç orada değildir. Ele geçirilmiş bir uygulama sunucusu, ele geçirme sürdüğü sürece KMS'ten çözme isteyebilir; erişimi sınırlamak ve audit logu bu yüzden gerekir. 3. **"Hangi servis, ne zaman, hangi veriyi çözdü" sorusu cevaplanabilir hâle gelir.** Uyumluluk (KVKK/GDPR) denetiminde asıl aranan budur; `.env` modelinde bu sorunun cevabı yoktur. ## Ne yapmalı 1. **Envelope encryption'a geçin.** Uygulama şifreleme/çözme için KMS'i çağırsın: master anahtar KMS'te dursun, her kayıt için ayrı bir data key'i o şifrelesin. Uygulamanın sakladığı tek şey şifrelenmiş data key olur; düz metne ihtiyaç duyduğunda KMS'ten çözmesini ister. 2. **Rotasyonu ve denetimi merkezîleştirin.** KMS/Vault anahtarı merkezî olarak **rotate** eder, her çözme işlemini **audit** loguna yazar ve erişimi IAM/policy ile sınırlar; bu üçünü baştan açın. 3. **T.C. kimlik/kart için tokenization'ı tercih edin.** Mümkünse hassas değeri hiç tutmayın: gerçek değeri bir vault/sağlayıcıda saklayın, sistemde yalnızca bir **token** dolaşsın. Böylece sistemin büyük çoğunluğu hassas veriyi hiç görmez — sızsa bile ele geçen şey anlamsız bir token olur. **Sonuç:** KMS ya da Vault ile envelope encryption; anahtar asla `.env`'de değil, rotate + audit edilen bir yerde. Mümkün olan her yerde tokenize edin. Ek not: full-disk şifreleme ya da tek başına `pgcrypto` yeterli değildir — canlı bir DB bağlantısı düz metni yine de okur. Asıl koruma, anahtarı veriden ayırmaktan ve uygulamaya master anahtarı hiç vermemekten gelir. ## Self-hosted CI/CD runner'larını nasıl izole eder, her çalışmadan sonra temizlenen (ephemeral) ortamı nasıl kurarım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/self-hosted-ci-cd-runner-izolasyonu-ve-ephemeral-ortam/ Kısa cevap: Root'a ve gerçek Docker soketine sahip, üstüne rastgele kod çalıştıran kalıcı bir self-hosted runner, sunucu ele geçirilmesini bekleyen bir açıktır. İki ilke üzerine kurun: **ephemerality** ve **izolasyon**. ## Kısa cevap Riskin kökü: güvenmediğiniz kodu, tam yetkili ve kalıcı bir makinede çalıştırıyorsunuz. Konteyner izolasyonunun neyi verip neyi vermediğini [Docker yazısında](/tr/blog/docker-sanallastirma-teknolojisi-kurulum-kullanim/) temelden anlatmıştım; buradaki mimari o temelin üstüne kurulur. ## Neden 1. **Kalıcı runner'da her şey job'lar arasında taşınır.** Bir job'ın bıraktığı dosya, cache ya da zehirlenmiş workspace bir sonraki job'ın önüne çıkar; ortam tek kullanımlık olmadığı sürece bu bulaşmayı engelleyemezsiniz. 2. **`/var/run/docker.sock`'ı vermek, root vermekle eşdeğerdir.** Soketi gören bir job, host üzerinde istediği konteyneri istediği mount'la ayağa kaldırabilir; `--privileged` ya da hassas mount'lar da aynı kapıyı açar. 3. **Fork'tan gelen PR sizin altyapınızda keyfi kod demektir.** En sık gözden kaçan ve en tehlikeli vektör budur; kaynak kodunu koruma amacıyla kurduğunuz runner, kontrolsüz bırakılırsa saldırganın giriş noktası olur. ## Ne yapmalı 1. **Ephemerality: her job taze, atılır bir ortamda çalışsın.** Runner'ı "kaydol → tek job çalıştır → kaydı sil" modeline geçirin; her job tek kullanımlık bir VM ya da container'da koşsun ve bittiğinde **yok edilsin**. 2. **İzolasyon: runner host'una root ve gerçek Docker soketi vermeyin.** Job'ları **unprivileged** container'larda (ya da rootless Docker / job başına VM) çalıştırın; capability'leri düşürün, `--privileged` kullanmayın, hassas hiçbir şeyi mount etmeyin. 3. **Güvenilmeyen fork PR'larını self-hosted runner'da hiç çalıştırmayın.** Bunları onay (required approval) arkasına alın; onaysız hiçbir dış katkı sizin makinenizde kod çalıştırmasın. 4. **Runner'ları prod'dan ağ olarak ayırın (segmentation).** Bir job kaçsa bile prod kaynaklarına ulaşamasın. Tooling tarafında: Kubernetes'te Actions Runner Controller ile ephemeral runner'lar, ya da her job için yeniden yaratılan autoscaling VM'ler. **Sonuç:** Ben olsam ephemeral tek-job runner'lar + unprivileged izolasyon + fork PR yasağı + ağ segmentasyonu kurardım ve **her job'ı düşman kabul ederdim**. Self-hosted runner'ın gücü kontrolde ama bedeli sorumluluk — konteyner izolasyonunun nasıl çalıştığını ve soketi vermenin neden root vermek olduğunu hub'daki Docker yazısında temelden anlattım. ## Terraform state'i S3 + DynamoDB locking ile nasıl güvenli yönetirim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/terraform-state-yonetimi-s3-ve-dynamodb-ile-locking/ Kısa cevap: Local `terraform.tfstate` + 3 kişi = bozulma ve race condition. State'i remote backend'e taşı: dosya için S3, locking için DynamoDB — bu kombinasyon uzun süredir sektörün standardıydı, ama Terraform 1.11'den beri DynamoDB locking deprecated durumda. ## Kısa cevap Sorunun kökü tek bir paylaşılan dosyanın, kilit olmadan, üç kişi tarafından aynı anda yazılması. Kilidin nereye konacağı sorusunun veritabanı tarafını — pessimistic lock mu, Redlock mu — [bakiye güncelleme kaydında](/tr/sor-bakalim/bakiye-guncellemede-race-condition-pessimistic-lock-mu-redlock-mu/) tartışmıştım; burada aynı mantık altyapı state'ine uygulanıyor. ## Neden 1. **Kilit, ikinci apply'ı sıraya sokar.** Terraform, herhangi bir apply'dan önce bir lock satırı yazar. İkinci kişi apply çalıştırınca kilit dolu olduğu için ya bekler ya da hızlıca hata verir — state'i üst üste yazıp bozmaz. Race condition tam burada biter. 2. **Tek state, blast radius'u birleştirir.** Prod, staging ve dev tek bir state'i paylaşırsa staging'de bir hata prod state'ini riske atar; ortamları ayırmak blast radius'u ayrı tutmanın en ucuz yolu. 3. **Serileştirilmiş tek yol, paralel apply'lardan güvenlidir.** Tek serileştirilmiş yol (CI), üç laptop'tan gelen paralel apply'lardan çok daha güvenli — kilit olsa bile insan sayısı arttıkça çakışma ihtimali artar. ## Ne yapmalı 1. **State dosyasını S3'e al — versiyonlu, şifreli, IAM ile kısıtlı.** Bucket'ta **versioning** açık olsun ki bozuk bir state'ten kolayca geri dönebilesin; encryption açık, erişim sadece gereken role'lerde. Artık state laptop'ta değil, merkezi ve yedekli bir yerde. 2. **DynamoDB ile state locking kur.** Backend konfigürasyonuna lock tablosunu ekle; Terraform kilidi kendisi yönetir, senin ek bir iş yapmana gerek kalmaz. 3. **Ortam başına ayrı state tut.** Prod, staging ve dev'i tek bir state'te paylaşma; her ortamın kendi state'i olsun. 4. **Mümkünse apply'ları sadece CI'dan çalıştır ve state'i elle düzenleme.** State'e elle dokunma; düzeltmen gerekirse `terraform state` komutlarını kullan. **Sonuç:** Ben olsam S3 (versiyonlu + şifreli) backend + DynamoDB lock tablosu + ortam başına state + CI'dan apply derdim. Not: yeni Terraform/OpenTofu sürümleri S3-native locking de yapabiliyor; DynamoDB lock tablosu hâlâ yaygın olsa da HashiCorp tarafından deprecated ilan edildi ve kaldırılması planlanıyor — yine de ekibin bugün tereddütsüz oturtabileceği şey budur. IaC'ye gerçekten geçmeye değer mi, yoksa script mi yeter sorusunu sade.dev'de ayrıca tartıştım. ## WAL arşivleme ve PITR ile felaketten saniyeler öncesine nasıl dönerim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/veritabani-pitr-ve-wal-arsivleme-ile-felaket-oncesine-donmek/ Kısa cevap: Gecelik dump'lar 18 saatlik bir RPO (kabul edilebilir veri kaybı penceresi) demek — üretimde bu kabul edilemez. **PITR** (Point-in-Time Recovery) bu boşluğu kapatır ve sizi hatalı ifadenin saniyeler öncesine geri götürür. ## Kısa cevap Mantık şu: bir base backup + o andan itibaren her değişikliğin sıralı kaydı (WAL) varsa, istediğiniz tam ana kadar "ileri sararak" geçmişi yeniden inşa edebilirsiniz. RPO ve RTO hedeflerini nasıl belirlediğimi, aktif-pasif senaryoyla birlikte [felaket kurtarma kaydında](/tr/sor-bakalim/felaket-kurtarma-rto-rpo-ve-aktif-pasif-senaryo/) ele almıştım; PITR o hedeflerin veritabanı ayağıdır. ## Neden 1. **Write-Ahead Log, DB'deki her değişikliği sırayla yazar.** Bu sıralı kayıt dışarıya arşivlendiği sürece, base backup ile şu an arasındaki her işlem geri oynatılabilir durumda kalır — kayıp penceresi yedek aralığına değil, arşivleme gecikmesine iner. 2. **`recovery_target_time` sizi tam istediğiniz ana bırakır.** Postgres belirlediğiniz noktada durur — yıkıcı ifadenin hemen öncesinde. "Felaketten bir an önce"ye böyle inersiniz, dün geceye değil. 3. **Doğrulanmamış bir yedek, Schrödinger'in yedeğidir.** Açana kadar çalışıp çalışmadığını bilmezsiniz; kurtarma prova edilmemişse elinizde bir yedek değil, bir varsayım vardır. ## Ne yapmalı 1. **Periyodik base backup alın.** Düzenli aralıklarla tutarlı bir tam yedek (base backup) alın. Bu, kurtarmanın başlangıç noktasıdır; WAL replay'i bu temelin üstüne uygulanır. 2. **WAL'ı sürekli S3'e arşivleyin.** `archive_command` (ya da pgBackRest/WAL-G) ile WAL segmentlerini üretildikçe S3'e atın. Böylece base backup ile şu an arasındaki her işlemin kaydı dışarıda, güvende durur. 3. **`recovery_target_time` ile tam ana kadar replay edin.** Kurtarırken base backup'ı geri yükleyin, sonra WAL'ı belirlediğiniz hedefe kadar oynatın: `recovery_target_time = '...hatalı DELETE'den 3 saniye öncesi'`. 4. **Restore'u mutlaka prova edin.** WAL arşivleme açık *ve* test edilmiş olmalı, base backup'lar bir takvime bağlı olmalı. En kritiği: kurtarmayı periyodik **prova edin**. Bunu elle yazmayın; pgBackRest ya da WAL-G kullanın. **Sonuç:** Base backup + sürekli WAL arşivleme (S3'e) + test edilmiş PITR — 18 saatlik kaybı saniyelere indirir. Üretim DB'sinde sadece gecelik dump'a güvenmek, bir gün gelecek hatalı bir `DELETE`'te sizi saatlerce veri kaybıyla baş başa bırakır; PITR'ı bugünden kurun ve restore'u prova edin. ## Canlı tabloda dual-write ile sıfır kayıplı şema geçişini nasıl koordine ederim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/canli-tabloda-dual-write-ile-sifir-kayipli-sema-gecisi/ Kısa cevap: 20M satırda bloklayan bir `ALTER TABLE` tabloyu kilitler ve sistem durur. Çözüm dual-write tabanlı bir **expand/contract** (genişlet/daralt) migrasyonu — geçişi aşamalara böl, hiçbir adım büyük tek transaction olmasın. ## Kısa cevap Mantık şu: tek hamlede şemayı değiştirmek yerine eski ve yeni şemayı bir süre yan yana yaşat, veriyi sızdırmadan taşı, sonra eskiyi kapat. Her adım deploy ile koordineli ve geri alınabilir olmalı. Aynı kademeli-geçiş disiplinini modül seviyesinde [strangler fig kaydında](/tr/sor-bakalim/monolitten-mikroservise-strangler-fig-ile-kademeli-gecis/) anlatmıştım; burada aynı şeyi tablo seviyesinde yapıyorsun. ## Neden 1. **Tek hamlelik `ALTER TABLE` tabloyu kilitler.** 20M satırlık bir tabloda doğrudan şema değişikliği tabloyu yeniden yazar; aktif ticaret dönen bir sistemde bu doğrudan kesinti demektir. 2. **Deploy sırasında eski ve yeni kod bir süre yan yana çalışır.** Tüm sunucular aynı anda dönmez; bu yüzden her ara adım geriye dönük uyumlu olmalı, yoksa eski kod işleyemeyeceği bir veri şekliyle karşılaşır. 3. **Sessiz veri kaybı ancak doğrulamayla yakalanır.** Backfill'in "bittiğini" görmek yetmez; satır sayıları ve örnek kayıtlar reconcile edilmezse eksik veri fark edilmeden canlıya taşınır. ## Ne yapmalı 1. **EXPAND — yeni yapıyı nullable ekle.** Yeni kolonları/yapıyı `NULL` kabul edecek şekilde ekle; tablo yeniden yazılmaz (rewrite yok), kilit anlık. Bu adım tek başına güvenli ve geri alınabilir. 2. **DUAL-WRITE — koda her iki şemaya yazdır.** Her değişiklikte hem eski hem yeni şemaya yazan kodu deploy et. Kod geriye dönük uyumlu kalsın: eski okuma yolları bozulmadan, yeni alanlar paralel doldurulsun. 3. **BACKFILL — geçmişi throttled batch'lerle taşı.** Eski veriyi küçük, hız sınırlı (throttled) dilimlerle, tercihen off-peak'te kopyala; eski ve yeni alanlar tutana kadar devam et. Sonunda satır sayılarını ve örnek kayıtları **doğrula** (reconcile) — sessiz veri kaybını ancak böyle yakalarsın. 4. **SWITCH READS — okumayı yeniye al.** Uygulamayı yeni şemadan okumaya geçir, ama bir süre **hâlâ her ikisine de yazmaya** devam et. Bu, sorun çıkarsa eskiye dönebilmen için bir güvenlik ağıdır. 5. **CONTRACT — eskiyi düşür.** Yeni yapının sağlam çalıştığından emin olunca eskiye yazmayı durdur, ardından eski kolonları `DROP` et. Bu son adım da bağımsız deploy edilir; mümkünse `pg_repack` / online-schema-change aracı kullan, asla tek dev transaction yazma. **Sonuç:** Expand/contract + dual-write + batch'li backfill + doğrulama; eski kodun asla işleyemeyeceği bir veri şekliyle karşılaşmaması için deploy'larla koordineli ilerle. Bu disiplinle PostgreSQL'de 20M satırlık finansal bir şemayı bile canlıyı durdurmadan dönüştürebilirsin. ## Zaman serisi verisi için TimescaleDB mi, InfluxDB mu? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/zaman-serisi-verisi-timescaledb-mi-influxdb-mu/ Kısa cevap: Günde 100M satır düz bir MySQL tablosunda patlar — çünkü B-tree indeks ve tam tablo agregasyonu RAM'e sığmaz. Zaman serisi motorları bunu **yapısal** olarak çözer. SQL'de düşünüyor ve agregasyon istiyorsanız doğru araç TimescaleDB. ## Kısa cevap Sorunun kökü model değil ölçek: zaman serisinde veri sürekli büyür; indeks şişer; "son 30 günün ortalaması" gibi sorgular tüm tabloyu tarar. Standart bir tablo bunu kaldırmaz. TimescaleDB'nin chunk'ları özünde zamana göre partitioning; aynı işi eklenti olmadan, düz declarative partitioning ile nasıl kuracağınızı [PostgreSQL partitioning kaydında](/tr/sor-bakalim/postgresql-tablo-partitioning-sifir-kesinti-ile-gecis/) anlatmıştım. ## Neden 1. **Hypertable veriyi zaman bazlı chunk'lara böler.** Insert'ler ve zaman aralığı sorguları yalnızca ilgili chunk'lara dokunur; tüm tabloyu tarama derdi biter. İndeks de chunk başına olduğu için RAM'i ezmez. 2. **SQL ekosistemini bırakmıyorsunuz.** TimescaleDB bir Postgres eklentisi olduğu için hâlâ saf SQL, `JOIN` ve mevcut Postgres ekosistemindesiniz — yeni bir dil/araç öğrenmenize gerek yok. 3. **InfluxDB amaca özel ama ayrı bir dünya.** InfluxDB zaman serisi için sıfırdan tasarlanmış, ama işletmeniz ve öğrenmeniz gereken ayrı bir sistem. InfluxDB 3 Core SQL ve `JOIN` destekliyor, yine de mevcut Postgres ekosisteminiz değil. Sizin gibi zaten SQL'de düşünen ve agregasyon isteyen biri için sürtünme yaratır. ## Ne yapmalı 1. **Tabloyu hypertable'a çevirin.** TimescaleDB'yi kurup zaman serisi tablosunu hypertable olarak tanımlayın; parçalama işini motora bırakın. 2. **Ortalamaları continuous aggregate'e alın.** Sürekli toplulaştırma (continuous aggregate), saatlik/günlük ortalamaları arka planda önceden hesaplar (materialize). "Geçmişe dönük ortalama" sorgunuz ham milyonlarca satırı değil, hazır özeti okur — anında döner. 3. **Eski chunk'ları sıkıştırın.** TimescaleDB eski chunk'ları native compression ile sıkıştırır; disk ve I/O dramatik düşer. 4. **Influx'a ancak ayrı bir metrik stack istiyorsanız yönelin.** Adanmış bir TSDB kurmak gibi net bir gerekçe yoksa, ikinci bir sistem yükünü üstlenmeyin. **Sonuç:** TimescaleDB hypertable + continuous aggregate + sıkıştırma — sizin senaryonuzda en düşük sürtünmeli kazanç. Influx'u yalnızca ayrı bir TSDB istiyorsanız düşünün. İki durumda da eski ham veriyi downsample edip retention ile düşün; süresiz ham tutmak hiçbir motoru kurtarmaz. "PostgreSQL her şeye yeter mi?" sorusunun zaman serisindeki cevabı: eklentisiyle çoğunlukla evet. ## Redis'te canlı leaderboard için neden Sorted Set kullanmalıyım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/redis-veri-tipleri-leaderboard-icin-sorted-set/ Kısa cevap: Skorları `user:123:score` gibi düz String'lerde tutup uygulamada sıralama — bu okuma başına milyonlarca anahtar üzerinde O(N log N), üstelik her seferinde her şeyi yeniden çekersiniz. Doğru araç **Sorted Set (ZSET)**. ## Kısa cevap Asıl mesele şu: leaderboard'ın doğası "sırala ve ilk N'i ver". Bu işi okuma anında yapmaya çalışırsanız ölçek sizi ezer; sıralamayı yazma anına taşımanız gerekir. Redis'i bu şekilde sıcak yolun ortasına koyarken cache tarafındaki tuzaklara da bakmakta fayda var; stampede'i [ayrı bir kayıtta](/tr/sor-bakalim/redis-cache-stampede-mutex-lock-mu-xfetch-mi/) ele almıştım. ## Neden 1. **Skip-list, sıralamayı yazarken yapar.** ZSET'in arkasındaki skip-list yapısı her şeyi **siz yazdıkça** sıralı tutar. Yani maliyet okuma anına değil yazma anına dağılır; okuma ucuz ve oyuncu sayısından neredeyse bağımsız kalır. 2. **Okuma maliyeti oyuncu sayısından bağımsızlaşır.** `ZRANGE leaderboard 0 99 REV WITHSCORES` en yüksek 100 oyuncuyu **zaten sıralı** olarak O(log N + 100)'de döner; milyonlarca oyuncu olsa da bu okuma neredeyse sabit maliyetlidir. 3. **String yaklaşımı her soru için her şeyi çeker.** "Ben kaçıncıyım?" gibi tek bir soru için bile herkesi çekip saymak gerekir; ZSET'te aynı soru tek komuttur. ## Ne yapmalı 1. **Skoru `ZADD` ile güncelleyin.** `ZADD leaderboard ` bir üyenin skorunu O(log N)'de günceller. Oyuncu skoru değiştiğinde tek komut; tüm tabloya dokunmanıza gerek yok. Skor değiştiği an set kendi içinde sıralı kalır. 2. **İlk 100'ü `ZRANGE ... REV` ile okuyun.** `ZRANGE leaderboard 0 99 REV WITHSCORES` yeterli (`ZREVRANGE`, Redis 6.2.0'dan beri deprecated); uygulama tarafında hiç sıralama yapmazsınız. 3. **Tek oyuncunun sırasını `ZREVRANK` ile alın.** "Ben kaçıncıyım?" sorusu `ZREVRANK leaderboard ` ile O(log N)'de cevaplanır. 4. **Büyük setlerde pencereleyin.** Devasa setler için periyodik snapshot'lar ve zaman pencereli board'lar (günlük/haftalık ayrı anahtarlar) kullanın. Eşitliklere dikkat: aynı skorlular lexicographic sıralanır; gerekiyorsa skora bir tiebreaker (örn. zaman damgası) gömerek sırayı deterministik yapın. **Sonuç:** ZSET tam da canlı leaderboard için tasarlanmış bir veri tipi; String + uygulama tarafı sıralama yerine onu kullanın. Sıralamayı okuma anından alıp yazma anına taşıdığınız an, milyonlarca oyuncuda bile leaderboard'ın anlık ve ucuz çalışır. ## CQRS'i ne zaman uygulamalı ve okuma/yazma modellerini nasıl senkronlamalıyım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/cqrs-ne-zaman-uygulanir-ve-okuma-yazma-senkronizasyonu/ Kısa cevap: Günde 5.000 yazma karşısında saniyede 2.000 okuma — bu asimetri gerçekten CQRS'e oynayan bir vaka. Ama en ucuz sürümle başla; doğrudan tam CQRS'e atlama. ## Kısa cevap Sorun net: ağır raporlama sorguları yazma yolunu da kilitliyor. Çoğu zaman bunu çözmek için tam bir mimari ayrışmaya gerek yok. Okuma modelini gerçekten Elasticsearch'e taşıyacaksan arama tarafının kendi maliyetleri de devreye girer; o hesabı [edge n-gram kaydında](/tr/sor-bakalim/elasticsearch-anlik-arama-edge-ngram-ile-optimizasyon/) ayrıca çıkarmıştım. ## Neden 1. **Tam CQRS ancak sorgu şekilleri ayrıştığında hak eder.** Ayrı bir okuma modeli (Elasticsearch ya da denormalize SQL) ancak sorgu şekilleri tek bir şemanın ikisini birden iyi servis edemeyeceği kadar ayrıştığında değer. Full-text arama, ağır agregasyon gibi ihtiyaçlar replica'nın da yetmediği noktada okuma modelini ayırmaya değer kılar. 2. **Dual-write kaçınılmaz olarak kayar.** Uygulamadan iki store'a birden yazarsan iki taraf zamanla birbirinden ayrışır (drift) ve hangisinin doğru olduğunu söyleyemez hâle gelirsin. 3. **Okuma modeli her hâlükârda geride kalır.** Okuma modeli yazmadan milisaniye-saniye geride kalır; eventual consistency bir kusur değil, bu mimarinin fiyatıdır. ## Ne yapmalı 1. **Önce read-replica ekle.** PostgreSQL'e bir veya birkaç read-replica koy ve tüm raporlama sorgularını oraya yönlendir. Yazma primary'de kalır, okuma replica'da; kilitlenme çoğu zaman sadece bununla biter. Bunu denemeden CQRS karmaşıklığını üstlenme. 2. **Modelleri event-driven senkronla.** Yazma tarafı her değişiklikte event üretsin; bir projector bu event'leri tüketip okuma modelini güncellesin. 3. **Güvenilirlik için outbox pattern kullan.** Event'i iş verisiyle aynı transaction içinde bir outbox tablosuna yaz, ayrı bir süreç onu yayınlasın — böylece "DB commit oldu ama event kayboldu" durumu olmaz. 4. **UI'ı eventual consistency'ye göre tasarla.** Örneğin "kaydedildi" anında görünsün, listede biraz sonra belirsin. Okuma modelini daima yazma tarafının event'lerinden besle, asla iki store'a birden yazma. **Sonuç:** Önce replica, raporlamayı oraya taşı. Tam CQRS'i yalnızca sorgu ihtiyaçları gerçekten ayrıştığında, outbox-tabanlı projeksiyonlarla kur — ve eklediğin karmaşıklığı bir maliyet olarak gör. "PostgreSQL bana yeter mi?" sorusunun cevabı çoğu yükte hâlâ evet; CQRS'e geçmeden önce onu sonuna kadar zorla. ## Bakiye güncellemede race condition: Pessimistic Lock mı, Redlock mı? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/bakiye-guncellemede-race-condition-pessimistic-lock-mu-redlock-mu/ Kısa cevap: Yaşadığın şey klasik **lost update** (kayıp güncelleme) problemi. Para söz konusuysa invariant'ı DB'de tut; en temiz çözüm atomik koşullu bir `UPDATE`, ikinci tercih `SELECT ... FOR UPDATE`. Redlock'a koşma. ## Kısa cevap Senaryonun özü: oku-değiştir-yaz (read-modify-write) döngüsü paralel worker'lar arasında bölünüyor. İkisi de eski değeri okuyor, ikisi de üstüne yazıyor; biri kayboluyor. Çözüm bu döngüyü atomik kılmak. `FOR UPDATE` ile satır kilitlemeye geçtiğinde kilit sırasının tutarlı olması gerekir; deadlock'un nasıl doğduğunu ve nasıl önlendiğini [deadlock kaydında](/tr/sor-bakalim/veritabani-deadlock-tespiti-ve-onleme-kurallari/) anlatmıştım. ## Neden 1. **Invariant'ı uygulama değil veritabanı korumalı.** Okuma ve yazma tek ifadede birleştiğinde DB satır kilidini kendi yönetir; hem race'i çözer hem de `balance >= 40` gibi bir koşulla eksiye düşmeyi reddeder. 2. **Redlock, DB içi bir invariant için fazla kırılgan.** Redis dağıtık kilidi (Redlock), korunan şey tek bir DB satırı *olmadığında* (servisler/kaynaklar arası koordinasyon) anlamlıdır — ve clock kayması/timeout gibi köşe durumları vardır. Bir bakiye DB'nin transaction'ıyla zaten korunabiliyorken onu dağıtık bir kilide emanet etme. 3. **Optimistic locking yoğun rekabette geri teper.** `version` kolonuna dayalı yaklaşım çakışma seyrekse ucuzdur, ama yoğun rekabette retry fırtınası yaratır — senin senaryonda aynı kullanıcı için art arda job geliyorsa bu risk gerçektir. ## Ne yapmalı 1. **En iyisi: atomik koşullu `UPDATE`.** `UPDATE accounts SET balance = balance - 40 WHERE id = ? AND balance >= 40` yaz. Okuma ve yazma tek ifadede; uygulama tarafında kilit gerekmez. Etkilenen satır 0 ise işlem yetersiz bakiyeden başarısız demektir. 2. **Alternatif: `SELECT ... FOR UPDATE`.** Mantık okuma ile yazma arasında karmaşıksa, transaction içinde bakiye satırını `FOR UPDATE` ile kilitle. İkinci worker o satıra geldiğinde birincinin commit'ini bekler; sıraya girerler, çakışma olmaz. Pessimistic locking budur. 3. **Düşük çakışmada optimistic locking'i değerlendir.** Satıra bir `version` kolonu ekle; güncellerken `WHERE version = ?` koy, eşleşmezse retry et. 4. **Redlock'u yalnızca DB dışı kaynak için kullan.** Korunması gereken şey bir DB satırı değilse — örneğin servisler arası bir kaynak — o zaman dağıtık kilit anlamlı olur. **Sonuç:** Atomik koşullu `UPDATE` (ya da `SELECT FOR UPDATE`) kullan; Redlock'u sadece DB dışı kaynaklar için sakla. Para için kuralı transaction'ın garanti ettiği yerde — DB'de — tut; dağıtık kilit, veritabanının kendisinin zaten zorlayabileceği bir invariant'ı korumak için fazla kırılgan bir araçtır. ## Esnek ürün nitelikleri için NoSQL mu, PostgreSQL mi seçmeliyim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/esnek-urun-nitelikleri-icin-nosql-mu-postgresql-mi/ Kısa cevap: Sırf nitelikler üründen ürüne değişiyor diye MongoDB'ye koşmayın — bu tam olarak **NoSQL tuzağı**. PostgreSQL'in `JSONB`'si üçüncü ve en isabetli yolu sunuyor. ## Kısa cevap Sorunun kökü gerçek: ilişkisel DB'de EAV modeli (entity-attribute-value) cidden acı verir — devasa join'ler, okunamayan SQL, performans kaybı. Ama bu acı sizi doğrudan şemasızlığa itmemeli. Bu refleksin mimarideki kardeşini — yeni bir projeye doğrudan mikroservisle başlama isteğini — [ayrı bir kayıtta](/tr/sor-bakalim/yeni-projeye-mikroservisle-mi-baslamali/) tartışmıştım; karar mekanizması burada da aynı. ## Neden 1. **Raporlama ihtiyacınız tek başına kararı veriyor.** Yoğun raporlama gerektiğini söylüyorsunuz. Veriyi Mongo'ya bölerseniz bu raporları PostgreSQL'deki veriyle birlikte tek bir SQL sorgusunda yapamaz, iki ayrı sistemden veri birleştirme derdine düşersiniz. Bu tek gereksinim bile bölmeye karşı yeterli argüman. 2. **Ürün, fiyat, sipariş işinizin omurgası.** ACID, join ve raporlama istediğiniz her şey burada; transaction garantisini, foreign key'leri ve `JOIN`'lı raporları kaybetmek istemezsiniz. 3. **Document NoSQL'in doğru olduğu senaryo sizinki değil.** MongoDB'ye geçmeniz, ancak tüm erişiminiz anahtara göre (key-based) olduğu, varlıklar arası transaction/raporlama gerektirmediğiniz ve bütün domain'in doğası gereği belge şeklinde olduğu durumda mantıklı. ## Ne yapmalı 1. **İlişkisel çekirdeği PostgreSQL'de tutun.** Ürün, fiyat, sipariş, stok — ACID, join ve raporlama istediğiniz her şey ilişkisel kalsın. 2. **Değişken nitelikleri tek bir `JSONB` kolonuna koyun.** Üründen ürüne değişen renk/beden/garanti gibi alanları tek bir `JSONB` kolonunda tutun. 3. **`JSONB` kolonuna GIN index kurun.** `attributes @> '{"renk":"kırmızı"}'` gibi sorgular böylece hızlı çalışır. İhtiyacınız olan yerde şema esnekliği, geri kalan her yerde transactional bütünlük elde edersiniz. **Sonuç:** PostgreSQL + variant nitelikler için `JSONB` (GIN index'li). NoSQL'i yalnızca erişim deseni gerçekten onu zorunlu kılıyorsa düşünün. "Şeması yok, kolay olur" diye başlamak, ileride tutarlılık ve raporlama faturasını çok daha ağır ödetir; bu mimari tercihin neden tuzak olduğunu sade.dev'deki yazıda açtım. ## Kuyrukta poison pill (zehirli mesaj) ve Dead Letter Queue'yu nasıl yönetirim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/kuyrukta-poison-pill-ve-dead-letter-queue-yonetimi/ Kısa cevap: Her seferinde patlayan ve sonsuza dek otomatik retry edilen bir iş, kuyruğu kilitleyip worker'ı çökertir — buna **poison pill** (zehirli mesaj) denir. Çözüm retry'ı sınırlamak ve hatalı işi yeniden kuyruğa atmak yerine bir DLQ'ya düşürmek. ## Kısa cevap Sizin yaşadığınız şey klasik: iş sürekli aynı yerde patlıyor, Laravel "tekrar deneyeyim" diyor, tekrar patlıyor, kuyruk ilerleyemiyor. Döngüyü kırmanız gerekiyor. Retry'ın güvenli olabilmesi işin idempotent olmasına bağlı; aynı isteği güvenle tekrarlamanın kurallarını [idempotency yazısında](/tr/blog/api-de-idempotency-ayni-istegi-guvenle-tekrar-etmek/) ayrıntılandırmıştım. ## Neden 1. **`failed_jobs` zaten sizin DLQ'nuz.** Sınır dolunca Laravel işi otomatik `failed_jobs` tablosuna taşır — ekstra bir altyapı kurmanıza gerek yok, Dead Letter Queue zaten budur. 2. **Fatal Error normal retry yolundan geçmez.** Exception değil, worker'ı komple öldüren Fatal Error için Laravel'in normal retry'ı işlemez. Supervisor worker'ı yeniden başlatır ama aynı iş tekrar çekilirse döngü kapanmaz. 3. **Sessizce başarısız olan iş en kötü senaryodur.** `failed_jobs`'a düşen bir iş kimseye haber vermiyorsa kuyruk ilerler ama o işin karşılığı olan sonuç hiç gerçekleşmez. 4. **Tek bir kötü iş tüm kuyruğu aç bırakır.** Riskli iş tipleri diğerleriyle aynı kuyrukta çalışıyorsa, bir zehirli mesaj bütün işlerin sırasını tıkar. ## Ne yapmalı 1. **Retry'ı sınırlayın.** İşin üstüne `tries` (ya da `maxExceptions`) ve bir `backoff` koyun. Böylece iş 3-5 kez denenir, her seferinde sonsuza dek değil. Belirlenen sınıra ulaşınca Laravel onu yeniden kuyruğa atmayı bırakır. 2. **İşe `failed()` metodu tanımlayın.** Temizlik/telafi mantığını oraya koyun; iş DLQ'ya düştüğünde yarım kalan yan etkiler geri sarılsın. 3. **Alert kurun.** Bir `JobFailed` listener'ı yazın; iş `failed_jobs`'a düştüğü an Slack/Sentry'ye bildirim göndersin. Yöneticiler anında haberdar olmalı. İncelemeden sonra `queue:retry` ile düzeltilen işi replay edersiniz. 4. **Fatal Error için sayaç tutun.** "Bu job id'yi N kez gördüm" sayacını (Redis'te) tutup eşiği aşınca işi zorla `failed_jobs`'a atın; Supervisor'ın worker'ı ayağa kaldırması tek başına döngüyü kapatmaz. 5. **Kuyrukları riske göre ayırın, işleri idempotent yapın.** Riskli iş tiplerini ayrı bir kuyrukta çalıştırın ki tek bir kötü iş tüm diğerlerini aç bırakmasın. Ayrıca işleri idempotent yazın; retry güvenli olsun, iki kez çalışsa da yan etki birikmesin. **Sonuç:** Sınırlı retry + `failed_jobs` DLQ + alert + `queue:retry` ile replay. Hiçbir işin sınırsız retry edilmesine izin vermeyin; sınırsız retry, tek bir zehirli mesajı tüm sistemi durduran bir silaha çevirir. ## Health check tasarımı: Liveness ve Readiness ayrımını nasıl yaparım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/health-check-tasarimi-liveness-ve-readiness-ayrimi/ Kısa cevap: Statik bir `{status:ok}` yalnızca HTTP sunucusunun ayakta olduğunu kanıtlar, uygulamanın gerçekten **hizmet verebildiğini** değil. Çözüm, iki farklı soruyu ayırmak: "yaşıyor mu?" ve "hazır mı?". ## Kısa cevap Asıl mesele şu: Liveness ile Readiness farklı şeyleri ölçer ve karıştırırsan kendi ayağına sıkarsın — özellikle bağımlılık kontrolünü yanlış probe'a koyarsan. Readiness'ın "beni rotasyondan çıkar ama öldürme" davranışı, autoscaling sırasında pod kapanırken trafiği boşaltma mantığının aynısıdır; o tarafı [SIGTERM ile graceful shutdown kaydında](/tr/sor-bakalim/autoscaling-sirasinda-sigterm-ile-graceful-shutdown/) ayrıca ele almıştım. ## Neden 1. **İki probe'un sonucu farklı yaptırıma bağlanır.** Liveness fail olursa Kubernetes pod'u **restart eder**; readiness fail olursa sadece **trafiği keser**. Bu ayrım kritik: geçici bir bağımlılık sorununda pod'u yeniden başlatmak istemezsin, sadece trafiği durdurmak istersin. 2. **Liveness'a bağımlılık koymak restart döngüsü yaratır.** Anlık bir DB kesintisi tüm sağlıklı pod'ları sonsuz bir restart döngüsüne sokar — oysa süreçlerin kendisinde bir sorun yoktur. 3. **Probe'un maliyeti replica sayısıyla çarpılır.** Her replica'nın probe'u her birkaç saniyede Postgres'e gerçek bir sorgu atarsa, 50 pod × sürekli probe = veritabanını sen çökertirsin. ## Ne yapmalı 1. **Liveness'ı ucuz ve bağımlılıksız tut.** Burada DB'yi, cache'i, kuyruğu **kontrol etme**. Liveness sadece "süreç yanıt veriyor mu?" sorusuna bakmalı. 2. **Readiness'ta kritik bağımlılıkları yokla.** DB, cache, kuyruk gibi hizmet için gereken bağımlılıkları burada kontrol et. Bir bağımlılık düştüğünde Load Balancer pod'u rotasyondan çıkarsın — ama pod'u **öldürmeden**. Bağımlılık dönünce pod tekrar trafiğe girer. 3. **Readiness'ı kısa timeout'la yap ve sonucunu cache'le.** Sonucu birkaç saniye **cache'le** ve sadece hizmet için gerçekten gerekeni kontrol et. 4. **Yavaş açılış için startup probe ekle.** Uygulaman yavaş boot ediyorsa, ayrı bir startup probe kullan ki liveness, uygulama daha açılırken pod'u öldürmesin. **Sonuç:** Ben olsam liveness'ı ucuz ve bağımlılıksız, readiness'ı bağımlılık-farkında ama cache'li yapardım. Altın kural: probe'lar asla veritabanını DDoS'lamasın. Kubernetes'in gerçekten gerekip gerekmediğini sade.dev'de ayrıca tartışıyorum; ama bu ayrımı yapmazsan en küçük DB titremesi bütün cluster'ını sallar. ## PostgreSQL tablo partitioning'e sıfır kesinti ile nasıl geçerim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/postgresql-tablo-partitioning-sifir-kesinti-ile-gecis/ Kısa cevap: Aylık 50M satır gerçekten büyük — partitioning burada doğru hamle. `created_at` üzerinden **aylık RANGE declarative partitioning** kullanın; geçişi sıfır kesinti için aşamalı yapın. ## Kısa cevap Önce şu sağlamayı yapmakta fayda var: 50M/ay gerçek bir hacim, "verin gerçekten büyük mü yoksa kötü mü modellenmiş" sorusunun cevabı burada net — gerçekten büyük. Partition mantıklı. Partition başına oluşan indekslerde kolon sırasını nasıl seçeceğinizi [composite index kaydında](/tr/sor-bakalim/postgresql-composite-index-kolon-sirasi-nasil-secilir/) ayrıca anlatmıştım; oradaki kural burada da aynen geçerli. ## Neden 1. **Partition pruning sorguyu tek aya daraltır.** Eski monolitik tablonun aksine, sorgu sadece ilgili aya değer; partition pruning sayesinde basit `select`'ler tekrar hızlanır. 2. **İndeksler partition başına oluşur.** Parent üstünde tanımladığınız indeksi Postgres her partition'a otomatik yayar (propagate). Yani global tek bir B-tree değil, her ay için ayrı indeks; bu aslında işinize yarar, eski ayların indeksi sıcak veriyi yormaz. 3. **UNIQUE ve FK kuralı şemanızı değiştirir.** Partitioned tabloda global bir UNIQUE/PK, **partition anahtarını içermek zorundadır** — yani PK'in `(id, created_at)` olur. Tabloya referans veren FK'ler de bundan etkilenir; modern PG'de partitioned tabloya FK kurmak çalışır ama sürümünüzü doğrulayın. ## Ne yapmalı 1. **Native declarative RANGE ile parçalayın.** Yeni bir partitioned parent (`PARTITION BY RANGE (created_at)`) oluşturun ve aylık partition'ları `ATTACH` edin. 2. **Geçişi sıfır kesinti ile yapın.** Yeni parent'ı kurun, partition'ları bağlayın, sonra geçmiş veriyi **batch'ler hâlinde** (örn. 50k'lık dilimlerle, off-peak) partition'lara backfill edin. Bu sırada uygulama yazmaya devam etsin: ya yeni yapıya yönlendiren bir trigger/view kullanın ya da hem eskiye hem yeniye **dual-write** yapın. Backfill bitince isimleri **tek bir transaction içinde** takaslayın — kesinti yok. 3. **Unique key'lere partition anahtarını ekleyin.** PK'i `(id, created_at)` biçimine taşıyın ve tabloya referans veren FK'leri buna göre gözden geçirin. 4. **Yaşam döngüsünü `pg_partman`'a bırakın.** Gelecek ayların partition'larını otomatik oluşturması için `pg_partman` kullanın; retention için eski partition'ları `DETACH` + `DROP` ile silin — toplu bir `DELETE`'ten çok daha hızlı. **Sonuç:** `created_at` üzerinde declarative RANGE, batch'li backfill, her unique key'de partition anahtarı, yaşam döngüsü için `pg_partman`. Geçişi dual-write + tek transaction'lık isim takasıyla kurarsanız canlı sistem hiç durmaz. Bu tür DB derinliklerine girmeden önce ölçeklenme ihtiyacının gerçekten "büyük veri" mi olduğunu netleştirin; bu vakada öyle. ## Veritabanı deadlock'larını önlemek için hangi kurallara dikkat etmeliyim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/veritabani-deadlock-tespiti-ve-onleme-kurallari/ Kısa cevap: Yaşadığınız şey klasik bir **kilit-sıralama döngüsü**: A önce X'i sonra Y'yi, B önce Y'yi sonra X'i kilitliyor. Döngüyü kırarsanız deadlock'u kökünden çözersiniz. ## Kısa cevap Asıl mesele şu: deadlock şanssızlık değil, tutarsız kilit sırasının matematiksel sonucu. İki transaction kilitleri farklı sırayla isteyince bir döngü oluşur ve ikisi de birbirini bekler. Deadlock'un kaçınılmaz kalan payını retry ile karşılayacağınız için, işlemin idempotent olması şart — aynı disiplinin ödeme tarafındaki hâlini [mükerrer bildirim ve idempotency kaydında](/tr/sor-bakalim/webhook-mukerrer-bildirim-idempotency-ve-hmac/) ele almıştım. ## Neden 1. **Deadlock tutarsız sıranın sonucudur.** İki transaction da kilitleri aynı sırada isterse döngü matematiksel olarak oluşamaz. 2. **Uzun transaction çakışma penceresini büyütür.** Kilit ne kadar uzun tutulursa, başka birinin ona çarpma olasılığı o kadar artar. 3. **İzolasyon seviyesini yükseltmek çözüm değildir.** Sık yapılan hata budur; genelde işe yaramaz, tam tersine daha çok kilit ekler. ## Ne yapmalı 1. **Kilitleri her zaman aynı global sırada alın.** En önemli kural. Satırları her yerde aynı deterministik düzende kilitleyin — örneğin her zaman artan primary key sırasıyla. 2. **Transaction'ı kısa ve dar tutun.** Yavaş/harici işi (API çağrısı, dosya yazımı, kullanıcı beklemesi) transaction'ın **dışına** alın; kilidi mümkün olduğunca geç alın, kısa tutun. 3. **En az satıra dokunun, hedefli kilitleyin.** Geniş kilitler yerine sadece ihtiyacınız olan satıra `SELECT ... FOR UPDATE` uygulayın. 4. **Mümkünse tek statement kullanın.** Tek bir `UPDATE`/`INSERT ... ON CONFLICT` kullandığınızda kilit sıralamasını DB yönetir. 5. **Yine de deadlock olacağını kabul edin, retry edin.** DB bir transaction'ı "kurban" seçip abort eder; bunu beklenen bir durum olarak ele alın, işlemi idempotent yapın ve backoff ile yeniden deneyin. **Sonuç:** Ben olsam tutarlı kilit sırası + kısa transaction + deadlock'ta retry üçlüsünü kurardım. Sık yapılan hata şu: izolasyon seviyesini yükselterek deadlock'u çözmeye çalışmak. Döngüyü kırın, izolasyonu zorlamayın. ## Go HTTP sunucusunda panic'i recovery middleware ile nasıl yönetirim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/go-da-panic-yonetimi-ve-recovery-middleware/ Kısa cevap: `net/http` bir handler'daki panic'i zaten kendisi recover eder; yani tek bir kötü istek süreci öldürmez. Ama istemciye düzgün bir yanıt da gitmez: bağlantı kopar ve stack trace yalnızca sunucunun hata loguna yazılır; varsayılanda bu stderr'dir. Recovery middleware'i kontrollü bir `500`, request id'li yapılandırılmış log ve bir metrik için gerekir — ve `http.ErrAbortHandler`'ı yeniden fırlatmalıdır. ## Kısa cevap Asıl mesele şu: sunucunun kendi recover'ı bir güvenlik ağıdır, hata yönetimi değil. Handler'daki bir nil deref, binlerce sağlıklı isteği sizinle birlikte öldürmez; ama panic eden isteğin sahibi cevap yerine kopmuş bir bağlantı görür ve geriye yalnızca stderr'de ham bir stack trace kalır. Süreciniz gerçekten kapandıysa o güvenlik ağının dışında bir panic arayın: `go` ile açılmış bir goroutine'de kimsenin recover etmediği panic programı sonlandırır. Servisi standart kütüphaneyle kurmanın detayını [Go'da HTTP servisi yazmak yazısında](/tr/blog/go-da-http-servisi-yazmak-standart-kutuphane-yeter-mi/) anlatmıştım. ## Neden 1. **Yerleşik recover ne yanıt verir ne sinyal.** Panic eden bir handler için sunucu panic'i recover eder, stack trace'i hata loguna yazar ve ağ bağlantısını kapatır ya da HTTP/2 `RST_STREAM` gönderir. İstemci `500` görmez, log satırında request id yoktur, hiçbir metrik artmaz. 2. **Recover yalnızca kendi goroutine'ini yakalar.** Sunucunun recover'ı handler'ı çalıştıran goroutine'i kapsar. Handler içinde `go func(){...}()` açtıysanız, oradaki panic ne sunucunun ne de middleware'inizin recover'ına takılır — ve bütün süreci sonlandırır; içerideki tüm istekleri drop eden durum budur. 3. **Sessizce yutulan panic, recover edilmemesi kadar tehlikelidir.** Metrik yoksa aynı bug günlerce gizli kalır. ## Ne yapmalı 1. **Her isteği `defer recover()` ile sarın.** Middleware içinde `defer func(){ if r := recover(); r != nil { /* logla + 500 yaz */ } }()` kurun. 2. **Bu middleware'i zincirin en dışına koyun.** Recovery en dıştaki katman olmalı ki kendisinden sonra gelen tüm handler ve middleware'lerin panic'lerini sarsın. 3. **Stack trace'i + request id'yi loglayın, ama içini sızdırmayın.** `debug.Stack()` ile stack trace'i ve isteğin id'sini loglayın; istemciye generic bir `500` dönün. 4. **`http.ErrAbortHandler`'ı yeniden fırlatın.** Yakalanan değer `http.ErrAbortHandler` ise `500` yazmak yerine aynı değeri yeniden fırlatın. Handler'ların yanıtı bilerek yarıda kesmek için kullandığı işaret değeri budur; yeniden fırlatmak sunucunun bu değere uyguladığı davranışı korur: istemci yarım kalmış bir yanıt görür, stack trace loglanmaz. 5. **Panic'i görünür kılın — bir metrik basın.** Her recover'da bir sayaç artırın. 6. **Açtığınız her goroutine'e kendi recover'ını verin.** Aksi hâlde süreç yine ölür. **Sonuç:** Ben olsam kontrollü bir `500` dönen, request id ile loglayan, panic'i sayan ve `http.ErrAbortHandler`'ı yeniden fırlatan bir istek başı recover middleware'i + her goroutine için ayrı recover kurardım. İki uyarı: recover, beklenmedik bug'lar (nil deref) içindir, hata dönmenin yerine geçmez — beklenen hataları `error` ile dönün. Ve buna rağmen süreci yeniden başlatan bir supervisor tutun. ## Felaket kurtarma: RTO ve RPO'ya göre aktif-pasif senaryoyu nasıl kurgularım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/felaket-kurtarma-rto-rpo-ve-aktif-pasif-senaryo/ Kısa cevap: Mimariyi sen seçmiyorsun, **RTO 30dk / RPO 5dk** seçiyor. Bu iki sayı, hangi felaket kurtarma stratejisini kuracağını tek başına belirliyor. ## Kısa cevap Asıl mesele şu: RPO ve RTO soyut hedefler değil, doğrudan teknik kararlara çevrilen rakamlar. Sayıyı oku, mimariyi ona göre kur. Tek bir region içinde terfi kararının nasıl verilmesi gerektiğini [split-brain ve quorum kaydında](/tr/sor-bakalim/postgresql-split-brain-quorum-ve-mutabakat-ile-onlemek/) ele almıştım; burada aynı soru region ölçeğinde soruluyor. ## Neden 1. **RPO replikasyon sıklığını belirler.** 5 dakikalık veri kaybı tavanı, gecelik dump'ı baştan eler; sürekli replikasyon şart. 2. **RTO hazırlık seviyesini belirler.** 30 dakikada sıfırdan altyapı kurulamaz; ikinci region'ın ayakta ve terfiye hazır beklemesi gerekir. 3. **Aktif-aktif bu sayılar için fazla.** İhtiyaç duyulmayan karmaşıklık, ödenmemesi gereken bir maliyettir. ## Ne yapmalı 1. **RPO 5dk için sürekli replikasyon kurun.** Region'lar arası streaming replikasyon (ya da RDS cross-region read replica) + WAL/PITR'ı S3'e gönderin. 2. **RTO 30dk için sıcak yedek (warm standby) tutun.** İkinci region'da terfi edilmeye hazır, önceden ayağa kaldırılmış bir kopya bekletin. 3. **Altyapıyı IaC ile önceden hazırlayın.** İkinci region'ın ağ, sunucu ve yapılandırmasını Terraform ile kodla tanımlayın; felaket anında elle tıklamak RTO'yu patlatır. 4. **Statik dosyaları ve DNS'i hazırlayın.** S3 Cross-Region Replication ile varlıkları kopyalayın; Route 53 health-check / failover routing ile trafiği otomatik çevirin. 5. **Failover'ı düzenli prova edin (game-day).** Test edilmemiş bir felaket kurtarma planı, çalışacağına dair bir tahminden ibarettir. **Sonuç:** Ben olsam cross-region replica + PITR + Route 53 failover kurar, yedeği IaC ile önceden hazırlardım. Aktif-pasif tam bu hedeflere oturuyor; ihtiyacın olmayan karmaşıklığı satın alma. Cloud mu kendi sunucun mu sorusunu sade.dev'de ayrıca tartışıyorum. ## Log storm ve disk dolma krizini nasıl önlerim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/log-storm-ve-disk-dolma-krizini-onlemek/ Kısa cevap: Burada iki ayrı arıza var — uygulamada throttling yok, sunucuda sınır/rotasyon yok. Tek katmanı düzeltmek yetmez; ikisini birden kapatmanız lazım. ## Kısa cevap Asıl problem şu: tek bir kopuk bağımlılık saniyede 10.000 satır üretti, disk doldu ve işletim sistemi nefes alamayınca makine düştü. Yani sizi öldüren şey hata değil, hatanın **sınırsız** loglanması. Log hattının kendi dayanıklılığını, yani üretimi tüketimden ayıran ara katmanı [log hattına buffer koyma kaydında](/tr/sor-bakalim/loglari-opensearch-e-gonderirken-araya-kafka-buffer-koymali-miyim/) ele almıştım. ## Neden 1. **Sınırsız loglama bir özellik değil, bir bug'dır.** Hiçbir hata, üretim hızıyla orantısız bir çıktı üretmemeli. 2. **Backoff'suz retry, log üretimini de katlar.** Her başarısız denemede anında yeniden denemek hem hedefe yüklenir hem satır sayısını çarpar. 3. **Log ile işletim sistemi aynı diski paylaşmamalı.** Log diski dolduğunda makinenin de düşmesi, bir yerleşim kararının sonucudur. ## Ne yapmalı 1. **Uygulamada tekrarlı logu throttle/sample edin.** Aynı hatayı 10.000 kez yazmayın; "Connection refused (60 saniyede ×10.000)" diye sayaçla tek satır yazın. Logu hata imzasına göre dedup'layın. 2. **Retry'a backoff koyun ki hata oranı kendisi düşsün.** Exponential backoff + jitter uygulayın; bu hem hedefe yüklenmeyi azaltır hem log üretim hızını doğal olarak kırar. 3. **Sunucuda logrotate ile sert sınır koyun.** `logrotate`'i boyut **ve** dosya sayısı sınırıyla yapılandırın (örn. `size 100M`, `rotate 5`); toplam log alanının bir tavanı olsun. 4. **Logu kök/işletim sistemi diskine yazmayın.** `/var/log`'a ayrı bir volume verin. Bu tek başına sizin yaşadığınız çökmeyi engellerdi. 5. **Filebeat'i sınırlı yerel spool ile çalıştırın, erken alarm kurun.** Disk spool'una tavan koyun ve disk kullanımı %100'e gelmeden çok önce (%70-80'de) alarm alın. **Sonuç:** Ben olsam uygulama tarafı throttling + ayrı log volume + rotasyonu birlikte kurardım. En önemlisi şu zihniyet değişikliği: sınırsız loglamayı bir özellik değil, bir **bug** olarak görün. Bu tip olayların disiplinli bir post-mortem ile nasıl işleneceğini sade.dev'de anlatıyorum. ## Dağıtık brute force ve DDoS'a karşı akıllı rate limiting'i nasıl kurarım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/dagitik-brute-force-ve-ddos-icin-akilli-rate-limiting/ Kısa cevap: IP başına limit, dönen proxy'lere karşı çaresiz — o yüzden ölçeklemeyi yalnızca IP üzerinden yapmayın. Saldırıyı çok boyutlu anahtarlama ve katmanlı savunma ile kırın. ## Kısa cevap Asıl problem şu: saldırgan binlerce IP'yi rotasyona soktuğu için "IP başına 60" kuralı her bireysel IP için asla tetiklenmez. Saldırının ortak paydasını yakalamanız lazım. Aynı "kapıda reddederek arkadakini koru" mantığının yük tarafındaki hâlini [graceful degradation kaydında](/tr/sor-bakalim/graceful-degradation-kritik-olmayan-servisleri-izole-etmek/) ele almıştım. ## Neden 1. **Tek boyutlu sayaç, dağıtık saldırıyı göremez.** Her IP eşiğin altında kalırken toplam trafik hedefi ezer; sayacı saldırının gerçekten ortak olduğu boyutta tutmanız gerekir. 2. **Sabit eşik, saldırı sinyalini kullanmaz.** Başarısız login oranı fırladığında hâlâ normal zaman limitleriyle çalışmak, saldırıya normal muamele etmektir. 3. **Uygulama katmanı volumetrik seli ememez.** L3/L7 flood'u Redis limiter'ıyla durdurmaya çalışmak, yanlış katmanda savaşmaktır. ## Ne yapmalı 1. **Redis sliding-window'u çok boyutta anahtarlayın.** Tek sayaç değil, birkaç boyutta sayaç tutun: kullanıcı adı (tek hesap spray'leniyorsa), IP, IP subnet'i / ASN, User-Agent parmak izi, ülke kodu. Bir IP temiz görünse de aynı `username`'e binlerce farklı IP'den vuran trafik o boyutta patlar. Token bucket da bu problemi çözer; boyutları tek bir yapıda birleştirmek için sliding-window'u tercih ediyorum. 2. **Başarısızlık oranı yükselince dinamik sıkın.** Normalde gevşek olan limitleri, başarısız login oranı ani yükseldiğinde otomatik daraltın. 3. **Credential stuffing'e karşı hesap kilidi ve challenge ekleyin.** Başarısız login'lerde hesap bazında kademeli gecikme/kilit uygulayın; N denemeden sonra CAPTCHA veya proof-of-work isteyin. 4. **Gerçek volumetrik DDoS'u edge'e itin.** Hacimsel saldırıyı Cloudflare gibi bir edge katmanına bırakın; Redis limiter'ınız akıllı ve hedefli kötüye kullanımı durdurur. 5. **Login yolunu fail-closed yapın, hangi boyutun tetiklendiğini sızdırmayın.** Limiter ya da Redis hata verirse login'i kapatın. Reddederken "IP'iniz engellendi" gibi hangi boyutun patladığını saldırgana söylemeyin. **Sonuç:** Ben olsam Redis'te `hesap + IP + ASN` üzerinde sliding-window kurar, credential stuffing için hesap kilidi ekler, volumetrik kısmı edge'e bırakırdım. Tek boyut (IP) hiçbir zaman yetmez; saldırının ortak paydasını yakalayan kombinasyon işi çözer. ## Graceful degradation: kritik olmayan servisleri yük altında nasıl izole ederim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/graceful-degradation-kritik-olmayan-servisleri-izole-etmek/ Kısa cevap: Graceful degradation bir kod tekniği değil, bir **önceden karar verme** disiplinidir. Neyin kritik, neyin lüks olduğuna Black Friday gelmeden karar ver; gerisi flag açıp kapatmak. ## Kısa cevap Asıl problem şu: %98 CPU'da hangi servisi kapatacağını düşünmeye başlarsan çoktan kaybetmişsindir. Çekirdek akış (ürünü gez, sepete at, ödeme yap) her zaman ayakta kalmalı; "olsa iyi" olanlar feda edilebilir olmalı. Tek bir yavaş bağımlılığın tüm sistemi nasıl aşağı çektiğini [circuit breaker kaydında](/tr/sor-bakalim/ucuncu-parti-api-icin-circuit-breaker-deseni/) ele almıştım; buradaki mesele o izolasyonu ürün seviyesinde kurmak. ## Neden 1. **Kriz anı karar anı değildir.** Hangi bileşenin feda edilebilir olduğuna sakinken karar verilir; %98 CPU'da doğaçlama yapılmaz. 2. **Yavaş bir lüks, kritik akışı aşağı çeker.** Öneri servisi 3 saniye beklerse, o beklemeyi checkout ödemiş olur. 3. **Test edilmemiş bir kill-switch bir tahmindir.** Kriz anında ilk kez basılan düğmenin çalışacağına dair elinizde kanıt yoktur. ## Ne yapmalı 1. **Kritik ile lüksü ayır ve etiketle.** Gez, sepet, checkout kritik. Önerilen ürünler, benzer ürünler, yorumlar lüks. Her lüks bileşeni **bağımsız kapatılabilir** bir feature flag arkasına al. 2. **Her lüks servise kısa timeout + statik fallback koy.** Öneri servisi 200ms'de cevap vermezse bekleme — boş dön ya da cache'lenmiş listeyi göster. 3. **Kill-switch ile anında devre dışı bırak.** Yük altında tek bir flag'le tüm lüks bileşenleri kapat. Deploy gerektirmeyen, canlıda anında etki eden bir anahtar olmalı. 4. **Load shedding ile DB'yi koru.** Kritik olmayan endpoint'ler için erkenden `503` dön; o istekleri DB'ye hiç ulaştırma. 5. **Çekirdek yolu bulkhead ile izole et.** Kritik ve kritik olmayan trafiğe ayrı bağlantı havuzu / worker ayır. **Sonuç:** Ben olsam flag'leri ve fallback'leri önceden kurar, "Black Friday anahtarlarını" gün gelmeden **prova ederdim**. Test edilmemiş bir kill-switch, kriz anında çalışacağına dair bir tahmindir. %98 CPU'da doğaçlama yapmak değil, önceden tanımlanmış bir senaryoyu çalıştırmak istiyorsun. ## Dağıtık sistemde Saga Pattern ile eventual consistency'yi nasıl tasarlarım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/dagitik-sistemde-saga-pattern-ile-eventual-consistency/ Kısa cevap: 2PC'den kaçınmanız doğru — o kilitlenir ve ölçeklenmez. Saga, tek bir atomik işlemi, her birinin bir telafi aksiyonu olduğu **yerel işlemler dizisine** çevirir. Sizin akışınız için orchestrator tabanlı Saga doğru tercih. ## Kısa cevap Asıl mesele şu: artık her şeyi tek transaction içinde geri alamazsınız. Bunun yerine, "bir adım bozulursa, daha önce yapılan adımları telafi et" disiplinini kurmanız lazım. Adımların taşındığı kuyruğun operasyonel tarafını [Laravel queue ve Supervisor yazısında](/tr/blog/laravel-queue-ve-supervisor-ile-asenkron-islemler/) anlatmıştım. ## Neden 1. **Telafi, rollback değildir.** İade gerçek bir işlemdir; "ileri yönde dengeleyici" bir adımdır. Bunu kabul etmek saga'nın tüm tasarımını belirler. 2. **Kuyrukta retry garantidir.** Aynı "stok düş" komutu iki kez gelebilir; idempotency olmadan saga ikinci denemede çift iade/çift stok üretir. Aynı disiplinin webhook tarafındaki hâlini [mükerrer bildirim ve idempotency kaydında](/tr/sor-bakalim/webhook-mukerrer-bildirim-idempotency-ve-hmac/) ele almıştım. 3. **"İşi yap" ile "olayı yayınla" ayrı kalırsa saga ortada kalır.** İş commit olur ama olay yayınlanmazsa akış hiç devam etmez. ## Ne yapmalı 1. **Merkezi koordinatör adımları kuyruktan sürsün.** Orchestrator (Sipariş Servisi) komutları kuyruğa basar: ödemeyi rezerve et → stok düş → gönderi oluştur. Her servis işini bitirince sonucu geri bildirir. Akış tek bir yerde, açık ve okunabilir. 2. **Hata olunca telafileri ters sırayla çalıştırın.** Kargo adımı patlarsa orchestrator geriye doğru gider: stoğu geri artır, ödemeyi iade et. 3. **Her adımı idempotent yapın.** Her komuta deterministik bir `saga_id` + `step_id` koyun, işlenmişse tekrar işlemeyin. 4. **Outbox pattern ile atomikliği koruyun.** İşi ve olayı aynı DB'deki `outbox` tablosuna tek transaction'da yazın, ayrı bir relay kuyruğa taşısın. 5. **Saga durumunu kalıcı tutun.** Hangi adımdasınız, hangileri tamamlandı — bir tabloda saklayın ki koordinatör çökse bile kaldığı yerden devam etsin. **Sonuç:** Orchestration'ı choreography'ye tercih edin; akış açık ve telafi sıralaması net. Eventual consistency'yi kabul edin: stok birkaç saniye tutarsız görünebilir, sonra mutabık olur. Mimari kararı sade.dev'de derinleştiriyorum. ## PostgreSQL split-brain'i quorum ve mutabakat ile nasıl önlerim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/postgresql-split-brain-quorum-ve-mutabakat-ile-onlemek/ Kısa cevap: Split-brain'in tek panzehiri **quorum**'dur — bir node ancak çoğunluğu elinde tutuyorsa primary olabilir ya da primary kalabilir. Yaşadığınız senaryoda eksik olan tam da buydu. ## Kısa cevap Sorunun kökü şu: bir replica yalnızca yerel bilgiye bakarak ("Master'a ulaşamıyorum, demek ki öldü") kendini terfi ettirdi. Oysa Master ölmemişti, sadece ağı koptu. İki primary, ayrışan veri. Uygulamanın hangi sunucuya bağlandığını yöneten katmanın kararlarını [PgBouncer havuzlama modları kaydında](/tr/sor-bakalim/pgbouncer-session-transaction-statement-modu-ve-prepared-statement/) ayrıca ele almıştım; failover o katmanın altındaki topolojiyi değiştirir. ## Neden 1. **Yerel bilgi bir arıza kanıtı değildir.** "Ulaşamıyorum" ile "ölmüş" arasındaki farkı tek bir node'un görmesi mümkün değil; bu ayrımı ancak çoğunluk yapabilir. 2. **Ev yapımı failover script'i tam bu hatayı üretir.** "Master'a ping atmıyorsa terfi et" mantığı, ağ bölünmesinde iki primary doğurur. Bu çözülmüş bir problem. 3. **İzole kalan eski primary yazmaya devam ederse veri ayrışır.** Terfiyi engellemek yetmez; eski primary'nin de kendini durdurması gerekir. ## Ne yapmalı 1. **Çoğunluk olmadan kimse primary olamasın.** Failover kararını mutabakatla veren bir yönetici kullanın: Patroni + etcd/Consul. Lider kilidini Raft tutar; yeni primary olabilmek için node'un quorum'dan o kilidi alması gerekir. 2. **Eski primary kendini düşürsün (fencing).** Patroni'nin varsayılan davranışı net: lider kilidinin güncellenmesi başarısız olduğu anda Postgres derhal demote edilip read-only başlatılır. Yani ağdan izole olan eski Master kendini replica'ya düşürür ve "iki primary aynı anda yazıyor" durumu imkânsızlaşır. (DCS Failsafe Mode açıksa primary, tüm üyelere Patroni REST API üzerinden ulaşabildiği sürece ayakta kalabilir; üyelerden biri cevap vermezse yine demote olur.) 3. **Tek sayıda oy veren üye koyun.** Mutabakat katmanını (etcd) farklı arıza alanlarına yayılmış **tek** sayıda üyeyle kurun — 3 ya da 5. Ağ ikiye bölündüğünde net bir çoğunluk tarafı oluşur. 4. **Son işlemleri kaybedemiyorsanız senkron replikasyon ekleyin.** `synchronous_commit` ve quorum tabanlı senkron replikasyon ile (örneğin `synchronous_standby_names = 'ANY 1 (r1, r2)'`) commit, listedeki replica'lardan en az biri onaylamadan dönmez. Bedeli gecikme, kazancı sıfıra yakın veri kaybı. 5. **Elle yazılmış failover script'ini emekliye ayırın.** Çözümü yeniden icat etmeyin. **Sonuç:** Ben olsam Patroni + etcd quorum + fencing üçlüsüne geçerim, failover'ı elle yönetmem. Veritabanı operasyonunun derinine sade.dev'de giriyorum; ama tek cümlesi şu: terfi kararı yerel bilgiyle değil, çoğunlukla verilir. ## Üçüncü parti API bağımlılığında Circuit Breaker'ı nasıl kurarım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/ucuncu-parti-api-icin-circuit-breaker-deseni/ Kısa cevap: Circuit breaker tek başına sizi kurtarmaz — onu **agresif bir timeout** ile birlikte kurarsanız kurtarır. Çünkü worker'larınızı öldüren şey hatanın kendisi değil, o hataya kadar geçen 30 saniyelik bekleme. ## Kısa cevap Yaşadığınız şey klasik **zincirleme çökme (cascading failure)**: yavaş bir bağımlılık, her isteği timeout'a kadar bekletir, havuz dolar, sistem kilitlenir. Breaker'ın işi, çöken bir bağımlılığı çağırmayı tamamen kesmek. Worker'ın istekler arasında ayakta kaldığı bir mimaride bunun neden daha sert vurduğunu [Laravel Octane yazısında](/tr/blog/laravel-octane-kalici-surecle-gelen-performans/) anlatmıştım. ## Neden 1. **Sizi öldüren hata değil, bekleme.** Bağımlılık hızlı hata dönseydi worker anında serbest kalırdı; 30 saniyelik timeout havuzu doldurur. 2. **Breaker'ın üç durumu bir protokoldür.** `CLOSED`: çağrılar geçer, hatalar sayılır. Eşik aşılınca `OPEN`: bağımlılığı hiç çağırma, anında fallback dön, bir süre (cooldown) böyle kal. Cooldown bitince `HALF-OPEN`: birkaç deneme isteğini içeri al — başarılıysa `CLOSED`'a dön, yine hata varsa tekrar `OPEN`'a geç. 3. **Tek bir bağımlılık tüm havuzu tüketmemeli.** İzolasyon olmadan kargo API'nin yavaşlığı sepet ve ödemeyi de aşağı çeker. ## Ne yapmalı 1. **Önce timeout'u kısın, asıl katil o.** `connect_timeout` ve `timeout`'u kısa tutun: Guzzle bunları saniye cinsinden float olarak alır; sınırsız beklemeyi bırakıp `0.8` ile `2` arası bir değer verin. Kısa timeout olmadan breaker işe yaramaz. 2. **Breaker'ı üç durumuyla kurun.** Yukarıdaki `CLOSED`/`OPEN`/`HALF-OPEN` döngüsü; eşik ve cooldown değerlerini gerçek hata oranınıza göre seçin. 3. **Bulkhead ile o bağımlılığı izole edin.** Kargo API'ye aynı anda gidebilecek istek sayısını sınırlayın; o servis yavaşladığında sadece ona ayrılmış küçük bir dilim tükensin. 4. **Fallback'i önceden tanımlayın.** Son cache'lenmiş kargo fiyatını dönün, olmuyorsa "tahmini ücret şu an hesaplanamıyor" deyin. Hata sayfası yerine bozulmuş ama çalışan bir deneyim sunun. 5. **Aracı doğru seçin.** Laravel'de Guzzle client'ını bir middleware'e sarın, durumu Redis'te tutun. Go'da elle yazmayın — `sony/gobreaker` zaten bu üç durumu veriyor. **Sonuç:** Ben olsam breaker'ı asla yalnız bırakmam: kısa timeout + bulkhead + cache'li fallback üçlüsüyle kurarım. Worker'ların timeout beklerken bloke olması özellikle kalıcı süreçli mimaride (Octane) çok daha sert vurur; o yüzden önce timeout'u kısın, sonra breaker'ı ekleyin. ## CDN'de asset sürümleme: hash'leme ve cache stratejisini nasıl kurarım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/cdn-asset-surumleme-hashleme-ve-cache-stratejisi/ Kısa cevap: Query-string ile cache busting güvenilmez; kalıcı çözüm content-hash'li dosya adı (`app.a8f9b2.js`) — ad sadece içerik değişince değişir, gerisini cache stratejisi halleder. ## Kısa cevap Sorunun kökü şu: `app.js?id=123` deseninde dosya adı hep aynı. Bazı CDN'ler query string'i cache key'inden atar ya da yok sayar, dolayısıyla `id` değişse de cache'teki ESKİ dosyayı servis etmeye devam eder. Cache key'ini değiştirmeniz gerekiyor — query'yi değil, adı. Aynı "önbelleği kim tazeliyor" sorusunun API tarafını [edge cache ve invalidation kaydında](/tr/sor-bakalim/cloudflare-workers-kv-ile-edge-cache-ve-invalidation/) ele almıştım. ## Neden 1. **Query string bir cache key garantisi değildir.** Davranış CDN'e göre değişir; bazıları onu tamamen yok sayar. Adın kendisi ise her katmanda cache key'in parçasıdır. 2. **İçerik hash'i doğal bir sürüm numarasıdır.** Ad yalnız içerik değiştiğinde değişir; değişmediğinde aynı kalır, yani gereksiz yeniden indirme de olmaz. 3. **Deploy anı bir yarış koşuludur.** Yeni HTML henüz yüklenmemiş bir hash'e işaret ederse kullanıcı 404 alır; sıra yanlışsa hash'leme de kurtarmaz. ## Ne yapmalı 1. **Content-hash'li dosya adı kullanın.** `app.a8f9b2.js` gibi; hash dosyanın içeriğinden türer. Böylece her asset'i `Cache-Control: public, max-age=31536000, immutable` ile cache'leyebilirsiniz — `immutable` direktifi tarayıcıya "bunu bir daha doğrulama bile" der, gereksiz koşullu istekleri keser. 2. **HTML/giriş noktasını kısa ömürlü tutun.** Hash'li asset'lere işaret eden HTML'i `no-cache` ya da kısa TTL ile verin: HTML her zaman taze gelsin ki yeni hash'lere işaret etsin. Burayı uzun cache'lerseniz tüm zincir kilitlenir. 3. **CI/CD'de önce yükleyin, sonra canlıya geçin.** Vite manifest'i ile hash'li çıktı üretin ve asset'leri uygulamayı canlıya almadan **önce** CDN'e/bucket'a yükleyin. DeployerPHP akışında bunu "symlink'i çevirmeden önce upload" adımı olarak kurun. 4. **Önceki sürümün asset'lerini bir süre tutun.** Deploy anında sayfası açık olan kullanıcı hâlâ eski HTML'i çalıştırıyor ve eski hash'leri ister. DeployerPHP'nin `keep_releases` mantığı bu "in-flight" kullanıcıları korur. **Sonuç:** Ben olsam content-hash'li, `immutable` cache'li asset + kısa ömürlü HTML + canlıya-geçmeden-önce-upload üçlüsünü kurardım. Gerçek content hashing'de pratikte asset purge'üne neredeyse hiç ihtiyacınız olmaz — ad değiştiği için eski dosya zaten dokunulmadan kalır, kimse onu istemez. Tek invalidate etmeniz gereken şey HTML'dir, o da zaten kısa cache'li. Query-string busting'i tamamen bırakın. ## Elasticsearch'te 'search-as-you-type' performansını edge n-gram ile nasıl optimize ederim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/elasticsearch-anlik-arama-edge-ngram-ile-optimizasyon/ Kısa cevap: `wildcard`/`regexp`'i bırakın; işi sorgu zamanından index zamanına taşıyın — `edge_ngram` ile önekleri yazarken üretin, autocomplete'in düz bir term lookup'a insin. ## Kısa cevap Asıl mesele şu: `wildcard`/`regexp` sorgu anında çalışan birer taramadır; inverted index'i verimli kullanamaz, her harfte 10M dokümanı süzmeye çalışır ve CPU'yu yakar. Çözüm sorguyu hızlandırmak değil, çalışmayı önceden yapmak. Elasticsearch'ün index/analiz modeline giriş için [Elasticsearch ve Kibana yazısına](/tr/blog/elasticsearch-ve-kibana-ile-buyuk-veriye-giris/) bakabilirsin. ## Neden 1. **Tarama ile lookup arasındaki fark ölçek farkıdır.** `edge_ngram` bir kelimeyi indekslerken tüm öneklerini ("a", "ay", "ayk", "ayka"…) ayrı token olarak üretir; kullanıcı "ayk" yazınca artık tarama yok, doğrudan o token aranır. Anlık aramanın bütün hızı buradan gelir. 2. **En sık yapılan hata analyzer'ları ayırmamaktır.** Sorguyu da ngramlarsanız kullanıcının yazdığı her önek tekrar parçalanır; alakasız eşleşmeler ve şişmiş skorlar gelir. 3. **Ngram index'i şişirir.** Gram aralığını geniş tutmak, kazandığınız hızı disk ve RAM olarak geri öder. ## Ne yapmalı 1. **`edge_ngram`'ı yalnızca index tarafında uygulayın.** `analyzer` index'te ngram üretsin, `search_analyzer` arama tarafında standart kalsın. Önekleri index üretir, arama sadece eşleştirir. 2. **Türkçe için `lowercase` + `asciifolding` ekleyin.** Custom analyzer'da ngram'dan önce koyun: "Şişe" ile "sise", "Ürün" ile "urun" eşleşsin. Türkçe arama çubuğunda bu pazarlık konusu değil. 3. **`min_gram`/`max_gram`'ı dar tutun** (ör. 2-15) ve hangi alanların aranacağını sınırlayın. 4. **Client'ta debounce ekleyin** (ör. 150 ms); her tuş vuruşunda istek atmayın. 5. **Kısayolu değerlendirin.** ES'in `search_as_you_type` alan tipi bu kurulumu (edge ngram + shingle) sizin yerinize hazır yapar. **Sonuç:** Ben olsam autocomplete'i ya custom `edge_ngram` analyzer'ı ile ya da hazır `search_as_you_type` alanıyla index zamanında kurardım; `wildcard`'ı otomatik tamamlama için asla kullanmazdım. Index analyzer'da ngram + `lowercase` + `asciifolding`, search analyzer'da standart eşleştirme, gram aralığını dar tutun ve client'ta debounce ekleyin. İş bittiğinde her harf, 10M ürün üzerinde tarama yerine düz bir term araması olur. ## 2-5 GB dosyaları PHP belleğini şişirmeden stream ile nasıl indirtirim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/buyuk-dosyalari-php-bellegini-sismeden-stream-ile-indirmek/ Kısa cevap: `file_get_contents`/`Storage::get` dosyanın TAMAMINI belleğe alır; 2-5 GB için yapmanız gereken onu stream etmek — ama daha iyisi, baytı PHP'den hiç geçirmemek. ## Kısa cevap Sorun kavramsal: bir HTTP cevabını "önce tamamını hazırla, sonra gönder" diye düşünüyorsunuz. Büyük dosyada doğru model "oku-bas-oku-bas"; üretirken aktarırsınız, bellek düz kalır. Dosyayı uygulamanın diskinden çıkarma kararının kendisini [yerel diskten S3'e geçiş kaydında](/tr/sor-bakalim/yerel-diskten-s3-e-gecerken-efs-ara-cozum-mu/) ayrıca ele almıştım. ## Neden 1. **Bellek limiti bir tavan değil, tasarımın sonucudur.** `memory_limit`'i yükseltmek 5 GB'lık bir dosyayı 5 GB'lık bir RAM sorununa çevirir; model değişmedikçe sınır hep geri gelir. 2. **Ara katmanlar stream'i sessizce iptal eder.** PHP'de doğru yazsanız bile FastCGI/Nginx buffering açıksa dosya yeniden belleğe birikir. 3. **En ölçeklenebilir yol, uygulamayı veri yolundan çıkarmaktır.** Yetkiyi PHP verir, baytı web sunucusu ya da object storage taşır. ## Ne yapmalı 1. **En iyisi: baytı uygulamadan hiç geçirmeyin.** Dosya S3/object storage'daysa kısa ömürlü bir `presigned URL` üretin, kullanıcı doğrudan oradan indirsin. Diskteyse `X-Accel-Redirect` (Nginx) veya `X-Sendfile` (Apache) ile dosyayı web sunucusu servis etsin. 2. **PHP'den geçirecekseniz chunk-by-chunk stream edin.** Laravel'de `Storage::readStream()` ile dosyayı açın, bir `StreamedResponse` (ya da `response()->streamDownload`) döndürün ve callback içinde döngüde `fread` edip yazıp `flush()` çağırın. `Content-Length` ve `Content-Disposition` header'larını verin. 3. **Output buffering'i o route için kapatın.** PHP'de `ob_*`, Nginx'te `fastcgi_buffering`/`proxy_buffering` kapalı olmalı; `X-Accel-Buffering: no` header'ı Nginx'e "bunu biriktirme" der. 4. **Octane'de cevabı bellekte tutmayın.** Kalıcı süreçte koca bir response gövdesini worker'da biriktirirseniz bellek istekler arası temizlenmez. Stream'i doğrudan çıkışa akıtın, ara değişkende tutmayın. **Sonuç:** Ben olsam ilk tercih presigned URL ya da `X-Sendfile`/`X-Accel-Redirect` olurdu — PHP yetkilendirir, baytı taşımaz; en ucuz, en ölçeklenebilir yol. Dosyayı gerçekten PHP üzerinden vermek zorundaysanız `readStream` + `StreamedResponse` ile chunk'layın, output buffering'i kapatmayı unutmayın. Tek kural: 5 GB'ı asla tek seferde belleğe almayın — ne PHP'de, ne de aradaki katmanda. ## HTTP/2 ve HTTP/3 (QUIC) API performansını nasıl etkiler? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/http-2-ve-http-3-quic-api-performansina-etkisi/ Kısa cevap: Tek ekrandaki 4-5 paralel istek sizin için HTTP/2'nin en güçlü olduğu senaryo — hemen açın; mobil kitle ağırlıktaysa HTTP/3 ile devam edin. ## Kısa cevap Yaşadığınız şey protokol seviyesinde bir darboğaz: HTTP/1.1 her host'a sınırlı sayıda bağlantı açar ve aynı bağlantıdaki bir istek ötekini bekletir (head-of-line blocking). Domain sharding bunu kapatmak için uydurulmuş bir hile; çözüm değil, semptom. Aynı "kullanıcıya en yakın yerden hızlı dön" hedefinin önbellek tarafını [edge cache ve invalidation kaydında](/tr/sor-bakalim/cloudflare-workers-kv-ile-edge-cache-ve-invalidation/) ele almıştım. ## Neden 1. **HTTP/2 çoklamayı tek bağlantıya indirir.** 4-5 isteğin tamamı tek TCP+TLS bağlantısı üzerinden eşzamanlı akar; "host başına 6 bağlantı" limiti kalkar, domain sharding'e gerek kalmaz. Üstüne HPACK header sıkıştırması var — "ekran başına çok sayıda küçük istek" deseninde tekrar eden header'lar neredeyse bedavaya gelir. 2. **HTTP/2'nin kör noktası TCP seviyesinde HOL blocking'dir.** Çoklama HTTP katmanında; altta tek bir TCP akışı var. Bir paket düşerse o paket yeniden gelene kadar tüm stream'ler bekler. Sağlam Wi-Fi'da fark etmez; paket kaybının yüksek olduğu mobil ağda geri vurur. 3. **HTTP/3 (QUIC) bu kör noktayı kapatır.** QUIC UDP üzerinde çalışır ve her stream'i bağımsız taşır; bir paket kaybı sadece o stream'i etkiler. Bağlantı kurulumu da daha hızlıdır (TLS handshake'i QUIC'e gömülü, `0-RTT` ile resumption). ## Ne yapmalı 1. **Önce HTTP/2'yi açın.** Ucuz, geriye dönük uyumlu, mevcut darboğazın çoğunu hemen çözer. Caddy'de hazır; Nginx'te 1.25.1'den beri ayrı bir `http2 on;` direktifi var (varsayılan kapalı). 2. **Mobil kitle ağırlıktaysa HTTP/3'ü ekleyin.** Caddy'de varsayılan; Nginx'te HTTP/3 modülü 1.25.0'dan beri var ama **deneysel** ve varsayılan derlemede yok — `--with-http_v3_module` ile derlenmiş bir binary gerekiyor, sonra `listen 443 quic;` ile açılıyor. 3. **Handshake'i ucuzlatın.** TLS'i edge'de sonlandırın, `keep-alive`'ı koruyun, `OCSP stapling` ve TLS session resumption (session ticket) ile her bağlantıda yeniden el sıkışmayı engelleyin. 4. **Domain sharding'i tamamen kaldırın.** Çoklamanın işe yaraması için tek origin'de toplayın; sharding'i geri sokarsanız HTTP/2'nin faydasını kendi elinizle iptal edersiniz. **Sonuç:** Ben olsam önce HTTP/2'yi açardım, sonra mobil kitle için HTTP/3'ü eklerdim; QUIC'in stream-bazlı bağımsızlığı ve hızlı kurulumu, kötü ağda kullanıcının hissettiği farktır. Tek origin, açık keep-alive, stapling ve session resumption — handshake maliyetini buradan kısarsınız. Domain sharding ise HTTP/2 dünyasında artık bir anti-pattern. ## PgBouncer'da Session/Transaction/Statement modlarından hangisini seçmeliyim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/pgbouncer-session-transaction-statement-modu-ve-prepared-statement/ Kısa cevap: Web/mikroservis filosu için **Transaction modu** — çok-statement'lı trafikte bağlantı yeniden kullanımını en yükseğe çıkaran en pratik seçenek bu. ## Kısa cevap Yaşadığın şey net: 50 pod × min bağlantı, `max_connections`'ı tüketiyor. PgBouncer tam da bunu çözer — çok sayıda client bağlantısını az sayıda sunucu bağlantısına multiplexler, yani `max_connections`'ı şişirmeden RAM'i kurtarırsın. Bağlantı havuzu doyduğunda sorgunun kendisinin de pay sahibi olduğunu unutma; sıcak sorgunun index'ini [composite index kolon sırası kaydında](/tr/sor-bakalim/postgresql-composite-index-kolon-sirasi-nasil-secilir/) ele almıştım. ## Neden 1. **Transaction modu en yüksek yeniden kullanımı verir.** Sunucu bağlantısını her transaction sonunda havuza iade eder; iki ardışık transaction farklı sunucu bağlantısına düşebilir. Yüzlerce kısa ömürlü mikroservis isteği için doğru mod budur. 2. **Session modu avantajı çöpe atar.** Her client'a tüm oturumu boyunca bir sunucu bağlantısı bağlar — yani 50 pod yine 50+ sunucu bağlantısı tutar; multiplexing kazancı kaybolur. Sadece session state'e gerçekten muhtaçsan mantıklı. 3. **Statement modu uygulamayı kırar.** Bağlantıyı her statement sonunda iade eder, bu da çok statement'lı transaction'ları bozar. Pratikte web uygulaması için kullanılmaz. ## Ne yapmalı 1. **Transaction modunu seç.** Havuzlama kazancının tamamı bu moddan gelir. 2. **Prepared statement tuzağını kapat.** Protokol seviyesi prepared statement'lar, `SET`, advisory lock ve `LISTEN` gibi bağlantıya bağlı durumlar farklı sunucu bağlantısına düşünce kaybolur. Client-side prepared statement'ları kapat (örn. PDO `ATTR_EMULATE_PREPARES`) **ya da** PgBouncer 1.21'den beri gelen named prepared statement desteğini kullan — `max_prepared_statements` sıfırdan büyükken PgBouncer bu komutları transaction ve statement modunda izler ve gerektiğinde yeni sunucu bağlantısında yeniden hazırlar. 3. **Statement'lar arası session state'e asla güvenme.** Transaction modunda bağlantı kimliği kalıcı değildir. 4. **`default_pool_size`'ı ölçülmüş eşzamanlılığa göre dar tut.** Sunucu bağlantısı havuzunu kocaman yapma — multiplexing'in tüm anlamı az sunucu bağlantısıyla çok client'a hizmet etmek. **Sonuç:** Ben olsam **Transaction modu** seçerdim, uygulamada protokol seviyesi prepared statement'ları kapatırdım (ya da PgBouncer'ı 1.21+'a yükseltirdim) ve `default_pool_size`'ı ölçülmüş eşzamanlılığa göre dar tutardım. Session modu sorununu çözmez, Statement modu uygulamanı kırar. Dikeyde `max_connections` ve RAM'i katlamak yerine bağlantı havuzlamayı çözmek çoğu zaman tam da PostgreSQL'i "yeterli" kılan hamledir — bu tartışmanın derinine sade.dev'deki yazıda girdim. ## Yeni bir projeye mikroservislerle mi başlamalıyım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/yeni-projeye-mikroservisle-mi-baslamali/ Kısa cevap: Büyük ihtimalle hayır. Sıfırdan başlayan çoğu ürün için doğru başlangıç noktası, **iyi modüllere ayrılmış bir monolit**tir. ## Kısa cevap Mikroservislerin çözdüğü asıl problem teknik değil, **organizasyoneldir**: birbirinden bağımsız deploy edilebilen, ayrı ekiplerin sahiplendiği, farklı hızlarda ölçeklenmesi gereken parçalar. Tek kişilik ya da küçük bir ekipseniz ve domain'i henüz tam oturmadıysa, bu problemlerin hiçbirine sahip değilsiniz. ## Neden 1. **Bedeli baştan ödüyorsunuz, faydayı sonra alıyorsunuz.** Ağ gecikmesi, kısmi hatalar, dağıtık transaction, gözlemlenebilirlik ve deploy karmaşası ilk günden faturalanır; ayrı ölçeklenme ihtiyacı ise aylar sonra — belki de hiç — gelir. 2. **Domain oturmadan çizilen sınır yanlış çizilir.** Ürünün ne olduğunu henüz öğreniyorken servis sınırı koymak, o sınırı ağ üzerinden betona dökmek demektir. 3. **Yanlış servis sınırını düzeltmek, monolitten çıkmaktan zordur.** "Monolitten servise çıkmak zor" derler; doğrudur — ama iyi çizilmiş sınırların olduğu bir monolitten çıkmak, baştan yanlış çizilmiş servis sınırlarını düzeltmekten çok daha kolaydır. ## Ne yapmalı 1. **Modüler monolitle başlayın.** Domain'i net sınırlara (bounded context) bölün, ama hepsini tek bir deploy edilebilir uygulamada tutun. Modüller arası iletişimi açık arayüzlerden geçirin, doğrudan tabloya uzanmayın. 2. **Sınırları kodda zorlayın.** Modüller arasında dairesel bağımlılığa izin vermeyin. Bu disiplin, ileride bir modülü servise çıkarmanız gerektiğinde işin büyük kısmını önceden yapmış olmanızı sağlar. 3. **Acıyı ölçtüğünde ayırın.** Bir modül gerçekten ayrı ölçeklenmek zorunda kaldığında, ayrı bir ekip onu sahiplendiğinde ya da deploy'lar birbirini bloke etmeye başladığında — işte o zaman o sınırı bir servise terfi ettirin. O günün nasıl yürütüleceğini [ödeme modülünü Strangler Fig ile koparma kaydında](/tr/sor-bakalim/monolitten-mikroservise-strangler-fig-ile-kademeli-gecis/) adım adım anlattım. **Sonuç:** Mikroservis bir hedef değil, belirli bir ölçekte ödediğiniz bir **bedeldir**; o ölçeğe ulaşmadan ödemeye başlamayın. Ben olsam modüler monolitle başlar, sınırları kodda zorlar ve bir modülü ancak ölçülmüş bir acı ortaya çıktığında servise terfi ettirirdim. Mimari muhakemenin tamamını sade.dev'de açıyorum. ## Autoscaling scale-in sırasında SIGTERM ile graceful shutdown'ı nasıl sağlarım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/autoscaling-sirasinda-sigterm-ile-graceful-shutdown/ Kısa cevap: Scale-in işleri yarıda kesiyor çünkü uygulama `SIGTERM`'de drain etmiyor — sürecin kapanış yaşam döngüsünü düzeltmeniz gerekiyor. ## Kısa cevap Asıl mesele autoscaling değil, kapanış protokolü: platform SIGTERM gönderdiğinde uygulamanız ya hemen ölüyor ya da işini bitirmeden kesiliyor. Doğru sıra şu: önce trafiği kesin, sonra mevcut işi bitirin, en son ölün. Kalıcı süreç modelinin bu davranışı neden değiştirdiğini [Laravel Octane yazısında](/tr/blog/laravel-octane-kalici-surecle-gelen-performans/) anlatmıştım. ## Neden 1. **Kapanış bir protokoldür, bir olay değil.** "Öl" sinyali geldiğinde yapılacak üç iş var ve sırası önemli; ikisini atlayıp doğrudan üçüncüye giderseniz veri bozulur. 2. **Platformun sabrı sınırlı.** SIGTERM'den sonra grace süresi dolunca SIGKILL gelir; drain süreniz o pencereden uzunsa iş yine yarıda kesilir. 3. **Hiçbir drain penceresi %100 garanti değildir.** Süreç sert kapanabilir; bu yüzden son savunma kodun kendisinde olmalı. ## Ne yapmalı 1. **SIGTERM'i yakalayın ve yeni istek almayı durdurun.** İlk hamle readiness probe'unu fail ettirmek veya kendinizi load balancer'dan **deregister** etmek; böylece LB size yeni istek yönlendirmeyi keser. Mevcut bağlantıları henüz kapatmayın. 2. **Go'da `server.Shutdown(ctx)` kullanın.** `signal.NotifyContext` ile SIGTERM'i yakalayın, `http.Server`'a deadline'lı bir context verin. Bu yeni bağlantıları reddeder, açık istekleri timeout'a kadar bitirir. 3. **Octane'de graceful stop/reload kullanın.** Worker'lar mevcut isteği bitirip yenisini almaz. Kuyruk tarafında `queue:work`, `kill -9` atmadığınız sürece SIGTERM'i kendisi ele alır — çalışan job'ı bitirir, yeni job claim etmez. 4. **Platformun grace süresini drain'inizle hizalayın.** ASG'de lifecycle hook, k8s'te `terminationGracePeriodSeconds` ile platformun bekleme süresini en uzun kabul edilebilir drain süresine eşitleyin. 5. **Uzun job'ları idempotent/resumable yapın.** Her adım idempotent olsun, yarıda kalan job baştan veya kaldığı yerden güvenle dönsün. **Sonuç:** Sıra önemli: önce LB'yi drain edin, sonra yeni istek alımını kesin, sonra mevcut işi bitirin, en son ölün. Go'da bunu `server.Shutdown(ctx)`, Octane'de graceful reload + `queue:work`'ün doğal SIGTERM davranışı verir; platform tarafında grace süresini gerçek drain süresine hizalayın. Üstüne job'ları idempotent yaparsanız sert kill bile veriyi bozmaz. ## PostgreSQL'de composite index kolon sırasını nasıl seçerim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/postgresql-composite-index-kolon-sirasi-nasil-secilir/ Kısa cevap: Bu sorgu için tek bir composite index kur — `(user_id, status, created_at DESC)`. Eşitlik kolonları başa, ORDER BY kolonu en sona; gerisi kendiliğinden çözülür. ## Kısa cevap Yaşadığın yavaşlık index eksikliği değil, **yanlış index biçimi**: üç ayrı tekli indeksi DB Bitmap Index Scan ile birleştirip sonra ayrı bir Sort adımı çalıştırmak zorunda kalıyor, ikisi de pahalı. Aynı "sıcak tabloyu hangi kolona göre düzenlemeli" sorusunun kiracı bazlı hâlini [multi-tenant izolasyon kaydında](/tr/sor-bakalim/multi-tenant-saas-veritabani-izolasyon-stratejisi/) ele almıştım. ## Neden 1. **Eşitlik kolonları önde, sıralama kolonu sonda olmalı.** B-tree, eşitlik filtresini uyguladıktan sonra satırları zaten sıralı verir; ayrı bir Sort adımına gerek kalmaz. Sıra bozulursa planlayıcı sıralamayı kendisi yapmak zorunda kalır. 2. **Cardinality burada ikincil.** Her iki kolon da eşitlik predicate'i olduğu için asıl kazanç sıralamayı index'e taşımakta; eşitlik kolonları arasında sıra, index'in ne kadarının taranacağını değiştirmez (PostgreSQL kılavuzu: baştaki kolonlardaki eşitlik koşulları taranan bölümü sınırlar); `user_id` başa gidiyor çünkü yalnızca `user_id` ile filtreleyen sorgulara da hizmet ediyor. 3. **Fazlalık index bedava değil.** Tekli `user_id` indeksi artık composite'in gereksiz bir öneki, ama her yazmada hâlâ güncelleniyor. ## Ne yapmalı 1. **`(user_id, status, created_at DESC)` composite index'ini kur.** Eşitlik kolonları başta, ORDER BY kolonu sonda. 2. **`DESC`'i index tanımına yazmak burada isteğe bağlı.** PostgreSQL bir B-tree'yi geriye doğru tarayabilir; baştaki iki kolon eşitlikle sabitlendiğinde `(user_id, status, created_at)` de `ORDER BY created_at DESC`'i karşılar. Index'in sorguyu belgelemesini istiyorsan yönü yaz; ORDER BY yönleri karışıksa (ör. `x ASC, y DESC`) gerçekten gerekir. 3. **`EXPLAIN (ANALYZE, BUFFERS)` ile doğrula.** İstediğin plan tek bir **Index Scan** — `Bitmap Index Scan` + `Sort` değil. Planda hâlâ Sort görüyorsan kolon sırasına bak: sıralama kolonu eşitlik kolonlarından sonra gelmeli. 4. **Tekli `user_id` indeksini sil.** Composite, baştaki kolon üzerindeki her aramayı zaten karşılıyor. `status` ve `created_at` indeksleri composite'in öneki değil; başka sorgular kullanıyorsa tut, silmeden önce `pg_stat_user_indexes`'e bak. 5. **`completed` baskınsa partial index düşün.** Sorgularının çoğu `status = 'completed'` ise `WHERE status = 'completed'` koşullu partial index kullan: index boyutunu küçültür, RAM'de daha çok tutabilir, yazma maliyetini düşürür. **Sonuç:** Ben olsam `(user_id, status, created_at DESC)` composite index'ini kurar, `EXPLAIN (ANALYZE, BUFFERS)` ile Index Scan'e düştüğünü doğrular ve gereksiz kalan `user_id` indeksini silerdim. Trafiğin tek bir status'e yığılıyorsa partial index'le bir tur daha sık. Indeksleme ile native SQL'in dengesi üzerine daha derin bir tartışma için sade.dev'deki yazıya bak. ## Redis cache stampede'i (thundering herd) nasıl önlerim — mutex lock mı, XFetch mi? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/redis-cache-stampede-mutex-lock-mu-xfetch-mi/ Kısa cevap: Sorun "miss"in kendisi değil, anahtarın **aynı saniyede uçurumdan düşmesi** ve tüm sürünün aynı anda DB'ye yüklenmesi. Çözüm, o ânı tek isteğe indirgemek ve sürüyü zamana yaymak. ## Kısa cevap Yaşadığın şey klasik **thundering herd**: TTL bitince tek bir kullanıcı değil, binlerce istek aynı milisaniyede cache miss alıp aynı sorguyu PostgreSQL'e atıyor. Tek bir sorgu yeterken yüzlercesi koşuyor, bağlantı havuzu doluyor ve DB diz çöküyor. Önbelleğin tazeliğini kimin ittiği sorusunu [edge cache ve invalidation kaydında](/tr/sor-bakalim/cloudflare-workers-kv-ile-edge-cache-ve-invalidation/) ayrıca tartışmıştım; buradaki mesele o tazelemenin **kaç kez** koştuğu. ## Neden 1. **Önbellek expire ânında amacının tersini yapar.** Görevi DB'yi korumakken, tüm yükü tek saniyeye sıkıştırır. 2. **Sabit TTL senkronizasyon üretir.** Tüm anahtarlara aynı 10 dakikayı verirseniz hepsi birlikte düşer; kıyameti beraber koparırlar. 3. **Kullanıcıyı recompute'un arkasında bekletmek gereksiz.** Elinizde bir saniye öncesine ait doğru bir değer varken onu sunmamak için bir sebep yok. ## Ne yapmalı 1. **Tek seferlik mutex (single-flight) lock kur.** Miss anında ilk istek Redis'te kısa bir kilit alır (`SET NX PX` ile, ms cinsinden TTL'li); kilidi alan recompute eder ve cache'i doldurur. Böylece Postgres'e tek sorgu gider. Kilide her zaman TTL koy ki recompute eden süreç ölse bile kilit kalıcı kilitlenmesin. 2. **TTL'lere jitter ekle.** TTL'yi `10dk ± rastgele birkaç dakika` yaparsan anahtarlar farklı anlarda düşer, basınç doğal olarak yayılır. Tek satırlık değişiklik, büyük kazanç. 3. **Stale-while-revalidate ile kullanıcıyı bekletme.** Anahtar expire olunca eldeki eski değeri sunmaya devam et, yenilemeyi arka planda yap. Bu, lock yaklaşımının "bekleyenler" maliyetini de sıfıra indirir. 4. **Recompute gerçekten pahalıysa XFetch'e geç.** Probabilistic early expiration ile her okuyucu, TTL bitimine yaklaştıkça recompute maliyetiyle orantılı bir olasılıkla erken yenilemeye karar verir; herd hiç oluşmaz. Daha zariftir ama uygulaması ve test etmesi daha incedir. **Sonuç:** Ben olsam çoğu durumda **single-flight lock + TTL jitter** ile başlardım — birlikte vakaların büyük çoğunluğunu kapatır, operasyonu basittir, anlaması kolaydır. XFetch'i recompute gerçekten pahalı ve trafik gerçekten devasaysa devreye al. Ve hangisini seçersen seç, stale-while-revalidate'i ekle: kullanıcıyı bir DB sorgusunun arkasında asla bekletme. ## Go'da GC baskısını sync.Pool ve escape analizi ile nasıl azaltırım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/go-da-gc-baskisini-sync-pool-ve-escape-analizi-ile-azaltmak/ Kısa cevap: GC duraklamaları bir semptom; asıl düşman **tahsis hızı** (allocation rate). Önce tahsisleri kesin, GC zaten rahatlar. ## Kısa cevap Yaşadığınız spike'lar GC'nin "kötü" olmasından değil — saniyede 50k paket, saniyede yüz binlerce kısa ömürlü nesne demek; GC bu çöpü toplamak için sürekli ve agresif çalışıyor, Stop-the-World duraklamaları da yanıt süresine yansıyor. Çözüm GC'yi kapatmak değil, ona daha az iş vermek. Bu yolun eşzamanlılık tarafını [Go'da goroutine ve channel pratiği yazısında](/tr/blog/goda-eszamanlilik-goroutine-ve-channel-pratigi/) ele almıştım. ## Neden 1. **Duraklamanın kaynağı tahsis sıklığıdır.** GC ne kadar çöp üretirseniz o kadar sık koşar; ayarı değiştirmek üretimi değiştirmez. 2. **En ucuz tahsis, hiç yapılmayandır.** Bir değeri stack'te tutabiliyorsanız GC o nesneyi hiç görmez — havuzlamaktan da ucuzdur. 3. **Pooling'in bir ters tepme riski var.** Başka heap verisine pointer tutan bir nesneyi havuzlarsanız, havuz onu canlı tuttuğu için işaret ettiği büyük graf da serbest kalmaz; bellek beklediğinizden çok şişer. ## Ne yapmalı 1. **Kör gitmeyin: `pprof` ile önce/sonra ölçün.** `alloc_space` profili en çok tahsis eden satırı söyler; `GODEBUG=gctrace=1` GC sıklığını ve duraklama süresini gösterir. Aynı yükle önce ve sonra ölçün. 2. **Tahsisi kökten azaltın.** Slice/map'leri biliyorsanız baştan `make([]T, 0, n)` ile boyutlandırın; `bytes.Buffer` ve JSON decoder'ı tekrar kullanın; gereksiz `[]byte`↔`string` kopyalarından kaçının. Tek bir kopya 50k kez çarpınca dağ olur. 3. **Escape analizini çalıştırın, stack'te tutun.** `go build -gcflags=-m` çıktısı hangi değerin heap'e kaçtığını söyler. Yaygın nedenler: bir pointer'ı fonksiyondan dışarı döndürmek, boyutu dinamik slice'lar ve zaten kaçmış değerlere pointer yazmak. 4. **Hot path'te `sync.Pool` ile yeniden kullanın.** Ingest yolundaki kısa ömürlü buffer'ları, struct'ları ve decoder'ları havuzdan al-geri verin. `Put` etmeden önce nesneyi **resetleyin** ki eski veri sızmasın. Havuzda yalnızca düz, kendi içinde kapalı buffer/struct'ları tutun. 5. **`GOGC`/`GOMEMLIMIT` ayarını en sona bırakın.** GC'yi seyrekleştirirler ama saniyede 50k tahsis yapan bir yolu düzeltmezler; son ince ayar, ilk hamle değil. **Sonuç:** Sıralama net: **profilleyin → en çok tahsis edeni öldürün → hayatta kalanları havuzlayın → sonra `GOGC`/`GOMEMLIMIT` ayarlayın.** Asıl kazanç, GC'ye en sıcak yolda hiç çöp üretmemekten gelir. ## Laravel Octane (FrankenPHP) altında bellek sızıntısını nasıl debug ederim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/laravel-octane-frankenphp-bellek-sizintisi-nasil-debug-edilir/ Kısa cevap: Tahmin etmeyin, **ölçün** — Octane'de worker uzun ömürlü olduğu için istekler arasında biriken her şey sızar; önce neyin biriktiğini izleyip yakalayın, sonra stateful tarafı hook'larda sıfırlayın. ## Kısa cevap Asıl mesele şu: PHP-FPM "her istekte taze süreç" varsayar, Octane ise worker'ı ayakta tutar. Bu varsayımı bozan her şey — büyüyen static array'ler, request state tutan singleton'lar, her istekte yeniden register edilen listener/binding'ler ve Octane'in bilmediği her durum — sessizce belleği şişirir. Octane kendi framework durumunu istekler arasında sıfırlar (auth guard'ları, log bağlamı, request örneği); görmediği durumu sıfırlamak sizin işiniz. Kalıcı sürecin bu modeli neyi değiştirdiğini [Laravel Octane yazısında](/tr/blog/laravel-octane-kalici-surecle-gelen-performans/) anlatmıştım. ## Neden 1. **Sızıntı bir kod hatası değil, bir varsayım ihlali.** PHP-FPM'de her istek kendi sürecinde doğup ölüyordu; sızdıran kod da onunla birlikte ölüyordu. Octane bu güvenlik ağını kaldırıyor, aynı kod aynı sürecde biriktirmeye başlıyor. 2. **Suçlu çoğu zaman kendi kodunuz değil.** "Her istekte taze süreç" varsayan bir third-party paket PHP-FPM'de görünmez, Octane'de patlar. Bu yüzden okuma değil ölçüm gerekiyor. 3. **`--max-requests` kök nedeni gizler.** Worker'ı belli sayıda istekten sonra geri dönüştürmek RAM'i sınırlar ama sızıntıyı kapatmaz — `500`'ü düşürmek sizi sadece geç çökmeye taşır. ## Ne yapmalı 1. **Önce ölçün, sonra konuşun.** Her N istekte bir `memory_get_usage(true)`'yi loglayın; bellek monoton artıyorsa sızıntı vardır, dalgalanıp düşüyorsa normaldir. Octane'in `RequestTerminated` hook'una bir sayaç koyun ve eğrinin şeklini görün. 2. **Bisect ile suçluyu bulun.** Service provider'ları ve şüpheli paketleri tek tek devre dışı bırakıp eğriyi tekrar ölçün; artış nerede duruyorsa sızıntı oradadır. Snapshot diff'i (iki noktada `memory_get_usage` farkı) hangi tipin biriktiğini daraltır. 3. **Stateful singleton'ları hook'ta sıfırlayın.** Request state tutan binding'leri `RequestReceived`/`RequestTerminated` hook'larında ya da `config/octane.php` içindeki `flush` listesiyle temizleyin. Singleton'ı container'da bırakıp içindeki state'i resetlemek, çoğu sızıntıyı tek satırda kapatır. 4. **Static birikimi öldürün, servisi request başına yeniden bağlayın.** Büyüyen static `$cache = []` array'leri, kapanmayan bağlantılar, istek başına şişen koleksiyonlar tipiktir. Request scope'una ait servisi singleton yapmayın; her istekte yeniden bind edin ki taşıdığı veri istekle birlikte ölsün. 5. **`--max-requests`'i emniyet supabı olarak açık tutun.** Ama asıl işi sızıntıyı kapatarak yapın; ona bel bağlamayın. **Sonuç:** Ben olsam sırayı şöyle kurardım: önce `memory_get_usage` ile eğriyi çıkarın, sonra service provider/paketleri bisect ederek suçluyu daraltın, ardından stateful singleton'ları Octane hook'larında sıfırlayın ve static birikimi temizleyin. `--max-requests`'i bir güvenlik ağı olarak elinizde tutun ama ona bel bağlamayın. Octane'in hızını alacaksanız, "süreç her istekte ölmüyor" gerçeğini kodun her katmanında kabul etmeniz gerekir. ## Multi-tenant SaaS'ta veritabanı izolasyonu: ayrı DB mi, şema mı, tenant_id mi? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/multi-tenant-saas-veritabani-izolasyon-stratejisi/ Kısa cevap: 10.000 tenant ölçeğinde pragmatik cevap, her yerde `tenant_id` olan **shared** bir DB + izolasyonu **Postgres Row-Level Security**'ye gömmek. ## Kısa cevap Önce neden diğer ikisini elediğimi söyleyeyim, çünkü karar bu sayıda operasyona bakar. Aynı "veri sınırını nereye koyacağım" sorusunun servis ayırma tarafındaki hâlini [monolitten mikroservise geçiş sorusunda](/tr/sor-bakalim/monolitten-mikroservise-strangler-fig-ile-kademeli-gecis/) tartışmıştım. ## Neden 1. **Database-per-tenant 10.000'de ölçeklenmez.** Her tenant için ayrı DB demek 10.000 migration, 10.000 connection pool, 10.000 backup işi demek. İzolasyonu mükemmel verir ama operasyonel yükü altında ezilirsiniz. Bu, ancak az sayıda büyük müşteride mantıklıdır. 2. **Schema-per-tenant orta yol ama o da zorlanır.** Tek DB'de ayrı şema, izolasyon ile yönetim arasında bir köprüdür; fakat 10k şemada katalog şişer, migration hâlâ N kere koşar. Birkaç yüz tenant'a kadar iyi, on bine değil. 3. **Shared modelde takas ettiğiniz tek şey güvenliktir.** Tek şema, her tabloda `tenant_id`; backup ve migration en basit burada, maliyet en düşük. Geriye kalan risk sızıntıdır ve onu geri satın almanın bir yolu vardır. ## Ne yapmalı 1. **Shared DB + her yerde `tenant_id` kurun.** Tek şema, tek migration, tek backup. 10.000 tenant için tek instance'ın kaynağını paylaşırsınız. 2. **İzolasyonu RLS ile DB'ye gömün.** Tabloda `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` açıp bir `CREATE POLICY` ile satırları `tenant_id`'ye bağlayın; bundan sonra tabloya her normal erişim o politikadan geçmek zorunda kalır. Politika yazmadan yalnızca RLS'i açmak varsayılan-reddet demektir, yani hiçbir satır görünmez — açmak yetmiyor, politikayı da yazmanız gerekiyor. Bir uyarı: tablo sahibi ve `BYPASSRLS` yetkili roller politikayı atlar; uygulama kullanıcısını tablo sahibi yapmayın, gerekiyorsa `FORCE ROW LEVEL SECURITY` ekleyin. İzolasyon, "geliştirici umarım her query'de scope koymuştur" varsayımında değil, veritabanının kendisinde yaşasın. 3. **ORM scope'u + indeks/partition ekleyin.** ORM katmanında bir tenant scope koyun (defense-in-depth), sıcak tabloları `tenant_id` ile indeksleyin veya partition'layın. 4. **Whale tenant için çıkış kapısını açık bırakın.** Bir müşteri diğerlerini ezecek kadar büyürse onu kendi DB'sine **promote** etme yolunu baştan tasarlayın. **Sonuç:** Ben olsam shared DB + her yerde `tenant_id` + Postgres RLS kurardım. Backup ve migration shared modelde en basit; güvenlik ödediğiniz bedel ama onu RLS ile geri alıyorsunuz. ORM scope'unu ikinci savunma katmanı yapın, sıcak tabloları `tenant_id` ile partition'layın, dev büyüyen tenant için kendi DB'sine taşıma kapısını açık bırakın. Veri mimarisinin neden böyle kurulduğunu sade.dev'de açıyorum. ## Ödeme webhook'u aynı bildirimi tekrar gönderiyor; idempotency'i nasıl kurarım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/webhook-mukerrer-bildirim-idempotency-ve-hmac/ Kısa cevap: Bunlar **iki ayrı mesele** — kimlik doğrulama (HMAC) ve mükerrerliği önleme (idempotency). İkisini karıştırma, ikisini de ayrı çöz. ## Kısa cevap Tek cümleyle: imza "bu gerçekten sağlayıcı mı"yı, idempotency "bu işi daha önce yaptım mı"yı sorar. İkincisinin genel hâlini, yani aynı isteği güvenle tekrar etmenin API tarafındaki kurallarını [idempotency yazısında](/tr/blog/api-de-idempotency-ayni-istegi-guvenle-tekrar-etmek/) anlatmıştım; burada aynı fikrin webhook üzerindeki uygulaması var. ## Neden 1. **İmza ile tekillik farklı şeyleri garanti eder.** Geçerli imzalı bir bildirim beş kez gelebilir; imza onun sahiciliğini söyler, kaçıncı kez geldiğini değil. 2. **Bir-kez garantisini uygulama mantığı veremez.** İki istek aynı anda gelirse "önce kontrol et, sonra yaz" mantığı ikisini de geçirir. Tekilliği ancak veritabanının kendi kısıtı verir. 3. **Yavaş yanıt yeni tekrarlar üretir.** Sağlayıcı timeout yerse aynı bildirimi yeniden gönderir; senkron çalışan bir handler kendi sorununu büyütür. ## Ne yapmalı 1. **İmzayı RAW body üzerinde doğrula.** HMAC imzasını, parse etmeden önce **ham gövde** üzerinde, **constant-time** karşılaştırmayla kontrol et. JSON'ı decode edip yeniden encode edersen imza bozulur. 2. **Idempotency'i DB'ye yaptır.** Provider'ın event id'sini (ya da hash'ini) **UNIQUE constraint**'li bir tabloda sakla; bir transaction içinde **check-or-insert** yap. 3. **Önce hızlı 200 dön, işi kuyruğa al.** İmzayı doğrula, kaydı al, hızlıca `200` dön ve asıl işi kuyruğa it; worker aynı key üzerinde idempotent kalsın. Kuyruk tarafının operasyonel detayı [Laravel queue ve Supervisor yazısında](/tr/blog/laravel-queue-ve-supervisor-ile-asenkron-islemler/). 4. **Her katmanda iki-kez-çağrılmaya dayanıklı ol.** Handler da, worker da, yan etki de aynı bildirimle iki kez çağrılınca tek bir net sonuç üretmeli. 5. **Sıralamayı ve replay penceresini gözet.** "Refund" bildirimi "charge"dan önce gelebilir; out-of-order durumları handle et. **Sonuç:** Ben olsam akışı **imza doğrula → hızlı 200 → işi kuyruğa al** diye kurar, idempotency'i `event_id` üzerindeki UNIQUE constraint + transaction'lı check-or-insert ile DB'ye yaptırırdım. HMAC'i ham body'de constant-time karşılaştır. Double-spending'i app kodu değil, veritabanının tekillik garantisi durdurur. ## PHP monolitten ödeme modülünü Strangler Fig ile nasıl koparırım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/monolitten-mikroservise-strangler-fig-ile-kademeli-gecis/ Kısa cevap: Önüne bir facade/gateway (Nginx/Envoy) koy, trafiği ağırlıkla kaydır (5/20/100), monolit fallback kalsın. Ama zor kısım routing değil — veri. ## Kısa cevap Strangler Fig'in mantığı şu: eski modülü bir anda öldürmezsin, etrafını sarıp trafiği yavaşça yeni servise kaydırırsın. Modül koptuğu anda iki taraf arasında bir mesaj sözleşmesi doğar; o sözleşmeyi nasıl kuracağını [Laravel ile Go arasındaki mesaj şeması sorusunda](/tr/sor-bakalim/laravel-ve-go-arasinda-mesaj-semasi-protobuf-mu-json-schema-mi/) ayrıca ele almıştım. ## Neden 1. **Routing kolay, veri zor.** Gateway'de ağırlık değiştirmek tek satır config; paylaşılan tabloları ayırmak aylar sürebilir. Planı ikinciye göre yap. 2. **Para söz konusuyken "umarım doğrudur" yoktur.** Yeni servisi gerçek para işlemeden önce doğrulamanın tek yolu, çıktıları monolitinkiyle karşılaştırmaktır. 3. **Sınırı net olmayan modülü koparmak felaketi hızlandırır.** Ödeme modülü monolitin tablolarına dolanmışsa, Strangler Fig o dolanmayı çözmez — üstüne trafik ekler. ## Ne yapmalı 1. **Gateway'de ağırlıklı yönlendirme kur.** Nginx/Envoy önde dursun, ödeme trafiğini `%5 → %20 → %100` ağırlıkla yeni Go servisine yolla, geri kalanı monolite. Geri dönüş tek satır config olsun. 2. **Önce SHADOW çalıştır.** Trafiği aynala, çıktıları monolitinkiyle karşılaştır, **hiçbir gerçek aksiyon alma**. Hesaplar tutuyorsa canlıya al. 3. **Tabloları asla paylaşma.** Ödeme servisine kendi store'unu ver; aralarına bir **anti-corruption layer** koy ki monolitin modeli yeni servise sızmasın. 4. **Tutarlılığı outbox + events ile sağla.** Distributed transaction'a girme; değişikliği kendi DB'sine yaz, aynı transaction'da bir outbox kaydı bırak, event olarak yayınla. Veriyi taşırken **dual-write + backfill**, sonra reconcile, sonra cutover. 5. **Önce dikişi düzelt.** Sınır zaten net değilse, koparmadan önce o seam'i temizle. **Sonuç:** Ben olsam gateway'de ağırlıklı routing + monolit fallback kurar, yeni Go servisini önce shadow'da gerçek parayla değil aynalanmış trafikle doğrular, sonra kademeli açardım. Asıl işin routing değil veri ayrımı olduğunu unutma: paylaşılan tablo yok, anti-corruption layer var, tutarlılık outbox/event ile geliyor. Mimari derinliği sade.dev'de açıyorum — sınırı temiz olmayan modülü koparmaya kalkma. ## Cloudflare Workers + KV ile edge cache kurarsam invalidation'ı nasıl yaparım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/cloudflare-workers-kv-ile-edge-cache-ve-invalidation/ Kısa cevap: Sadece TTL'e güvenmeyin — TTL eventual'dır ve süre dolana kadar **eski fiyatı** servis eder. Invalidation'ı push-based ve event-driven kurun. ## Kısa cevap Sizin senaryonuzda kabul edilemez olan şey "yanlış fiyat" penceresi. Çözüm, değişikliği edge'e beklemeye bırakmak değil, itmek. Bu, önbelleği bir hız katmanı olarak görüp doğruluk kaynağını origin'de tutan klasik bir [backend mimarisi](/tr/uzmanlik/backend/) kararıdır. ## Neden 1. **TTL bir invalidation mekanizması değil, bir eskime sınırıdır.** Süre dolana kadar edge eski değeri servis etmeye devam eder; para etkileyen veride bu pencere kabul edilemez. 2. **KV kaynak değil, cache'tir.** Doğruluk kaynağınız origin DB'dir; KV onun hızlı bir kopyasıdır. Bu zihniyet, KV bozulduğunda ya da tutarsız kaldığında origin'den yeniden besleyebilmenizi sağlar. 3. **KV global olarak eventual consistent'tır.** Yazımlar dünyaya yayılırken 60 saniye ya da daha uzun sürebilir; küçük bir tutarsızlık penceresi kaçınılmazdır. Anlık global doğruluk gerekiyorsa KV yanlış katmandır. ## Ne yapmalı 1. **Değişiklikte write-through (veya purge) yapın.** Origin'de fiyat değiştiği anda, aynı transaction/outbox içinde Cloudflare API'ına çağrı atıp yeni değeri KV'ye yazın ya da key'i silin. Böylece güncelleme yazma işleminin parçası olur, ayrı bir "umarım çalışır" adımı değil. 2. **Key'i entity+version ile kurun.** Her değeri bir versiyonla damgalayın (`price:42:v7` gibi). Worker eski versiyonu görürse **serve-stale-while-revalidate** yapıp arkada güncelleyebilir. 3. **Kısa TTL'i emniyet ağı olarak bırakın.** Push invalidation bir sebeple kaçarsa, kısa TTL en kötü ihtimalle eskiliği birkaç saniyeyle sınırlar. TTL tek savunma değil, son savunma olsun. **Sonuç:** Ben olsam **değişiklikte write-through (ya da purge) + kısa TTL emniyet ağı + versiyonlu key** üçlüsünü kurardım. TTL-only çözüm fiyat gibi para etkileyen veride yanlış; push-based invalidation ile origin değiştiği an edge güncellenir. KV'nin saniyelik yayılma penceresini bilerek tasarlayın — onu kaynak değil, hızlı ve tazelenebilir bir cache olarak kullanın. ## Mikroservislerde dağıtık izlemeyi (OpenTelemetry) nasıl kurarım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/mikroserviste-dagitik-izleme-opentelemetry-ile-nasil-kurulur/ Kısa cevap: Custom bir şema icat etmeyin; **W3C Trace Context** (`traceparent`/`tracestate`) üzerinde standartlaşın ki PHP ve Go birbirini sorunsuz anlasın. ## Kısa cevap Sizin asıl derdiniz "800ms nerede" — ve bunu görebilmek için trace'in servisler ve kuyruklar arasında **kopmadan** taşınması gerekiyor. Go tarafındaki HTTP mekaniğini, yani context'in istekle birlikte nasıl taşındığını [Go'da HTTP servisi yazmak yazısında](/tr/blog/go-da-http-servisi-yazmak-standart-kutuphane-yeter-mi/) anlatmıştım. ## Neden 1. **Standart başlık, iki dilin ortak paydasıdır.** W3C Trace Context standart olduğu için Laravel ve Go aynı `traceparent`'ı ortak okur. Kendi `trace_id` başlığınızı uydurursanız her yeni servis için bir çeviri katmanı borçlanırsınız. 2. **Zincir en çok kuyruk hop'unda kopar.** Takımların unuttuğu parça budur: HTTP tarafını doğru kurup mesaj yayınlarken context'i taşımazsanız asenkron sıçramalar APM'de **orphan trace** olarak görünür. Aynı kuyruğun kendi dayanıklılık tarafını [log hattına buffer koyma sorusunda](/tr/sor-bakalim/loglari-opensearch-e-gonderirken-araya-kafka-buffer-koymali-miyim/) ayrıca tartışmıştım. 3. **Head-only sampling tam da aradığınız trace'i atar.** 800ms'lik outlier'ı görmek istiyorsanız kararı isteğin başında vermek yanlış yerdir. ## Ne yapmalı 1. **W3C Trace Context'i standart yapın.** Gateway'de kök span'i başlatın; her servis gelen HTTP header'ından context'i **extract** etsin, giden çağrılara **inject** etsin. 2. **Kuyruklarda aynı `traceparent`'ı mesajla taşıyın.** Mesajı publish ederken `traceparent`'ı bir mesaj header'ı/attribute olarak ekleyin, worker'da yeniden **extract** edin. 3. **Her servise OTel SDK koyun, OTLP ile gönderin.** Servis → OTLP → bir **Collector** → Jaeger/Tempo. Collector'ı araya almak, export'u uygulamadan ayırır ve backend değiştirmeyi kolaylaştırır. 4. **Sampling'i akıllı seçin.** `parent-based` ile bir trace'in tüm span'leri aynı kaderi paylaşsın; üstüne **tail sampling** ekleyin ki yavaş trace'ler kesin tutulsun. 5. **trace_id'yi elle örmeyin.** Kendi elinizle header okuma/yazma kodu yazmayın; SDK'nın context propagation'ı bunu zaten taşır. Elle yapınca bir yerde unutursunuz ve trace o noktada kopar. **Sonuç:** Ben olsam W3C Trace Context + OTel SDK + Collector + Tempo/Jaeger kurar, propagation'ı SDK'ya bırakır, kuyruk hop'unda `traceparent`'ı mesaj header'ına koymayı asla atlamazdım. Mimari muhakemeyi neden böyle kurduğunuzu sade.dev'de anlatıyorum. 800ms'i ancak kopmamış bir trace gösterir. ## WireGuard ile Docker ağları çakışıyor; routing'i nasıl stabilize ederim? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/wireguard-ile-docker-agi-ip-cakismasi-ve-routing/ Kısa cevap: Kök neden çakışan RFC1918 aralıkları. Çözüm runtime hilesi değil, deterministik bir adresleme planı: Docker'ın havuzunu sabitle, WireGuard'ın `AllowedIPs`'ini daralt, rotaları kalıcı yap. ## Kısa cevap Bu bir "rota bug'ı" değil, bir planlama sorunu: WireGuard ayağa kalkınca aynı `172.x`/`10.x` aralığına oturan iki ağ var ve çekirdek hangisini routing yapacağını şaşırıyor. Docker'ın ağ katmanını kendi başına nasıl kurduğunu [Docker kurulum ve kullanım yazısında](/tr/blog/docker-sanallastirma-teknolojisi-kurulum-kullanim/) anlatmıştım; çakışma da tam oradan, otomatik atanan köprü ağından çıkıyor. ## Neden 1. **Docker yerleşik havuzlardan seçtikçe çakışma ihtimali sürer.** Otomatik ağ atama, önceden tanımlı havuzlardan (`172.17.0.0/16`'dan `192.168.0.0/16`'ya) sizin adınıza bir aralık alır. Docker, host'ta kullanımda olan önekleri atlamaya çalışır; ama sonradan ayağa kaldıracağınız bir subnet'ten uzak duracağının garantisi yoktur. 2. **Geniş `AllowedIPs` default rotayı ele geçirir.** `0.0.0.0/0` verirseniz WireGuard host'un tüm trafiğini tünele çeker (route hijack) ve sorun "çakışma"dan "her şey tünelden akıyor"a döner. ## Ne yapmalı 1. **Docker'ın havuzunu sabitle.** `daemon.json` içinde `default-address-pools` ile Docker'ın otomatik ağ atadığı bloğu, WireGuard subnet'iyle **asla** çakışamayacak bir aralığa pinle. 2. **WireGuard'a dar `AllowedIPs` ver.** `AllowedIPs` yalnızca DB subnet'i olsun. Daraltınca çekirdek sadece DB trafiğini `wg0`'a yollar. 3. **Rotaları kalıcı yap.** `ip route add` ad-hoc komutları reboot'ta uçar. Rotaları `systemd-networkd`/netplan ile tanımla ki yeniden başlatmada otomatik gelsin. 4. **DB kutusunu sadece `wg0` üzerinden inbound tut.** Veritabanını dünyaya NAT'lama; yalnızca tünelden gelen bağlantıyı kabul etsin. 5. **Doğrula.** `ip route get ` ile paketin gerçekten `wg0`'dan çıktığını, `wg show` ile handshake'in canlı olduğunu gör. Tahminle değil, bu iki komutla teyit et. **Sonuç:** Çakışmaları runtime'da kovalamayı bırak; bu bir planlama problemi. Ben olsam her arayüz için (`ens192` / `docker0` / `wg0`) belgelenmiş, çakışmayan bir subnet planı yazardım. Docker havuzunu pinle, `AllowedIPs`'i daralt, rotaları systemd ile kalıcı kıl, DB'ye sadece tünelden eriş. Adresleme planı netse routing kendiliğinden stabil olur. ## LLM sağlayıcısını (Ollama → Bedrock/OpenAI) nasıl soyutlamalıyım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/ollama-dan-uretimde-bedrock-openai-a-gecerken-llm-soyutlamasi/ Kısa cevap: Sağlayıcının etrafına değil, **yeteneğin** etrafına dar bir port/adapter (Strategy) kurun. Küçük bir arayüz + config'le seçilen gerçek implementasyonlar; daha fazlası değil. ## Kısa cevap Sizin asıl tehlikeniz aşırı soyutlama: insanlar bunu jenerik bir "AI framework"e çevirmeye çalışıp aylarını yiyor. İhtiyacınız olan tek bir ince arayüz. Laravel tarafında bu arayüzün hangi implementasyona bağlanacağını seçen mekanizma zaten elinizde — [servis sağlayıcı ve container yazısında](/tr/blog/laravelde-servis-saglayici-ve-containeri-dogru-kullanmak/) anlattığım binding tam olarak bu iş içindir. ## Neden 1. **Vendor'ı değil yeteneği soyutlamak arayüzü küçük tutar.** `complete(prompt, opts): Result` gibi minik bir kontrat yeter; Ollama, Bedrock ve OpenAI bunun üç implementasyonu olur. Sağlayıcıya özel parametreleri core mantığa sızdırdığınız anda soyutlama işlevini kaybeder. 2. **Farklar iş mantığında değil konfigürasyonda yaşamalı.** "Lokalde küçük, üretimde büyük context penceresi" gibi ayrımlar `if` bloğuna dönüşürse, her yeni sağlayıcı kod tabanına yeni bir dallanma ekler. ## Ne yapmalı 1. **Arayüzü yetenek üzerinden tanımlayıp seçimi config'e bırakın.** Hangi implementasyonun çalışacağını `config`/`env` seçsin; çağıran taraf hangi sağlayıcıyla konuştuğunu bilmesin. 2. **Açık timeout + ucuz fallback koyun.** Üretimde sağlayıcı yavaşlar veya rate-limit yer; her çağrıya net bir timeout verin ve gerektiğinde ucuz/yerel bir fallback'e düşün. 3. **Token bütçesini kenarda yönetin.** Her modelin context penceresi farklı; girdiyi modele sığacak şekilde kenarda truncate/özetleyin. 4. **Streaming'i aynı kontratın arkasına koyun.** İhtiyaç varsa opsiyonel olarak aynı arayüzde tutun; çağıran taraf sağlayıcının stream API'sını bilmesin. 5. **Golden-prompt test seti tutun.** Aynı prompt setini her sağlayıcıya karşı çalıştırın; sağlayıcı değiştirdiğinizde neyin bozulduğunu bu yakalar. **Sonuç:** Ben olsam tek bir küçük arayüz, üç gerçek implementasyon ve config-tabanlı seçim kurardım — fazlası YAGNI. Yerel Ollama dev maliyeti ve hızlı iterasyon içindir; üretimdeki context penceresi, gecikme, rate-limit ve maliyet farklarını **konfigürasyon** olarak ele alın, iş mantığına dallanma olarak değil. Soyutlama dar oldukça sağlam olur. ## Yerel diskteki dosyaları S3'e taşırken EFS ara çözüm olur mu? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/yerel-diskten-s3-e-gecerken-efs-ara-cozum-mu/ Kısa cevap: EFS'i **hedef** olarak seçme — tuzak. Asıl çözüm bir object-storage adaptörü; Laravel'de zaten elindeki Flysystem ile bu çok kısa bir iş. ## Kısa cevap Sizin engeliniz "yerel disk" değil aslında, "paylaşılan mount varsayımı". Kod bir ham path bekliyor; ölçeklenince o path düğümler arasında paylaşılmıyor. Bu varsayımın nereden geldiğini görmek için [Laravel'de dosya yükleme ve görsel işleme yazısındaki](/tr/blog/laravel-ile-dosya-yukleme-ve-gorsel-isleme/) klasik akışa bakmak yeter: her adım diskin orada olduğunu varsayar. ## Neden 1. **EFS hedef olarak tuzaktır.** "Yerel disk" engelini kaldırır ama sizi bir ağ dosya sisteminde tutar: daha kötü gecikme, daha yüksek maliyet ve **aynı coupling** (kod hâlâ paylaşılan mount sanıyor). EFS'i yalnızca koda gerçekten dokunamadığınız kısa ömürlü bir köprü olarak kullanın, varış noktası olarak değil. 2. **Doğru hamle object-storage adaptörüdür.** Laravel'de Flysystem zaten var; disk'i `s3` yapıp her okuma/yazmayı ham path yerine `Storage::disk()` üzerinden geçirdiğinizde iş mantığınız "dosya nerede" bilmez. Bu can sıkıcı derecede kanıtlanmış (boring) ve tam da bu yüzden doğru. ## Ne yapmalı 1. **Disk'i `s3` yapın, erişimi soyutlamanın arkasına alın.** Çağrı yerlerini tek tek taşımak yerine tek bir disk konfigürasyonuyla devredin. Dokunduğunuz yüzey ne kadar küçükse risk o kadar az. 2. **İmaj manipülasyonunun paylaşılan mount varsayımını kırın.** Dosyayı geçici bir `tmp` dosyasına çekip işleyin, ya da bir worker + presigned URL ile yapın. Girdiyi object storage'dan alın, sonucu oraya yazın. 3. **Geçişi dual-read ile yapın.** Bir süre **önce S3'e bakın, yoksa yerel diske düşün** mantığıyla çalışın; arka planda mevcut görselleri backfill edin. Hepsi taşındığında okumayı tamamen S3'e çevirin ve yerel diski devreden çıkarın. **Sonuç:** EFS'e para ve gecikme yatırmayın. Ben olsam doğrudan Flysystem `s3` diskine geçer, her şeyi `Storage::disk()` üzerinden okur/yazar, manipülasyonu temp dosya/worker ile çözerdim. Dual-read ile sıfır kesintiyle geçer, backfill bitince yerel diski kaldırırdım. Sıkıcı ama bir kere kurup unutacağınız türden bir mimari. ## Laravel ve Go servisleri arasında mesaj şeması için Protobuf mu, JSON Schema mı? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/laravel-ve-go-arasinda-mesaj-semasi-protobuf-mu-json-schema-mi/ Kısa cevap: Asıl çözüm hangi wire format'ı seçtiğiniz değil, **contract-first** çalışmanız. Şemayı tek doğruluk kaynağı yapıp iki tarafı da ona zorlayınca sorun büyük ölçüde biter. ## Kısa cevap Yaşadığınız sıkıntı format sorunu değil, sözleşme sorunu: Go tarafı tipi dayatıyor, PHP tarafı gevşek array'le "idare ediyor" ve veri tam sınırda kayboluyor. Şemayı önce yazıp iki tarafı da ona bağlama yaklaşımının API tarafındaki karşılığını [sözleşme öncelikli API tasarımı yazısında](/tr/blog/api-tasarimini-sozlesme-oncelikli-contract-first-yurutmek/) anlatmıştım; buradaki mesele onun kuyruk üzerindeki hâli. ## Neden 1. **Protobuf tip + performans + codegen istiyorsanız doğru araç.** `.proto` dosyası tek doğruluk kaynağıdır; hem PHP hem Go için kod üretirsiniz, alan adlarını elle senkronlamazsınız. Geriye uyumluluğun tek kuralı var: alan numaralarını **asla** yeniden numaralandırmayın veya geri kullanmayın; sadece yeni `optional` alan **ekleyin**. Binary olduğu için hafif ve hızlıdır. 2. **JSON Schema JSON-native ve debug edilebilir kalır.** Mesaj insan-okur kalır, şemayı iki uçta da validate edersiniz. Operasyonu hafiftir ama garantisi zayıftır — kimse şemayı uygulamazsa yine eski noktaya düşersiniz. Yoğun trafik için Protobuf daha sağlam tercih. 3. **Asıl hata noktanız gevşek PHP array'i.** Mesajı uygulamanın derinliğinde değil, tam sınırda tipli bir DTO'ya çevirmediğiniz sürece hangi format'ı seçtiğiniz sonucu değiştirmez. ## Ne yapmalı 1. **Mesajı sınırda validate edip DTO'ya çevirin.** Geçersizse oraya, kuyruğa girmeden reddedin. Array'i app içine sızdırırsanız hatayı 10 katman sonra yakalarsınız. 2. **Bilinen tuzakları baştan isimlendirin.** Şema kayması (drift), `optional` ile `required` karışması, Go'da "boş değer" (`0`/`""`) ile "alan hiç yok" semantiğinin aynı görünmesi, versiyonsuz breaking change — hepsi klasik. Versiyonlamayı baştan kurun. 3. **Para ve zamanı doğru serileştirin.** Parayı `float` değil **integer minor unit** (kuruş) olarak taşıyın; zaman damgalarını **RFC3339/UTC** verin. Bu ikisi en sık sessizce bozulan alanlardır. **Sonuç:** Laravel↔Go yoğun asenkron trafikte ben olsam **Protobuf + bir schema registry** seçerdim — tipi gerçekten dayatır, codegen elle senkron derdini bitirir, performansı bedava gelir. JSON Schema'yı yalnızca trafiğin hafif ve insan-debug ihtiyacının yüksek olduğu yerde tutun. Ama hangisini seçerseniz seçin, kuralı değiştirmeyin: gevşek array'i sınırda öldürün, tipli DTO'ya çevirin, breaking change'i versiyonlayın. ## Logları OpenSearch'e gönderirken Fluent Bit ile arasına Kafka/Redis buffer koymalı mıyım? - Kaynak: https://muhammetsafak.com/tr/sor-bakalim/loglari-opensearch-e-gonderirken-araya-kafka-buffer-koymali-miyim/ Kısa cevap: Evet, araya bir buffer koymak doğru hamle — ama Redis listesi ile Kafka arasındaki seçim, kabul ettiğin garanti seviyesine bağlı. ## Kısa cevap Yaşadığın şey klasik **backpressure**: OpenSearch'in indeksleme hızı üretim hızının altına düşünce zincir geriye doğru tıkanıyor, en sonunda uygulama konteynerinin stdout'u bloke oluyor. Çözüm, üretimi (uygulama) tüketimden (OpenSearch) ayıran dayanıklı bir ara katman. ## Neden 1. **Redis listesi ucuz ama riskli.** Bellekte tutar; `RPUSH`/`LPOP` basit ama AOF kapalıysa Redis temiz kapanmadığında son yazılan veriyi kaybedebilirsin ve liste şişerse Redis'in RAM'ını doldurur. Kaybı dert olmayan loglar için buffer olarak iş görür, finansal/audit logu için değil. 2. **Kafka 40k/s'in doğal sahası.** Disk tabanlı, retention'lı, replay edilebilir, consumer group'larıyla yatay tüketim. `at-least-once`'ı doğal verir. Bedeli operasyonel: kümeyi ayağa kaldırıp beslemek gerekir. Hacmin kalıcıysa doğru araç odur. ## Ne yapmalı 1. **Önce Fluent Bit'in kendi disk buffer'ını aç.** Input tarafında `storage.type filesystem`, output tarafında `storage.total_limit_size` ile Fluent Bit, OpenSearch yavaşladığında logu RAM yerine diske yazar; uygulamaya doğru basınç gitmez. Harici bir kuyruk eklemeden önce ölçülmesi gereken ilk şey bu — çoğu zaman tek ihtiyacın olan budur. 2. **Hacim kalıcıysa Kafka'ya geç.** Buffer'ı bir kuyruğa taşıma kararını 40k/s'in geçici bir tepe mi yoksa yeni taban mı olduğuna bakarak ver; geçici tepeler için disk buffer yeter. 3. **at-least-once'ı consumer'da idempotent kıl.** OpenSearch'e yazarken her belgeye deterministik bir `_id` ver (örn. kaynak+offset hash'i). Aynı mesaj iki kez gelse de aynı `_id` üstüne yazar; mükerrer kayıt oluşmaz. Kuyruk tarafının worker ve yeniden deneme mekaniği için [Laravel queue ve Supervisor yazısına](/tr/blog/laravel-queue-ve-supervisor-ile-asenkron-islemler/) bakabilirsin. **Sonuç:** Önce Fluent Bit filesystem buffer + OpenSearch tarafında bulk ayarı ve ISM (rollover) ile indekslemeyi rahatlat. Bu yetmiyorsa Kafka'ya geç — Redis'i yalnızca kaybını göze alabildiğin loglarda kullan. Ve altın kural: uygulama konteynerini log yazarken asla bloklatma. Log fire-and-forget olmalı; kritik veriyi (ödeme, audit) log hattı üzerinden taşıma, onu ayrı ve garantili bir yoldan yürüt. # Labs ## Bir oturumda biten 4X: kapsamı kısıt seçti - Kaynak: https://muhammetsafak.com/tr/labs/regnarium-lab/ Strateji oyunları benim on binlerce saat gömdüğüm tek tür. Ama o türün alışılmış oturumu bir akşamı yiyor, ve elinde bir akşam olmayan oyuncu için kimse bir şey yapmıyordu. ## Tek oturum, kapanan bir el Denenen şey, 4X'in dört fiilini — keşfet, genişle, üret, alt et — yarım saate sığdırmaktı. Bunun tek gerçek yolu, oyunun kendini bitirmesi: el azami altmış turda kapanıyor, fetih tamamlanmadıysa kazananı puan belirliyor. Süre bir uyarı değil, kuralın parçası. Kapsam da aynı kısıttan türedi. Tek kişilik bir iş büyük yapımların içerik hacmiyle yarışamaz; yarışmaya çalışırsa hiçbir sistemi bitiremez. O yüzden liste bilinçli olarak kısa tutuldu ve her kaydın tahtada ayırt edilebilir bir işi var — iş görmeyen bir birim eklemek, listeyi uzatıp oyunu zenginleştirmiyor. ## Sürpriz yok, pişmanlık var İkinci karar savaşta. Zar atan bir savaş sistemi, kaybedilen her eli sisteme yıkma imkânı verir; oyuncu kendi kararını sorgulamak yerine talihi suçlar. Burada saldırıdan önce gördüğünüz önizleme hesabın kendisi: aynı sayı, aynı fonksiyon. Ayrı bir "tahmin" fonksiyonu yazmak, önizleme ile sonucun ayrışabileceği tek kapıyı açardı; o kapı hiç açılmadı. Rastgelelik tamamen yok değil — harita tohumlu bir üreticiden geliyor — ama oynanışın içine sızmıyor. Aynı tohum ve aynı komut dizisi her zaman aynı eli veriyor, rakibin kararları dâhil. ## Sunucusuz olmanın getirdiği kayıt biçimi Oyunun çevrimdışı ve hesapsız çalışması, kaydın nerede duracağını da belirledi. Kayıt tam bir durum kopyası değil: tohum, kurulum ve o ana kadar verilen komutların listesi. Yükleme bu listeyi yeniden oynatarak durumu kuruyor — ve geri alma da aynı mekanizmayı kullanıyor, kaydı bir komut kısaltıp eli baştan oynatıyor. Ayrı bir geri-alma durumu hiç tutulmuyor. Bunun bir bedeli var ve açıkça ödendi: kurallar değişince eski kayıt yeniden oynatıldığında başka bir el üretiyor, dolayısıyla bir göç yolu yazılamıyor. Kayıt sürümü artıyor ve eski kayıt sessizce atılıyor. Tek oturumluk bir oyunda bu kabul edilebilir bir kayıp; uzun kampanyalı bir oyunda olmazdı. ## Kısıtı makineye söyletmek Mekanikleri ve mimariyi ben tasarladım, kodun büyük kısmını yapay zekâ yazdı; benim işim mimarinin korunmasıydı. Ama bir kuralı gözle korumak ölçeklenmiyor, o yüzden kısıtların çoğu makineye devredildi: kural çekirdeği tarayıcıya, saate ve ağa erişemiyor — bunlar lint kapısında yasaklı — ve kapının gerçekten tuttuğu, kasıtlı olarak hatalı yazılmış dosyaların denetlenmesiyle ayrıca sınanıyor. Oyun yayında ve oynanabilir durumda; geliştirme sürüyor, açık maddeler kendi kayıtlarında duruyor. Ürün tarafının ayrıntısı portfolyoda. ## Zar atmayan bir bulmaca kurmak: SilkWard'da her hamle aynı sonucu üretir - Kaynak: https://muhammetsafak.com/tr/labs/silkward-lab/ Sıra tabanlı bulmaca benim için Hitman GO'nun bıraktığı boşluğu dolduruyordu: az sayıda kural, sıfır şans, sonucu düşünerek üretme hissi. SilkWard'ı bu boşluğa kurdum — düğüm-çizgi bir grafta ilerleyen, ağırlıkla sözsüz anlatılan bir bulmaca. ## Şans yok, refleks yok, sürpriz kural yok Bu üç ilke oyunun tasarım felsefesini özetliyor. Düğüm-çizgi grafında attığınız her adım, düşmanların da aynı deterministik kuralla ilerlemesini tetikliyor; bir bölümü kaybederseniz bunun sebebi zarın kötü gelmesi değil, hamlenin kendisi. 36 bölüm + 6 gizli bölüm üç perdeye yayılıyor. ## Tek çekirdek, iki platform Kural motoru sıfır bağımlılıkla yazıldı: ne DOM'u ne React Native'i tanıyor, yalnız saf TypeScript. Bu saflık, web (Next.js tabanlı PWA) ile mobil (Expo) istemcisinin aynı davranışı iki kez yazmadan paylaşmasını sağlıyor — kural motoru ve çözücü ortak bir çekirdek pakette, bölüm verisi ayrı bir veri paketinde, arayüz metinleri ise ayrı bir dil paketinde yaşıyor; platforma özgü olan yalnızca arayüz katmanı. ## Bölüm nasıl kalibre ediliyor Yeni bir bölüm tek dosyayı yazmakla bitmiyor: önce düzenleyici araçla tasarlanıyor, sonra veri paketine bir seviye tanımı olarak aktarılıyor. Bölümün asgari hamle sayısı elle tahmin edilmiyor — solver-cli aracının BFS ile bulduğu sayı yazılıyor, ardından bölüm tasarımı bu sayıya göre kalibre ediliyor. Bir doğrulama komutu bu sayının her değişiklikte hâlâ doğru olduğunu yeniden sınıyor, böylece "par" değeri zamanla sessizce güncelliğini yitirmiyor. Kural motorunun kendisi de aynı disipline tabi: çekirdek pakette rastgele sayı üreteci ya da sistem saatine erişim yasak — determinizm bir yorum satırıyla değil, derleme zincirindeki bir kısıtla korunuyor. Devreye alınabilecek bir yapay zekâ görsel hattı da çekirdek koddan ayrı, yalnızca geliştirme aracı dizininde duruyor; denendi, ama çıktısı onay sürecinden geçmediği için oyuna hiç girmedi — oynanan sürümün görselleri tamamen prosedürel üretildi, oyunun runtime'ı hiçbir yapay zekâ istemcisi taşımıyor. ## Ağa neredeyse hiç çıkmayan bir oyun İlk yükleme SilkWard'ın kabuğunu (arayüz, kod, görseller) önbelleğe alıyor; o andan sonra oynanış hiçbir ağ isteği yapmıyor. Tek istisna sesler: 26 ses dosyası baştan indirilmiyor, her biri ilk çalındığı anda bir kez indirilip kalıcı önbelleğe yazılıyor — bu yüzden uçak modu güvencesi ikinci oturumdan itibaren geçerli, ilk oturumda ağ yoksa oyun sessiz çalışıyor, çökmüyor. Bu sonradan eklenen bir "çevrimdışı mod" değil, en baştan konan bir kısıt: çekirdek ve uygulama katmanında `fetch` kullanımı lint kurallarıyla engelleniyor, `XMLHttpRequest` kullanımı ise ayrı bir kaynak tarama testiyle yakalanıyor — kısıtın kendisi koda bağlı, gözle denetlenen bir söz değil. Kayıt burada durduruldu: oyun bugün silkward.com üzerinden oynanabilir durumda, ama üzerinde yeni bir geliştirme yapılmıyor. ## Yazdığınız her komut gerçekten ayrıştırılıyor: SysAdmin Crisis'te Tab tamamlama da man sayfası da çalışıyor - Kaynak: https://muhammetsafak.com/tr/labs/sysadmincrisis-lab/ SysAdmin Crisis'in komut satırı görünüşte kalmasın istedim: yazdığınız her komut gerçekten ayrıştırılıyor ve simüle edilen dünyada gerçek bir etki üretiyor — gece vardiyasının gerilimi ancak sözdizimi kozmetik değil de gerçekten çalışıyorsa hissediliyor. ## Sözdizimi Unix'ten, ayrıştırma gerçek Sözdizimi oyunlaştırılmış bir Unix'tir — gerçek komutların yaygın bayrakları da kabul ediliyor. `top` sunucu yükünü gerçekten okuyor, `logs` gerçekten olay geçmişini listeliyor, `block_ip` gerçekten bir saldırı kaynağını kesiyor; toplam yirmi dokuz komut var, Tab tamamlama çalışıyor, `man ` yerleşik bir kılavuz açıyor. Vardiya yedi olay tipiyle (DDoS'tan başlayıp diğer altı senaryoya) kuruluyor; öğretici bölüm bunlardan ikisini — bellek sızıntısı ve DDoS'u — yavaşlatılmış tempoda, aynı komutlarla tanıtıyor. ## Tek motor, iki platform Web (Next.js tabanlı PWA) ve mobil (Expo) sürümü aynı oyun motorunu paylaşıyor; mobilde terminal ekranı WebView içine gömülü bir web sayfası değil, native bileşenlerle çiziliyor. On geceli kampanya modu, artan yük ve itibar-bütçe döngüsüyle aynı motor üzerinde çalışıyor; sonsuz mod ve zorluk seçenekleri de bu paylaşılan çekirdeğin üstüne kuruldu. Vardiya sonunda harf notuyla (S/A/B/C) özetlenen bir skor ve yerel bir en-iyi-onlu liste tutuluyor; bitmemiş bir kayıt da yerelde kalıyor, dışa ve içe aktarılabilir, sürüm numarası taşıyan bir kayıt biçiminde. ## Geliştirme disiplini kod zincirine bağlı Kod incelemesine bırakılmayan kısımlar var: her push'ta bağımlılık denetimi, lint, tip denetimi, test paketi, kapsam ölçümü, mobil test paketi ve web derlemesi CI'da otomatik çalışıyor. Uçtan uca test paketi ve erişilebilirlik denetimi CI'da değil, geliştiricinin kendi ortamında koşuyor — erişilebilirlik denetiminde ciddi ya da kritik seviyede bulgu kalmıyor. ## Sıfır veri toplama koda bağlı Hesap yok, analitik yok, reklam yok — oyun çalışırken ilk sayfa yüklemesi ve web sürümünün service worker güncellemesi dışında hiçbir ağ isteği yapmıyor. Bu bir gizlilik metni değil, derleme zincirine bağlı bir kısıt: oyun paketlerinde `fetch`/`XMLHttpRequest` kullanımı lint kurallarıyla, bağımlılıklara sızan analitik/reklam istemcileri ise ayrı bir bağımlılık denetim betiğiyle engelleniyor. Kayıt burada durduruldu: v1.0.0 olarak yayına hazırlandı, bugün sysadmincrisis.com üzerinden oynanabilir durumda, ama üzerinde yeni bir geliştirme yapılmıyor. ## Üretim verisine erişim: parolayı paylaşmak tek seçenek değil - Kaynak: https://muhammetsafak.com/tr/labs/queryproxy-lab/ Bir hata üretimde görünüyorsa teşhisi de orada. Ama üretim parolasını dağıtmak bir çözüm değil; onu tek kişide tutmak da, o kişi izne çıktığı anda çözüm olmaktan çıkıyor. ## Parolayı paylaşmadan veriye bakmak Denenen şey şuydu: geliştirici üretim verisine bakabilsin, ama kimlik bilgisini hiç görmesin. Bağlantı künyesi uygulamada şifreli duruyor, sorguyu çalıştıran da geliştiricinin makinesi değil, portalın kendisi. Geliştiricinin elinde kalan tek şey yazdığı SQL. Bunun bedeli, kabul edilen bir dolaylılık: sorgu artık anında çalışmıyor. Karşılığında üretim parolası hiçbir zaman bir insanın eline geçmiyor ve bir kişinin takvimi ekibin teşhis hızını belirlemiyor. ## Sorgu okunuyor, aranmıyor Portalın kapısı desen eşleştirmesiyle kurulsaydı ilk gün aşılırdı — araya yorum sıkıştırmak, dizeyi bölmek, alt sorguya saklanmak yeterdi. Bunun yerine gelen SQL ayrıştırılıyor: ifadenin ne olduğu metinden değil, çözümlenmiş yapısından okunuyor. Pratik sonucu şu: `WHERE`'i olmayan bir `UPDATE` ya da `DELETE` geri çevriliyor, limitsiz bir `SELECT` varsayılan bir satır sınırıyla dönüyor, veritabanı ya da kullanıcı yönetimine dokunan ifadeler koşulsuz reddediliyor. Yorum ve dize sabitleri normalize edildiği için `DROP/**/DATABASE` yazmak kapıyı açmıyor. Kapının anlamadığı bir limit ifadesi ise kırpılmıyor, **reddediliyor** — anlaşılmayan bir sınır, zorlanamayan bir sınırdır. Ayrıca çalıştırıcı tarafında SQL ne derse desin geçerli olan mutlak bir satır tavanı duruyor; kapıdan bir şekilde sızan sorgu bile sonuç deposunu dolduramıyor. ## Onay tek servisten geçiyor Onay üç kanaldan geliyor — web arayüzü, Slack, Teams — ama üçü de aynı servise iniyor. Kural tek yerde yazılı olduğu için kanal eklemek kuralı çoğaltmıyor: bir DBA'nın kendi isteğini onaylaması her kanalda kapalı — sistem admini aşabiliyor ve aşma denetim kaydına düşüyor — iki kanal aynı isteği aynı anda onaylarsa koşullu güncelleme ikincisini düşürüyor. Sohbet geri çağrılarında eylemi yapan kişi, isteğin beyan ettiği adresten değil yöneticinin kurduğu eşlemeden çözülüyor — paylaşılan bir imza anahtarı, başkası adına onay vermeye yetmiyor. ## Maskeleme sonradan değil, yazarken Son karar maskelemenin nerede durduğuyla ilgili. Sonucu önce kaydedip gösterirken maskelemek kolay yoldu; o tasarımda maskesiz veri diske bir kez yazılıyor ve oradan sonrası temenniye kalıyor. Bunun yerine sonuç akışı satır satır okunuyor ve her satır **dosyaya yazılmadan önce** maskeleniyor. Sonuç deposuna maskesiz bir hücre hiç girmiyor; indirme ve görüntüleme de zaten maskelenmiş dosyayı okuyor. Aynı disiplin kuralların kendisinde de var: bir düzenli ifade kaydedilirken uzun bir prob dizesiyle sınanıyor, çalışma anında değerlendirilemezse değer açıkta bırakılmıyor, maskeleniyor. Labs aşaması üç günde kapandı — ihtiyaç o kadar netti. QueryProxy artık kendi alan adı ve sürümleri olan bir ürün; ayrıntısı portfolyoda. ## Onlarca projenin dokümanını tek sunucudan servis etmek - Kaynak: https://muhammetsafak.com/tr/labs/contextator-lab/ Bir kodlama ajanına projenin dokümanını vermenin yolu MCP'den geçiyor. Zor olan protokol değil; onlarca projede aynı şeyi tekrar tekrar kurmak. ## Proje başına sunucu, proje sayısınca bakım İlk üç projede her birine ayrı bir MCP sunucusu kurmak makul görünüyordu. Sonra sunucular birbirinden ayrı yaşamaya başladı: hangisi hangi dizini indeksliyordu, hangisi en son ne zaman senkronlanmıştı, hangisinin kendi chunk'lama kodu vardı. Her yeni proje aynı chunker'ı, aynı embedding döngüsünü ve aynı artımlı indeksleyiciyi bir kez daha yazmak demekti — sonra da hepsini sürdürmek. Contextator bu tekrarı tek bir kuruluma indiriyor. Bir proje, kaynaklarının toplamıdır: sunucuda bağlı bir klasör, bir git deposu (ya da onun tek bir alt dizini), yüklenmiş bir arşiv, bir Obsidian kasası veya bir Notion çalışma alanı. Hepsi tek bir uçta birleşiyor ve her kaynak kendi adının altına monte ediliyor, böylece bir doküman `handbook/install.md` diye okunuyor. ## Yalıtım bir ayar değil, adresin kendisi Her proje kendi URL'ini alıyor: `/mcp/`. `/mcp/billing`'e bağlı bir istemci `/mcp/mobile`'ı hiç görmüyor. Bunun alternatifi tek bir uç açıp isteğe bir `project` parametresi koymaktı; o tasarımda yalıtım, ajanın doğru parametreyi göndermesine bağlı kalırdı. Bedeli açık ve kabul edilmiş durumda: **projeler arası arama yok.** Tek soruda iki projeye bakılamıyor, çünkü bakılabilseydi her arama sessizce her projeye yayılırdı. ## Anahtarsız çalışması bir koşuldu Embedding'ler varsayılan olarak makinenin CPU'sunda üretiliyor. Bunun sebebi maliyetten önce şu: sürekli sorgulanmak üzere kurulmuş bir aracın çalışması için bir API anahtarı istemesi, "kendi sunucunda çalışır" vaadini baştan bozar. Birkaç ortam değişkeniyle OpenAI embedding'lerine geçmek mümkün, ama varsayılan yol dışarıya hiç çıkmıyor. Bedeli ilk açılışta ödeniyor: model indiriliyor ve container'ın ayağa kalkması birkaç dakika sürüyor. Sonraki başlangıçlar saniyeler sürüyor. ## Vektörün temsil etmediği şey Bir cümle modelinin `HALYARD_DISPATCH_TIMEOUT` diye bir şeyden haberi yok — yalnız çevresindeki kelimelerden haberi var. Dokümantasyonda aranan şeyin önemli bir kısmı ise tam olarak bu: bir sabit adı, bir hata kodu, bir bayrak. O yüzden her chunk hem vektör hem tam metin dizininde duruyor ve bir arama iki listeyi birden alıp sıralamalarını birleştiriyor. Skorlar değil sıralamalar birleştiriliyor, çünkü skorların ağırlıklandırılması embedding modeli her değiştiğinde yeniden öğrenilmek zorunda kalırdı. Bunun görünür bedeli şu: sonuçtaki benzerlik skoru artık o sonucun neden orada olduğunu açıklamıyor. ## Ölçülüp bırakılan sınır Diller arası arama çalışmıyor: Türkçe sorulan bir sorunun cevabı İngilizce bir sayfada duruyorsa çoğunlukla bulunmuyor. Bu bir eksik değil, kapatılmış bir karar. İki çözüm denendi: önce hibrit arama, sonra çok dilli bir yeniden sıralama modeli. İkisi de ölçüldü, ikisi de yetmedi ve sonuçlar depoda duruyor. Gerçek çözüm ikinci bir çeviri-eğitimli model, yani dört kat indirme ve her kurulumda tam yeniden indeksleme; bir doküman sunucusu için bu fiyat ödenmedi. Kayıt bu yüzden "yapılacaklar" listesinde değil, dokümantasyonda duruyor. Ürünün künyesi portfolyoda. ## Uygulama sağlayıcı anahtarını hiç görmeden RAG'li istek göndersin - Kaynak: https://muhammetsafak.com/tr/labs/ragmux-lab/ Kendi dokümanıyla çalışan bir LLM özelliğinde "RAG kullanacağım" kararı işin kolay yarısı. Zor yarısı, o retrieval'ı üretimde kimin taşıyacağı. ## Kararın ardından kalanlar Doküman gömülü, chunk'lar veritabanında, arama çalışıyor. Geriye üç soru kalıyor: sağlayıcı anahtarı nerede duruyor, hangi uygulama hangi modeli kullanıyor, harcama nerede kesiliyor. Bu üçü retrieval'ın parçası değil ve üçünü de her uygulamanın kendi içinde çözmesi, aynı kodun uygulama sayısınca kopyalanması demek. Ragmux bu üçünü uygulamanın dışına, aradaki geçide taşıyor. Uygulama tek bir OpenAI uyumlu uca konuşuyor; hangi sağlayıcıya gideceğini, hangi sistem prompt'unu taşıyacağını ve hangi doküman havuzundan bağlam alacağını projenin ayarı biliyor. ## İstemcinin gönderdiği model adı hiçbir şey seçmiyor İsteğin `model` alanı yönlendirme yapmıyor. Hangi modele gidileceğini projenin bağlantısı belirliyor; istemcinin yazdığı değer yalnızca cevapta ve her akış parçasında olduğu gibi geri yankılanıyor, böylece o alanı karşılaştıran SDK'lar kırılmıyor. Bunun anlamı şu: sağlayıcı kimlik bilgisi sunucuda, şifreli duruyor ve hiçbir arayüzde geri gösterilmiyor. Uygulamanın elinde yalnız projenin kendi anahtarı var. Bedeli de açık — istemci artık model seçemiyor; model seçimi bir dağıtım kararı değil, bir yapılandırma kararı hâline geliyor. ## Bütçe parayla değil token'la kesiliyor Her isteğin tahmini maliyeti hesaplanıyor ve panelde duruyor, ama **maliyet bilgidir**: hiçbir istek bir para eşiğine ulaşıldığı için reddedilmiyor. Harcamayı sınırlamanın yolu token sınırlamak — proje başına dakikalık istek ve token tavanı, günlük ve aylık token bütçesi. Burada saklanmayan bir sınır var: token sayısı ancak sağlayıcı cevapladıktan sonra kesinleşiyor, o yüzden tek bir istek bütçeyi kendi boyutu kadar aşabiliyor ve reddedilen, bir sonraki istek oluyor. ## Tek PostgreSQL, ikinci bir depo yok Kullanıcılar, bağlantılar, projeler, dokümanlar, chunk'lar, vektörler, istek kayıtları ve kullanım sayaçları — hepsi pgvector'lü tek bir PostgreSQL'de. Redis yok, ayrı bir vektör veritabanı yok. Sayaçlar süreç değişkeni değil satır olduğu için aynı veritabanına bakan birkaç kopya aynı bütçeyi paylaşabiliyor. ## Kendi kuralını çiğnediğini bulan gözden geçirme Kayıtlı beş kararın üçü bir yayın öncesi gözden geçirmeden doğdu, ikisi de kodla dokümanın çeliştiği yerlerden: rol matrisi `viewer`'ı salt-okunur anlatıyordu ama kod ona kendi gateway anahtarını üretme hakkı veriyordu; bir fiyat tablosu satırı, ücretli bir uca bakan bağlantıyı "0,00 $" diye raporluyordu. Beşinci karar ise aynı gün yumuşatıldı. Görsel indirme tavanına kiracı başına sert bir pay konmuştu; ölçüm, tek projeli kurulumda — yani en yaygın hâlde — tavanın yarısının kalıcı olarak boş kaldığını gösterdi. Pay o gün esnetildi: kimse beklemiyorsa tek kiracı tavanın tamamını kullanabiliyor. Ürünün künyesi portfolyoda. ## academia.sh: teori ile sahadan gelen problem aynı derste durabilir mi? - Kaynak: https://muhammetsafak.com/tr/labs/academia-lab/ Video kurs platformlarının aksine birincil ortam yazı: okunabilir, aranabilir, alıntılanabilir, tarayıcıda kalıcı. academia.sh bilgiyi bir ders kitabı gibi sunuyor — müfredat, kurs, konu, ders — ve her ders kayıt olmadan, ücretsiz okunuyor. İlk içerik bilgisayar bilimleri, ama model baştan çok disiplinli: matematik ya da dil bilim eklemek şema değişikliği değil, ders yazmak. Asıl mesele burada değil. Asıl mesele şu: bir ders yalnız teoriyi taşırsa ders notu olur, yalnız sahadan gelen problemi taşırsa blog yazısı. İkisini aynı akışta tutmak — tanımın hemen ardından o tanımın sahada nerede kırıldığını yazmak — neye mal olur? ## Teori ile problemi tek derste tutmak Sahadan gelen problem ayrı bir "alıştırma" varlığı **değil**; dersin kendi metninde, teorinin devamı olarak duruyor. Problemler uydurulmuş örnekler de değil: sektörde gerçekten karşılaşılmış olanlar. Bu tercih ders varlığını tek tutuyor. Ayırsaydım iki ürün çıkardı — bir tarafta okunacak metin, diğer tarafta çözülecek alıştırma listesi — ve bölünme anında ilerleme, arama, ölçme de ikiye bölünürdü. Tek varlıkta kalmanın karşılığı iki yerde görülüyor: **İlerleme.** Yüzde ders bazında tutuluyor, payda "yayınlanmış aktif dersler". Bir dersin teorisini okuyup problemini atlamak diye bir durum yok; ikisi aynı ders. Ölçme de aynı ilkeyi izliyor: quiz teoriyi değil dersin bütününü soruyor. **Arama.** Metin merkezli bir platformda arama süs değil omurga — video platformunda arayamadığın şey burada birincil erişim yolu. Postgres tam metin araması: ağırlıklar başlık A, giriş B, gövde C; sıralama `ts_rank_cd`, parçacık `ts_headline`. `search_vector` bir generated column **değil**, olamıyor da — ama gerekçe `to_tsvector` değil: iki argümanlı biçimi immutable. Sorun sözlüğün satırda `text` olarak durması; `regconfig`'e cast edilmesi gerekiyor ve o cast immutable sayılmıyor, Postgres de ifadeyi generated column'da kabul etmiyor. Vektörü bu yüzden bir trigger üretiyor. ## Okuması ücretsiz, kayıtsız, metin merkezli Çıkan şey açık ve yalnızca işini yapan bir eğitim platformu: dersler ücretsiz ve hesap istemiyor, reklam yok, dikkati içerikten çeken bir katman yok. Kurs sonu sınavı, doğrulanabilir sertifika ve çalışma asistanı Pro katmanda; okuma asla değil. İçerikler de uygulamanın veritabanında doğmuyor — markdown olarak ayrı bir GitHub reposunda duruyor, platform oradan besleniyor. Bu ayrım ileriyi de belirliyor: müfredat, katkıya gerçekten açılabilecek tek biçimde, düz metin olarak tutuluyor. Bugün yazan tek kişiyim; yarın olmak zorunda değil. Yığın sade: Laravel 13, Blade + Livewire, Tailwind, PostgreSQL 16, Redis; yönetim Filament'te. Markdown sunucuda render ediliyor (`league/commonmark` + `tempest/highlight`), çalışma zamanında Node yok. Sertifika tarafında bir karar özellikle dişe dokunur: `holder_name` ve `course_title` düzenleme anında donduruluyor. Sonradan değişen bir isim basılmış belgeyi değiştirmiyor, bir ders işareti geri alınsa bile belge geçerli kalıyor — geri alma bir hata düzeltmesidir, kazanılmış belgeyi iptal etmez. Doğrulama sayfası herkese açık ama `noindex, nofollow`: bir kişinin adı arama sonuçlarında çıkmamalı. Repository katmanı yok. Eloquent zaten o rolü üstleniyor; iş akışı barındıran her şey `Action`, durumsuz yetenekler `Service`. Kalite kapıları Pest + Pint + Larastan level 6. ## Model gerçekten disiplin-bağımsız mı Devam etmesinin sebebi kalan iş listesi değil. Okumanın herkese açık, ücretsiz ve erişilebilir olması gerektiğini düşünüyorum: öğrenmenin önündeki engel bilginin kendi zorluğu olmalı — metnin önündeki ödeme ekranı, kayıt duvarı ya da bir şirketin kapatma kararı değil. Metin merkezli olması da, içeriğin ayrı bir repoda durması da bu vizyonun sonucu: okunabilir, kopyalanabilir, birileri devam ettirmek isterse devam ettirilebilir. Asıl açık soru içerik tarafında ve teknik değil. Şema çok disiplinli, ama bu modelin şartı her dersin sahadan gelen problemi de taşıması. Bilgisayar bilimlerinde bunu yazabiliyorum çünkü o sahada yıllardır çalışıyorum. İkinci bir bilim dalı eklemek migration istemiyor — yazar istiyor. Modelin gerçekten disiplin-bağımsız olup olmadığı ancak o yazar bulununca belli olacak. ## Qurandia: numarası tutmayan iki veri setini metnin parmak iziyle eşleştirmek - Kaynak: https://muhammetsafak.com/tr/labs/qurandia-lab/ Qurandia, Kur'an okumak, anlamak ve öğrenmek isteyen insanlar için açık ve ücretsiz bir bilgi kaynağı olarak kuruldu: meal, kelime-kelime çözümleme, karşılaştırmalı tefsir, sebeb-i nüzul, hadis külliyatı, kök sözlüğü, tematik dizin, arama ve öğrenme akademisi tek çatı altında. İşin ilginç kısmı arayüzde değil, altındaki veride. Bu tür bir platform tek bir kaynağın kopyası değildir; birbirinden habersiz üretilmiş veri setlerini üst üste bindirmek zorundadır. Ve o setler birbirini tanımaz. ## Numaraya değil, metnin parmak izine güvenmek En sert örnek hadis tarafında çıktı. Arapça metin ve İngilizce çeviriler bir veri setinden, Türkçe çeviriler bambaşka bir setten geliyor. İkisi de hadisleri numarayla adresliyor — ama numaralar aynı şeyi saymıyor: biri koleksiyon içi global sıra numarası kullanıyor, diğeri farklı bir numaralandırmayı. Doğrudan eşleştirmede tutarlılık **%0–14** arasında. Yani numaraya güvenmek sessizce yanlış hadise yanlış çeviriyi bağlamak demek. Denenen çözüm numarayı hiç kullanmamak: **Arapça metnin parmak izi**. Metin NFC ile normalize ediliyor, yazım varyantları tek harfe indiriliyor (أ إ آ ٱ → ا, ى → ي, ؤ → و, ئ → ي, ة → ه), sonra `\x{0621}-\x{064A}` aralığı dışında ne varsa — hareke, durak işareti, boşluk, noktalama — söküp atılıyor. Geriye kalan çıplak harf dizisi iki veri setinde de aynı. Eşleşme oranı koleksiyona göre **%91–99**. Aynı disiplin import hattının tamamında geçerli: her komut idempotent (doğal unique anahtar üzerinden upsert), her koşu `import_runs` satırı bırakıyor, testler ağ kullanmıyor — hepsi fixture ile çalışıyor. Ses tarafında da bir sınır çizildi: 6.236 mp3 kendi sunucumuzda barındırılıyor, üçüncü tarafa bağlı değil ve oynatıcı `wire:navigate` gezinmeleri boyunca kesintiye uğramayan kalıcı bir dock olarak duruyor — sayfa değişince tilavet durmuyor. ## Sayılarla bugünkü hâli Sayılarla: 114 sure, 6.236 ayet, üç kaynaktan 18.708 meal, 77.429 kelime ve 1.651 kök, 8.117 tefsir girdisi, 197 sebeb-i nüzul notu (TR+EN), altı koleksiyonda 34.178 hadis. Türkçe hadis çevirisi kapsamı %83 — kalan boşluk kaynak tarafında, bizde değil. Arama Postgres FTS + `pg_trgm` üstünde; Scout ya da Meilisearch yok, çünkü gerekmedi. Üçüncü taraf UI/JS kütüphanesi de yok: dalga formu, grafik, ikon, tarih — hepsi el yapımı SVG ve Alpine. Sitemap ~85 bin URL'e çıkıyor, locale başına tek giriş verecek biçimde index'lenmiş. Kalite kapıları duruyor: gerçek Postgres üzerinde 127/127 Pest testi yeşil, Larastan level 8 ve Pint temiz. Mobil Lighthouse `/quran/al-fatihah` için erişilebilirlik 100, best practices 100, SEO 92. ## Kalan yüzde henüz eşleşmedi Altyapı kurulu, platform yayında. Devam eden şey kod değil veri. Parmak izi eşleştirmesi koleksiyona göre %91–99'da duruyor; geriye kalan yüzde henüz eşleşmedi. Bunlar yazım varyantları normalize edildikten sonra bile birebir tutmayan kayıtlar — yani otomatik eşleştirmenin bittiği yer. Türkçe hadis çevirisi kapsamı da %83; kalan boşluk kaynak tarafında ve yazılımla kapanmıyor, başka bir kaynak ya da elle çeviri istiyor. Açık uç bu yüzden ölçülebilir ve sıkıcı: kapsamı yüze yaklaştırmak. Eşleşmeyen her hadis, metnin parmak izine indirgenmesinin nereye kadar götürdüğünü gösteren bir kayıt. ## Swipenor: skoru istemci hesaplarsa sunucu neye güvenir? - Kaynak: https://muhammetsafak.com/tr/labs/swipenor-lab/ Mekanik basit: kart gelir, sağa kaydırırsan "Doğru", sola kaydırırsan "Yanlış". Yaş grubuna göre içerik (4–12, 12–18, 18+), doğru cevapta puan, seri tutturunca bonus, global ve yaş bazlı sıralama. Basit olmayan kısım, skorun kime ait olduğu. ## Skoru istemci hesaplıyorsa sunucu neye bakar Oyun döngüsü 60 fps'te istemcide dönüyor (reanimated worklet'leri + gesture-handler); skoru da doğal olarak istemci hesaplıyor. Sunucunun bu sayıya körlemesine inanması ise sıralama tablosunu ilk gün çöpe çevirir. Denenen model **signed-client**: oturum başında sunucu bir `server_nonce` üretiyor, istemci skoru bu nonce ile HMAC'leyip imzalıyor, API hem makuliyet kontrolü yapıyor hem imzayı doğruluyor. Puan sabitleri de istemciye gömülü değil — doğru cevap 100 puan, seri eşiği 3, seri bonusu +25 — hepsi `GET /config` ile sunucudan geliyor ki istemci ile sunucu birbirinden sapmasın. Puan sabitlerinin sunucudan gelmesi de imzanın devamı. İstemcide sabit tutulsalardı imza yine doğrulanırdı ama doğrulanan sayı yanlış olabilirdi: eski sürümde kalmış bir istemci, seri bonusunu kendi bildiği değerle hesaplayıp geçerli bir imzayla gönderirdi. Sabitler tek yerde durunca doğrulama da anlamlı oluyor. ## Dört depo, tek şema sahibi Dört ayrı depo tek klasörde: `game` (Expo SDK 57, React Native, TypeScript), `api` (Laravel 13, PostgreSQL 16, Redis), `admin` (Filament, API ile **paylaşılan** veritabanı üzerinde çalışır) ve Astro ile yazılmış tanıtım sayfası. Çekirdek döngü uçtan uca çalışıyor: kaydırma, puanlama, seri bonusu, global/yaş sıralaması. Şema sahipliği tek noktada tutuluyor. Admin paneli Filament ile yazıldı ve API ile aynı veritabanına bakıyor, ama migration çalıştırmıyor: şemayı yalnız API değiştiriyor. İki uygulamanın da migrate edebildiği bir kurulumda hangi sürümün hangi tabloyu tanımladığı ilk çakışmada belirsizleşir ve o belirsizlik üretimde geri alınamaz. Panelin şemaya yazma yetkisi bu yüzden hiç açılmadı. ## İmza modelinin sınavı yayında Açık uçlar oyunun kendisinde değil, etrafında: reklam entegrasyonu ve tek seferlik "reklamları kaldır" satın alması, mağaza materyalleri ve yayın süreci. İmza modelinin gerçek kullanıcıya karşı ne kadar dayandığını da ancak yayında göreceğim. ## Garajdaş: model-özelinde topluluk, forumun dağınıklığı olmadan - Kaynak: https://muhammetsafak.com/tr/labs/garajdas-lab/ Bir araç sahibinin en sık sorduğu soru şu: "Bu modelin şu yılki kasasında bilinen bir sorun var mı?" Bugün cevabı Facebook gruplarına, forumlara ve WhatsApp gruplarına dağılmış durumda; hiçbiri model-özelinde organize değil, hiçbiri aranabilir ve kalıcı değil. Garajdaş bunu tek yerde toplama denemesi: her marka-model kendi **kulübü**, kullanıcı ilgilendiği kulüplere abone oluyor, akış oradan geliyor. ## Her marka-model kendi kulübü Taksonomi kararı ürünün kendisi: İçten yanmalı motorlu (ICE) ve elektrikli (EV) araçlar ayrı dünyalar değil, dört tekerlek ile iki tekerlek de öyle. Aynı çatı, aynı kulüp mantığı. İsim de bu duruşun parçası — "garaj" + akrabalık eki; ürün içinde gerçek bir ilişki tipi, süs değil. Kulüp biriminin marka değil marka-model olması da bir karar. Marka düzeyinde tek kulüp kurmak listeyi kısaltırdı ve moderasyonu kolaylaştırırdı; karşılığında aynı markanın on beş modeli tek akışta karışırdı. Aracıyla ilgili sorunun neredeyse tamamı modele ve yıla özel — marka düzeyi, sorunun sorulduğu çözünürlükte değil. Mimari sade tutuldu: tek API (Laravel 13) şemanın tek sahibi, üstünde iki istemci — Next.js 16 ile web, Expo/React Native ile mobil. Tasarım sistemi tek bir dosyada tanımlı ve iki platformda elle senkronlanıyor; polyrepo'da token build aracı yok, bir token değişirse ikisi de aynı commit'te değişiyor. Token build aracının polyrepo'da kurulmaması da bir karar: üçüncü bir yapı adımı ve üçüncü bir sürüm hattı demekti, iki istemci için ödenmeye değmedi. Kural onun yerine sözleşmeye yazıldı. ## MVP fazları kapandı MVP fazları sırayla kapandı: yerel altyapı ve API iskeleti, kimlik ve kulüp taksonomisi, içerik/etkileşim yüzeyi, Expo mobil istemcisi, Next.js web istemcisi, sertleştirme turu ve bildirimler. API tarafında 97 test; web'de lint + build + canlı smoke; mobilde lint, tsc ve bundle export doğrulandı. Giriş yapılmadan da kulüp ve gönderi sayfaları indekslenebilir şekilde render oluyor — topluluk içeriğinin aranabilir olması ürünün varlık sebebi. ## Platform yayında Kod tarafında bilinen eksik kalmadı; kalanlar hesap/kimlik bilgisi gerektiren işler (Google OAuth doğrulaması, push bildirim kimlik bilgileri) ve mobil mağaza süreci. Platformun kendisi yayında. ## SentryFixer: Sentry hatasını koda karşı okuyup düzeltme planı çıkarmak - Kaynak: https://muhammetsafak.com/tr/labs/sentryfixer-lab/ Hata izleme ile düzeltme arasındaki boşluk hep aynı yerde: Sentry neyin patladığını söylüyor, ama patlayan satırın bugünkü hâlini, o kod yolunun o tarihten sonra değişip değişmediğini ve düzeltmenin neyi kırabileceğini yine insan çıkarıyor. SentryFixer bu ara işi otomatikleştirmek için yazdığım bir Claude Code skill'i. ## Event'ten düzeltme planına dört adım Skill bir Sentry issue'sunu alıp sırasıyla şunları yapıyor: 1. 50–300 KB'lık event verisini redakte edilmiş bir özete indiriyor. 2. Stack trace çerçevelerini çalışma ağacındaki gerçek kaynak dosyalara eşliyor. 3. Hatanın oluştuğu tarihten bu yana kodun değişip değişmediğini — tazeliğini — kontrol ediyor. 4. Kök nedeni gerekçelendirip somut bir düzeltme ve test önerisi çıkarıyor. Sözleşmenin sert kısmı sonda: **plan onaylanmadan hiçbir dosya değişmiyor.** Otomatik commit yok; salt-okunur geçmiş dışında git işlemi yok. İkisi birlikte duruyor, çünkü tek başına "dosya değiştirmem" yetmez: commit atan bir araç, değiştirdiği şeyi geri almayı da zorlaştırır. Onay geldikten sonra diff uygulanıyor, testler koşuyor ve sonuç olduğu gibi raporlanıyor. ## Dar bağımlılık, sert onay kapısı Bağımlılık yüzeyi bilerek dar: Python 3.8+ ve yalnızca standart kütüphane, artı `curl`/bash. Sentry tarafına üç yoldan bağlanılabiliyor — doğrudan Sentry API, `sentry-cli` ya da Sentry MCP sunucusu; hangisi kuruluysa o kullanılıyor. Lisans MIT, depo açık. Bağımlılığın dar tutulması tercih değil koşuldu: skill başkasının makinesinde, başkasının projesinde ve çoğu zaman bir hata anında koşuyor. `pip install` gerektiren bir araç, tam da en çok ihtiyaç duyulan anda kurulum sorunu çıkarır. Sentry'ye üç yoldan bağlanabilmesi de aynı sebepten: kullanıcıdan yeni bir şey kurması istenmiyor. Tasarımın merkezinde iki karar var. Birincisi **bağlam sıkıştırma**: ham event verisi olduğu gibi modele verilmiyor, önce işe yarayan çerçevelere indirgeniyor ve kişisel veri bu adımda redakte ediliyor. İkincisi **onay kapısı**: skill'in yazma yetkisi, planı okuyup "uygula" diyene kadar hiç açılmıyor. ## Tazelik kontrolünün sınırı Masadaki açık soru şu: tazelik kontrolü şu an dosyanın değişip değişmediğine bakıyor; asıl istediğim, hatanın geldiği commit ile bugünkü kod arasındaki farkın kök nedene ne kadar dokunduğunu söyleyebilmek. Bir sonraki tur orada. ## Devrazzi: geliştirici haberini model katmanlarına eletmek - Kaynak: https://muhammetsafak.com/tr/labs/devrazzi-lab/ Sorun bilgi eksikliği değil, sinyal/gürültü oranı. Ve mesele genel teknoloji haberi de değil: sürüm notları, model çıkışları, kütüphane kırılmaları, mühendislik yazıları — geliştiricinin gerçekten işine yarayan yazılım ve AI tarafı. Devrazzi bu ayıklamayı makineye yaptırma denemesi: kaynakları topla, puanlat, eşiğin altındakini at, kalanı TR ve EN sentez olarak üret, yayınla. Akış uçtan uca otomatik yürüyor. ## Yerelde ele, yukarıda devret Çekirdek bir durum makinesi: `RAW → EVALUATING → APPROVED/REJECTED → PUBLISHED`. İlk eleme **yerelde**: Ollama üzerinde koşan bir model (`deepseek-r1:14b`) her kaydı dört eksende puanlıyor — özgünlük, teknik derinlik, pragmatizm, ekosistem etkisi. Ortalama 7'nin altındaysa kayıt orada ölüyor. Hacim bu katmanda, dolayısıyla eleme maliyeti sıfır. Eşiği geçen kayıt yukarı çıkıyor. Üst katmanlarda DeepSeek, Gemini, OpenAI ve Claude API'leri var ve hiçbiri diğerinin işini yapmıyor: her biri hattın ayrı bir adımına, o adımın istediği bağlam uzunluğu ve kalite/maliyet profiline göre bağlı. Ucuz olan çoğa bakıyor, pahalı olan aza. Sağlayıcı arayüzü tek; model seçimi konfigürasyon meselesi, düşen sağlayıcı, zinciri bir sonrakine devrediyor. Model çıktısının tamamı katı bir JSON sözleşmesine bağlı — sözleşmeyi tutmayan yanıt kabul edilmiyor. İki savunma baştan içeride: modele giden metin kırpılıyor (ham scrape doğrudan verilmiyor) ve sistem talimatı, kötü niyetli bir feed'in modeli kaçırmasına karşı sanitize ediliyor. Arada insan onayı olmayan bir hatta bunlar opsiyonel değil. ## Faz 0 canlı, gövde çıkarımı yok Faz 0 canlı doğrulandı: 5 kaynak, 80 RAW kayıt, uçtan uca işlenip veritabanına yazıldı; `REJECTED` akışı da beklendiği gibi çalıştı. Zemin TypeScript: modüler paketler (config / core / db / llm / ingestion / pipeline) ve Prisma şeması üstünde duruyor; ham payload'ı olduğu gibi PostgreSQL 16'nın JSONB'si saklıyor. Çıktı iki uçta görünür durumda: [devrazzi.com](https://devrazzi.com) ve iOS uygulaması. Bilinen eksik açık açık duruyor: tam makale çıkarımı yok. Bu yüzden Hacker News ve Reddit gibi "link + iki cümle" besleyen kaynaklar, içerik yetersizliğinden eleniyor — beklenen ama kabul edilebilir değil. ## Kör noktayı kapatmak Sıradaki tur o eksik: gövde çıkarımı olmadan hattın en değerli kaynakları sistemin kör noktasında kalıyor. Ardından katman başına maliyet/kalite ölçümü geliyor — hangi adımın hangi modeli hak ettiği şu an ölçümle değil sezgiyle kararlaştırılmış durumda. ## Aynı kuyruğu iki dilin serileştirme kilidine takılmadan paylaşması - Kaynak: https://muhammetsafak.com/tr/labs/babelqueue-lab/ PHP'nin `serialize()`'ı ile Python'un `pickle`'ı aynı işi yapar; birbirlerinin ürettiği baytları okuyamazlar. Sorun formatın kendisinde değil, formatın bir dile ait olmasında — ve kuyruk, iki ucunda farklı diller duran ilk yer. ## Dile hapsolmuş bayt Polyglot mimaride sinsi olan şu: bir servis kuyruğa kendi dilinin formatıyla yazıyor, başka bir dildeki tüketici o baytları açamıyor. Mesaja gömülen dile özgü tip bilgisi işi üreten dilin içine hapsediyor. Denenen şey, araya sidecar ya da broker eklentisi koymadan, her dilin standart kütüphanesiyle ayrıştırabildiği tek bir zarf tanımlamaktı. Zarf iş kimliğini, dağıtık izleme için `trace_id`'yi, veri yükünü, metadata'yı ve deneme sayısını taşıyor. ## Dondurulmuş zarf Katı bir JSON zarfı — `schema_version` 1'de dondurulmuş — ve bu zarfın Redis ile RabbitMQ üzerinde çalışan referans uygulaması. Ölçüm hedefi ek yükün %2'nin altında kalmasıydı; sözleşme "bir dil üretir, başka bir dil tüketir, aynı baytlar" ilkesine göre yazıldı. ## Kimliği sınıf adından ayırmak İki karar zarfın biçiminden daha çok belirledi. **İş kimliği sınıf adı değil, URN tabanlı kararlı bir tanımlayıcı.** Kuyruğa yazılan işin "ne olduğunu" söylemenin en kolay yolu sınıf adını yazmaktır ve tek dilli bir sistemde işe yarar. İki dilde yaramaz: `App\Jobs\SendInvoice` Python tarafında bir sınıf değil, anlamsız bir dize. Kimlik, onu üreten dilin ad alanından bağımsız olmak zorundaydı. **Şema `schema_version 1`'de donduruldu.** Genişletilebilir bir sözleşme yazmak daha esnek olurdu; alan eklemek serbest kalırdı. Bedeli, bugün yazılan bir üreticinin yarın eklenen bir tüketiciyle aynı teli paylaşacağının garanti edilememesiydi. Dondurulmuş şema bu garantiyi veriyor ve karşılığında genişleme serbestisini alıyor — bir standart için doğru takas, bir uygulama için olmazdı. İkisinin ortak sonucu benimsemenin artımlı olabilmesi: BabelQueue olmayan mevcut işler bozulmadan çalışmaya devam ediyor, geçiş kuyruk kuyruk yapılıyor. ## Tek jenerik SDK yerine dil başına adaptör Üçüncü karar dağıtım tarafındaydı. Zarf bir spesifikasyon olduğuna göre tek bir referans kütüphane yazıp "gerisi sizin işiniz" demek yeterdi; okuyan herkes kendi diline taşırdı. O yol sözleşmeyi kâğıtta bırakırdı. Bir kuyruk standardının benimsenmesi zarfı anlamakla değil, mevcut iş altyapısına takılmasıyla oluyor — ve o altyapı dile değil framework'e ait. Bu yüzden her dil kendi SDK'sıyla ve o dilin gerçekten kullanılan framework adaptörleriyle geliyor. Labs aşamasında kapsam dört dildi: PHP'de Laravel ve Symfony, Python'da Celery ve Django, Go'da Redis/RabbitMQ transport'ları, Node.js'te BullMQ ve NestJS. Bedeli bakım yüzeyi: dil başına bir SDK, bir sürüm hattı, bir paket kaydı. Karşılığında geçiş, mevcut kuyruk kodunu yeniden yazmayı değil bir adaptör takmayı gerektiriyor. ## Spesifikasyona giden yol Zarf bir spesifikasyona ve çok dilli SDK setine dönüştü: dört dil altıya çıktı — Java'da Spring Boot, .NET'te MassTransit eklendi — ve dağıtım her ekosistemin kendi kanalına bağlandı: Packagist, PyPI, pkg.go.dev, npm, Maven Central, NuGet. BabelQueue artık kendi alan adı olan bir standart. Ayrıntısı portfolyoda. ## Bluetooth yakınlığıyla ekran kilidi: 'var/yok' yetmiyor - Kaynak: https://muhammetsafak.com/tr/labs/signallock-lab/ Bluetooth cihazı menzilde mi diye sormak kolay; cevabın doğru olması ayrı iş. Ham "var/yok" sinyali saniyede birkaç kez yalan söylüyor ve her yalan bir yanlış kilit demek. ## "Var/yok" yalan söylüyor Ortak çalışma alanında kilitlenmemiş bir Mac gizlilik riski; kilitlemeyi hatırlamak da kullanıcının işi olmamalı. Denenen çözüm: kullanıcının zaten yanında taşıdığı bir Bluetooth cihazını (iPhone, Apple Watch, AirPods, BLE etiketi) güvenilir cihaz saymak ve menzil dışına çıkınca ekranı kilitlemek. İşin asıl konusu sinyalin kendisi oldu. Bluetooth sinyali doğası gereği dalgalanıyor; cihaz masanın üstünde dururken bile birkaç örneklemede kayboluyor. Düz görünürlüğe bakan bir kilit, kullanıcı yerinde otururken ekranı kilitliyor. ## Görünürlük yerine yakınlık değerlendirmesi Uygulamanın çekirdeği düz görünürlük yerine bir yakınlık değerlendirmesi üzerine kuruldu: yumuşatılmış RSSI sinyal gücü ile son görülme zaman damgası birlikte okunuyor. RSSI eşiği, uzaklaşma gecikmesi ve yumuşatma penceresi kullanıcı tarafından ayarlanabiliyor; varsayılanlar yanlış kilidi en aza indirecek biçimde bilinçli olarak temkinli seçildi. macOS tarafı Swift/SwiftUI + CoreBluetooth, Windows tarafı C#/.NET 8. İki ayrı yerel sürüm, aynı algoritmayı ve aynı yapılandırma modelini paylaşıyor. ## Ağa hiç çıkmamak Bu tür bir araç için bulut en kolay yol: cihaz eşleştirmesini sunucuda tut, ayarları senkronla, "masamı bul" gibi özellikler ekle. Hepsi bir hesap sistemi ve bir arka uç demek. Seçilen kısıt bunun tersi: uygulama hiçbir ağ isteği yapmıyor. Arka uç sunucu, bulut senkronizasyonu, hesap, telemetri, analitik yok; konum servisleri kullanılmıyor ve bütün ayarlar yalnızca kullanıcının kendi makinesinde duruyor. Bedeli açık — cihazlar arası senkronizasyon yok, uzaktan yönetim yok, kullanım verisi olmadığı için varsayılan eşikler ölçümle değil muhafazakâr tercihle belirlenmiş durumda. Karşılığında ekranını kilitlemek isteyen kullanıcı, bunun için yanındaki telefonun kimliğini bir sunucuya vermek zorunda kalmıyor. Bir güvenlik aracı için doğru takas bu. ## İki kod tabanı, tek algoritma Sıfır ağ kararının ikinci sonucu platform tarafında. Ağa çıkmayan, sürekli Bluetooth tarayan ve ekran kilidini tetikleyen bir uygulama, çalıştığı işletim sisteminin en alt katmanlarına dokunuyor: macOS'ta CoreBluetooth ve kilit API'si, Windows'ta kendi Bluetooth yığını ve oturum yönetimi. Tek bir çapraz platform kabuğu iki sürümü tek kod tabanında tutardı. Bedeli, o kabuğun altındaki platform farkını yine de yazmak ve üstüne bir çalışma zamanı taşımak olurdu — menü çubuğunda oturan, sürekli açık kalan bir araç için ağır bir fatura. Seçilen yol iki ayrı yerel sürüm oldu: macOS için Swift/SwiftUI, Windows için .NET 8. Paylaşılan şey kod değil, yakınlık algoritmasının ve yapılandırma modelinin kendisi — eşikler, gecikme ve yumuşatma penceresi iki tarafta aynı anlama geliyor. Bakım yüzeyi ikiye çıkıyor, davranış tek kalıyor. ## Menü çubuğuna giden yol Menü çubuğu uygulaması olarak yayınlandı; ürün sayfası ve indirmesi kendi alan adında. Ayrıntısı portfolyoda. ## Diff'i dışarı sızdırmadan yerel kod incelemesi - Kaynak: https://muhammetsafak.com/tr/labs/commitbrief-lab/ Diff'i bir modele okutmak için onu makineden çıkarman gerekmiyor. Tek dış çağrı seçtiğin sağlayıcıya gidiyor — ve asıl iş o sağlayıcıyı takılabilir kılmakta. ## Terminalden çıkmayan inceleme Sorun şuydu: bir kod değişikliği başka bir insana (ya da gelecekteki bana) gitmeden önce, terminalden çıkmadan yapılandırılmış bir "ikinci göz" incelemesi alınabilsin. Diff makineden çıkmasın, inceleme çıktısı da öyle. ## Tek komutun kapsamı Kapsam tek bir komuta sığdı: staged diff, tek commit, çalışma ağacı ya da PR tarzı üç noktalı aralık. Proje kuralları `COMMITBRIEF.md` ile özelleştirilebiliyor, çıktı renkli kart / markdown / katı JSON şeması olarak alınabiliyor. ## Sağlayıcıyı takılabilir kılmak Bir LLM aracının en kısa ömürlü parçası, bağlandığı sağlayıcıdır. İki karar bu kabulden çıktı. **`Provider`, `database/sql` kalıbında bir kayıt sistemi.** Sağlayıcıyı doğrudan çağırmak en kısa yol olurdu; ikinci sağlayıcıda o yolun bedeli ödeniyor. Kayıt sistemiyle yeni bir sağlayıcı eklemek tek bir paket ve tek bir blank import demek. Anthropic, OpenAI, Gemini ve Ollama API tabanlı; `claude-cli` ile `gemini-cli` ise kullanıcının mevcut aboneliğini yeniden kullanan subprocess tabanlı sağlayıcılar — ikisi de aynı arayüzü uyguluyor. **Git tek yoldan okunmuyor.** `go-git` saf Go, kurulum gerektirmiyor; commit tabanlı işlemlerin tamamını, `status` ve `diff`'i de karşılıyor. Kapsamı çalışma ağacının kenarlarında bitiyor: `add` yalnız bayraksız hâliyle destekleniyor, çoklu worktree ve index v1/v3 hiç desteklenmiyor. Saf çözüm için binary'ye hiç düşmemek gerekirdi ve o zaman bu kenar durumların hepsi aracın kör noktası olurdu. Hibrit yol seçildi: önce `go-git`, kapsamı aşan yerde `git` binary'si, ikisi de aynı `Repo` arayüzünün arkasında. ## Aynı diff'i iki kez ödememek Üçüncü karar maliyet tarafında. Bir LLM çağrısı ücretli ve geliştirici aynı diff'i gün içinde birkaç kez inceletiyor — commit'ten önce, düzeltmeden sonra, PR açarken. Önbellek anahtarı yalnız diff olsaydı yanlış cevap dönerdi: aynı diff farklı sağlayıcıda, farklı modelde ya da farklı dilde başka bir inceleme demek. Anahtar bu yüzden beş parçadan oluşuyor — diff, sistem prompt'u, sağlayıcı, model ve dil — ve SHA256 ile özetleniyor. Aynı beşli tekrar geldiğinde çağrı tek disk okumasına iniyor, her çağrının sonunda kazancı `Saved: $X` satırı olarak yazılıyor. Aynı disiplin gönderilen içerikte de var: üç katmanlı bir filtre modele neyin gideceğini daraltıyor — yerleşik varsayılanlar, `.commitbriefignore` ve `COMMITBRIEF.md` içindeki semantik filtre. Diff makineden çıkmıyor derken kastedilen şey buydu; çıkan şeyin de ne olduğu seçilebiliyor. ## İmzalı binary'lere giden yol Labs aşaması kapandı: CommitBrief artık kendi alan adı, imzalı binary'leri ve sürüm sözleşmesi olan bir ürün. Ayrıntısı portfolyoda. ## Kişisel ve kurumsal finansın aynı veri modelinde durması - Kaynak: https://muhammetsafak.com/tr/labs/parantaj-lab/ Banka hesabı, kredi kartı ve kasa — üçü de aynı kişinin parası, üçünün takibi ayrı araçta. Kişisel ile kurumsalı ayrı ürüne bölmek bu dağınıklığı çözmüyor, ikiye katlıyor. ## Aynı para, üç ayrı araç Denenen şey, kişisel kullanımla kurumsal kullanımı ayrı ürünlere bölmeden tek bir hesap/işlem modelinde toplamaktı; üzerine yinelenen ve zamanlanmış işlemler. ## Tek çekirdek, üç istemci Model tuttu: çoklu hesap yönetimi, bütçe planlama, hedefler ve raporlama aynı çekirdek üzerinde yürüdü. Yük arttıkça olay temelli parçalar (Redis, RabbitMQ) devreye girdi; istemci tarafı web'in yanına React Native ile iOS ve Android olarak açıldı. ## Kişisel ile kurumsalı bölmemek Kişisel finans ile kurumsal finansı iki ayrı ürün yapmak sektörde alışılmış yol ve tek bir gerekçesi var: kurumsal tarafın ekip, rol ve yetki ihtiyacı kişisel tarafta yok. O ayrımın bedeli veri modelinde ödeniyor. Aynı kişinin şahsi hesabı ile şirketinin hesabı iki ayrı üründe durursa nakit akışı hiçbir ekranda bir arada görünmüyor — oysa parayı taşıyan kişi aynı. İki ürün iki şema, iki raporlama katmanı ve aynı işlemin iki kez girilmesi demek. Seçilen yol tek hesap/işlem modeli oldu; kurumsal fark yalnız bir yerde duruyor, rol tabanlı yetkide. Bir hesaba takım üyesi eklemek modeli değiştirmiyor, o hesabın üzerine bir yetki katmanı koyuyor. Karşılığında kurumsal tarafa özel akışlar (muhasebe entegrasyonu gibi) çekirdekte değil, üstte durmak zorunda kaldı — kabul edilen bedel bu. ## Yinelenen işlem sonradan eklenmedi İkinci karar zamanlama tarafında. Kira, abonelik, maaş, taksit — kişisel bütçenin de kurumsal nakit akışının da çoğu düzenli tekrar eden işlemden oluşuyor. Bunu bir "hatırlatma" özelliği olarak üste koymak kolay yoldu: kullanıcı işlemi elle girer, sistem zamanı gelince dürter. O tasarımda gelecek hiçbir zaman raporda görünmüyor — bütçe yalnız olmuş bitmiş işlemleri biliyor, oysa sorunun kendisi gelecekle ilgili. Yinelenen ve zamanlanmış işlem bu yüzden çekirdeğin bir parçası: işlem modelinin kendisi tekrar kuralını taşıyor, raporlama da planlanmış olanı gerçekleşmiş olanla birlikte okuyabiliyor. Olay temelli parçalar (Redis, RabbitMQ) ise baştan değil, yük arttıkça girdi. Sıralama bilinçli — kuyruk erken eklenirse çözdüğü bir problem olmadan bakım maliyeti getiriyor. ## SaaS'a giden yol Çekirdek bir SaaS ürününe döndü; abonelik, mobil uygulamalar ve kurumsal özellikler ürün tarafında yaşıyor. Ayrıntısı portfolyoda. ## Tek başına da çalışan, birlikte de çalışan PHP paketleri - Kaynak: https://muhammetsafak.com/tr/labs/initphp-lab/ Bir PHP paketi ya bir çatının parçasıdır ya da tek başına duran bir araç. İkisini birden isteyince ortak bir çekirdek paket yazmak ilk akla gelen çözüm — ve yanlış olan da o. ## Tek başına mı, aile mi Denenen şey şuydu: Router, Database, Cache, Mailer, Socket gibi parçalar hem tek başına kurulduğunda anlamlı çalışsın, hem aynı ailedeki diğer paketlerle birleştiğinde bir çatı oluştursun. Parçalar ayrı ayrı yazıldı ve her biri kendi başına bir soruya cevap verdi. ## 30'u aşkın paket ve üstündeki çatı 30'u aşkın bağımsız paket ve bunların üzerine oturan minimalist bir MVC çatısı (Framework3). Ortak zemin tip güvenliği ve PHPUnit testleri; dağıtım Composer/Packagist üzerinden, lisans MIT. ## Ortak çekirdek yerine PSR "Hem tek başına hem birlikte" isteği tek bir noktada çatışıyor: paketler birbirini tanımak zorundaysa aralarında bir bağımlılık var demektir ve o bağımlılık tek başına kurulumu bozar. İlk akla gelen çözüm ortak bir çekirdek paket yazmaktı — `initphp/core` gibi bir şey, arayüzleri ve ortak yardımcıları taşısın. O yol iki şeyi birden getirirdi: her paket bir bağımlılık daha kazanırdı ve ailenin dışındaki hiçbir kütüphane bu çekirdeği tanımayacağı için aile kendi içine kapanırdı. Seçilen zemin PHP-FIG standartları oldu. Logger PSR-3, Container PSR-11 uyguluyor; paketler birbirini değil, ortak bir arayüz sözleşmesini tanıyor. Bunun karşılığı şu: InitPHP Container'ı InitPHP Logger'ı kabul ediyor ama Monolog'u da kabul ediyor ve InitPHP Logger başka birinin PSR-11 container'ına da takılıyor. Çelişkiyi çözen şey ailenin kendi çekirdeği değil, ailenin dışında zaten var olan bir sözleşmeydi. ## Tek kütüphane yerine otuz dokuz paket Ailenin ikinci kararı paketlemede. Otuz dokuz ayrı depo, otuz dokuz sürüm hattı ve otuz dokuz Packagist kaydı yerine tek bir `initphp/initphp` paketi yazmak bakımı belirgin biçimde ucuzlatırdı. Bedeli kullanıcıya yazılırdı. Yalnız bir Router isteyen proje, Socket sunucusunu, Mailer'ı ve Queue'yu da kurmak zorunda kalırdı; her sürüm yükseltmesi kullanmadığı on paketin değişikliğini de taşırdı. Ayrılmanın karşılığı şu: `composer require initphp/router` yalnız Router'ı getiriyor, Socket kütüphanesinin TCP/TLS/UDP/SSL yüzeyini hiç görmüyorsunuz. Sürdürülebilirliği ayakta tutan şey de bu yüzden ortak kod değil, ortak disiplin: her pakette aynı tip güvenliği seviyesi, aynı PHPUnit alışkanlığı ve aynı PSR uyumu. Aile bir arada duruyor; aynı bağımlılığı paylaştığı için değil, aynı kurallarla yazıldığı için. ## Ekosisteme giden yol Koleksiyon dağınık paketler olmaktan çıkıp bakımı süren bir açık kaynak ekosistemine döndü. Ayrıntısı portfolyoda. # Research ## 64 goroutine, dört çekirdekte Mutex ile kanal aynı hızda, ama Mutex'in p99'u beş kat yüksek - Kaynak: https://muhammetsafak.com/tr/research/go-mutex-ve-channel-cekisme-altinda/ Dört çekirdekte 64 goroutine'i aynı sayaca saldığımda `sync.Mutex` ile kanal saniyede aynı sayıda işlem yaptı: 7,69 ve 7,94 milyon. Aynı hücrede p99 bekleme 491.519 ns'ye karşı 98.303 ns'ydi. "Hangisi daha hızlı" sorusunun tek bir cevabı olmadığını bu ikilide gördüm; bu kayıt o cevabın neye bağlı olduğunu ölçtüğüm kadarıyla yazıyor. Kodun ve ham koşuların hepsi [go-sync-bench](https://github.com/muhammetsafak/go-sync-bench) deposunda. Goroutine ve kanal tarafının pratik anlatımı için [Go'da eşzamanlılık: goroutine ve channel pratiği](/tr/blog/goda-eszamanlilik-goroutine-ve-channel-pratigi/) yazısına bakabilirsiniz. Burada yalnız ölçüm var. ## Ne ölçtüm Ana desen (S1) şu: G goroutine, tek bir paylaşılan sayacı artırıyor. Sayaca erişimin dört yolu var. | Model | Erişim biçimi | | --- | --- | | Mutex | sync.Mutex ile korunan sayaç | | Tek yönlü kanal | Sayacı tek bir sahip-goroutine tutuyor, diğerleri kanala istek yolluyor, cevap beklemiyor | | İstek-cevaplı kanal | Aynı sahip-goroutine, ama her istek cevap kanalı taşıyor ve cevabı bekliyor | | atomic | atomic artırma; yalnız bağlam için, başlık iddiasına girmiyor | İstek kanalı tamponsuz. Tamponlu bir istek kanalı sonucu değiştirebilir; bunu ölçmedim. İki desen daha koştu: S2'de okuma oranı değişiyor (yüzde 50, 90 ve 99 okuma), S3'te bir üretici-tüketici kuyruğu farklı tamponlarla (kanal 0, 1, 64, 1024 ve `sync.Cond`'lu kuyruk) deneniyor. Çekirdek sayısı P, konteynere cpuset ile verilen çekirdek grubunun büyüklüğü: 1, 2, 4 ve 8. Bu kayıtta "işlem/sn" bir throughput penceresinin medyanı, "bekleme" ise gecikme penceresinde bir işlemin sayaca ulaşması için geçen süre. Gecikme yüzdelikleri log kovalarının üst sınırı olarak raporlanıyor; bu yüzden 491.519 gibi sayılar bir kovanın tavanı, ölçülmüş bir tam değer değil. ## 64 goroutine, dört çekirdek: aynı hız, farklı kuyruk | Model | İşlem/sn | p50 | p99 | p99.9 | | --- | --- | --- | --- | --- | | Mutex | 7.688.545 | 639 ns | 491.519 ns | 1.441.791 ns | | Tek yönlü kanal | 7.944.446 | 175 ns | 98.303 ns | 114.687 ns | S1, G=64, P=4, iş yükü W=0. İki hücre de bayraksız. p99 oranı 491.519 / 98.303 = 5,00. Throughput farkı yüzde 3,3 ve kanalın lehine, yani gürültü içinde. Bekleme farkı gürültü değil: p50'de 639 ile 175, p99'da 491.519 ile 98.303, p99.9'da 1.441.791 ile 114.687 ns. Mutex aynı toplamı daha geniş bir dağılımla üretiyor. Ortalamaya bakan bir benchmark bu iki modeli eşit görürdü. Neden böyle olduğunu ölçmedim. Mutex'in bekleyen goroutine'leri nasıl uyandırdığına dair bir açıklama kurabilirim, ama bu deneyin ürettiği bir kanıt olmazdı; yazıya koymuyorum. ## Sekiz çekirdekte tablo döndü **G=64: dört ve sekiz çekirdekte throughput** Mutex sekiz çekirdekte iki katına yakın çıkarken tek yönlü kanal geriledi. İstek-cevaplı kanalın P=4 hücresi kalite bayrağı taşıdığı için grafiğe girmedi; P=8 değeri aşağıdaki tabloda. Kaynak: go-sync-bench, results/2026-09-29/merged/summary.json, S1 hücreleri | | P=4 | P=8 | | --- | --- | --- | | Mutex | 7.69 M | 15.96 M | | Tek yönlü kanal | 7.94 M | 6.65 M | | Model | İşlem/sn (P=8) | Mutex oranı | p99 | | --- | --- | --- | --- | | Mutex | 15.962.148 | 1,00 | 114.687 ns | | Tek yönlü kanal | 6.648.606 | 2,40 | 122.879 ns | | İstek-cevaplı kanal | 3.970.457 | 4,02 | 131.071 ns | S1, G=64, P=8. Üç hücre de bayraksız. Oran sütunu Mutex'in ilgili modele karşı katı: 15.962.148 / 6.648.606 = 2,40 ve 15.962.148 / 3.970.457 = 4,02. Mutex P=4'ten P=8'e 2,08 kat hızlandı. Tek yönlü kanal 0,84 katına indi: sayacı tek bir sahip-goroutine tutuyor; çekirdek eklemenin ona ne kattığını bilmiyorum. Nedenini deney ölçmedi; bir hipotez bile öne sürmüyorum. P=8'de p99 üç modelde de birbirine yakın: 114.687, 122.879 ve 131.071 ns. Dört çekirdekteki beş katlık fark burada görünmüyor. Hangi modelin kuyruğunun "daha iyi" olduğu sorusunun tek bir cevabı yok; P'ye bağlı. Kapsam dışı bırakmam gerekenler: P=1'deki Mutex hücresi (63,10 milyon) `spread_persistent` ve `observer_effect` bayrağı taşıyor, P=2'deki (13,22 milyon) bayraklı. İkisini de iddiaya almadım. Mutex eğrisinin P'ye göre monoton olup olmadığını da söyleyemiyorum, çünkü P=1 ve P=2 hücreleri bayraklı. ## 10.000 goroutine: Mutex hızlı, adaletsizliği açıklanamıyor | Model | İşlem/sn | Jain | p99 | Bayrak | | --- | --- | --- | --- | --- | | Mutex | 14.878.958 | 0,8237 | 27.262.975 ns | fairness_unexplained | | Tek yönlü kanal | 2.052.681 | 0,9999 | 5.767.167 ns | yok | | İstek-cevaplı kanal | 1.778.745 | 1,0000 | 6.291.455 ns | yok | S1, G=10.000, P=8. Mutex/tek yönlü kanal = 7,25; Mutex/istek-cevaplı kanal = 8,36. Jain 1'e yaklaştıkça goroutine'ler eşit pay alıyor. Mutex burada kanalların 7,25 ve 8,36 katı hızlı. Ama Jain adillik indeksi 0,8237 ve bu değer işaretli: `fairness_unexplained`. Harness adaletsizliği iki kaynaktan yalnız birine bağlayabiliyor: park davranışına (`sync`). Kural şu: G ≤ P ise, goroutine başına düşen zaman dilimi sınırı b ≥ 1 ise ya da örneklenen edinimlerin en az yarısı park eşiğini aşıyorsa kaynak `sync`, değilse `unexplained`. Bu hücrede G=10.000 > P=8, b = 0,12 ve yavaş edinim payı yalnız 0,0176; üç koşul da tutmadığı için adaletsizlik park davranışına bağlanamadı. Kural Jain değerine bakmıyor. Harness'ın b'den hesapladığı beklenen Jain 0,12 (`expected_quantum_jain`), ölçülen 0,8237; bu hesabın dayandığı kuantum modelini doğrulamadım, o yüzden zamanlayıcıya atıf yapmıyorum. Mutex'in adaletsizliğini bir nedene yazamıyorum. Tasarım kuralım gereği bu hücreler modeller arası adillik kıyasına girmiyor; tabloda "Mutex daha adaletsiz" demiyorum, "adaletsizliği açıklanamıyor" diyorum. Aynı tablonun bağlamı: dört çekirdekte Mutex'in Jain değeri G=1.024'te 0,4064, G=10.000'de 0,1892, ikisi de `fairness_unexplained`. Kanal hücrelerinde G=10.000 ve P=8'deki Jain 0,9999 ve 1; aynı G'de P=4'te tek yönlü kanalın Jain'i 0,7995 (yeniden koşudan), yani kanalların adilliği de P'ye bağlı. ## Okuma oranı ve kuyruk tamponu S2'de Mutex, okuma oranı arttıkça hızlanıyor; sahip-goroutine kanalı neredeyse hiç kıpırdamıyor. **S2, G=64, P=4: okuma oranına göre throughput** Mutex bayraksız. Kanalın yüzde 90 okuma hücresi yeniden koşudan geliyor, yüzde 99 okuma hücresi spread_throughput bayrağı taşıyor. RWMutex hücreleri observer_effect bayraklı, grafikte yok. Kaynak: go-sync-bench, results/2026-09-29/merged/summary.json, S2 hücreleri | | %50 okuma | %90 okuma | %99 okuma | | --- | --- | --- | --- | | Mutex | 9.86 M | 12.23 M | 13.12 M | | Sahip-goroutine kanalı | 4.42 M | 4.58 M | 4.5 M | Yüzde 50 okumada Mutex kanalın 2,23 katı, yüzde 90'da 2,67 katı. Yüzde 99 hücresinin oranını bayraklı olduğu için yazmıyorum. Mutex'in yüzde 99'daki hızı yüzde 50'ninkinin 1,33 katı. S3'te G=64, P=4: tamponsuz kanal 5.414.190, tamponu bir olan kanal 6.912.398, tamponu 1.024 olan `sync.Cond`'lu kuyruk 4.461.277 işlem/sn. Bu üç hücre bayraksız: tampon bire çıkınca kanal 1,28 kat, `sync.Cond`'lu kuyruğa göre 1,55 kat hızlı. 64 ve 1.024 tamponlu kanal ile 64 tamponlu `sync.Cond` hücreleri bayraklı; 18,72 ve 21,98 milyon gibi sayılar yalnız yön gösterir, oran çıkarmadım. ## Sonuç > **Sonuç** > > G=64 ve P=4'te Mutex ile tek yönlü kanal aynı hızda (7,69 ve 7,94 milyon > işlem/sn) ama Mutex'in p99'u 5 kat yüksek. P=8'de Mutex 2,4 ve 4,0 kat hızlı. > G=10.000 ve P=8'de Mutex 7,25 ve 8,36 kat hızlı, fakat adaletsizliği bir > nedene bağlanamıyor. "Hangisi daha hızlı?" sorusunun cevabı G ve P'ye bağlı. ## Nasıl ölçtüm Deney 845 hücre koştu; her hücre 5 round, her round tekrar başına 0,5 sn ısınma, 1,5 sn throughput ve 2,0 sn gecikme penceresi. Toplam 4.225 geçerli tekrar. Tekrarların medyan süresi 4,38 sn; sekiz tekrar bütçeyi aştı (örneğin `s1-atomic-g10000-p4-w0` 55 sn). Her round × çekirdek grubu container'ından sonra 60 sn soğuma var; container içindeki hücreler arasında bekleme yok. Konteyner ağsız ve read-only, 4 GiB belleğe sahip. İlk geçişte uyku kapısı yoktu; ana makine bir kez uyudu: 11:45-11:59 UTC aralığında `s1-chan_oneway-g64-p1-w0` hücresinin 3. round'u bundan etkilendi ve bu hücre iddiaya girmiyor. Yeniden koşudan önce `run.sh`'a `sleep 0` şartı eklendi. Sonuçlar iki geçişten geliyor. İlk geçiş `sha256:640a046f219a…` imajıyla 2026-09-29T08:34:54Z'de başladı. İlk tasarımın yeniden koşu tetikleyicisi 294 aday seçti (290'ı sapma kapısına, 11'i ikinci bir kapıya takılmıştı; bir hücre ikisine de takılabilir). Bu, 84 hücrelik bütçeyi aştı. Tetikleyici kırpılmış aralığa çevrildi: en uçtaki tekrar atılıp kalan aralığa bakılıyor (throughput için %10, p99 için %50) ve bu yalnız hangi hücrenin yeniden koşulacağını seçiyor. Bütçe 84'ten 88'e çıkarıldı; 88 hücre `sha256:eabc1573265a…` imajıyla 2026-09-30 01:38-02:31 UTC'de yeniden koşuldu. Birleşik tabloda kaynak `rerun` görünüyor; 36'sı yeniden koşudan sonra da eşik üstünde kaldı. Hücre medyanı yine 5 tekrarın hepsinden alınıyor. Bayrak ise ayrı bir ölçüt: tam aralık üzerinden throughput için %10, p99 için %25, saat kayması için %5. Kalite bayrakları: | Bayrak | Hücre sayısı | Anlamı | | --- | --- | --- | | observer_effect | 171 | Gecikme penceresindeki işlem hızı, throughput penceresindekinin 0,9 katının altında; ölçüm kendini etkiliyor | | fairness_unexplained | 235 | Adaletsizlik park davranışına bağlanamıyor: G > P, b < 1 ve örneklenen edinimlerin yarısından azı park eşiğinin üstünde; Jain değerine bakılmaz | | spread_p99 | 189 | Tekrarlar arası p99 sapması 0,25 üstü | | spread_throughput | 125 | Tekrarlar arası throughput sapması 0,10 üstü | | calib_drift | 72 | Saat kalibrasyonu 0,05 üstünde kaydı | | spread_persistent | 36 | Yeniden koşudan sonra da sapma sürüyor | Bir hücre birden çok bayrak taşıyabilir; toplam 518 bayraklı hücre, yani 845'in %61,3'ü. Oran %15'i aştığı için ölçümün önerdiği güven düzeyi düşük (confidence: low). Gürültü tabanı: P=2 için atomic G=2'de Jain 0,9999, P=4 için G=4'te 0,9998, P=8 için G=4'te 0,9997. Yani ölçüm düzeneği kendi başına adaleti bozmuyor. Kendi kendini sınayan koşunun 40 denemesinin 40'ı geçti. Yayın komutu `reproduce` alanında; ham veri ve lisans (MIT) depoda. > **Tan:** Bu kayıt hangisinin daha hızlı olduğunu söylemiyor, hangisinin ne zaman hızlı olduğunu söylüyor. Bence en değerli satır, Mutex'in adaletsizliğinin bir nedene bağlanamadığı hücre. Nedenini bilmediğiniz bir davranışı üretimde varsaymayın, kendi P'nizde ölçün. ## Sınırlar ve dürüstlük notları 1. **Bayraklı hücrelerin çoğunluğu.** 845 hücrenin 518'i bayraklı. Başlıktaki throughput ve p99 oranlarını bu bayrakları taşımayan hücrelerden aldım; G=10.000, P=8 Mutex hücresi yalnız adillik bayrağı taşıyor. Bayraklı hücreler yön göstermek için anıldıysa öyle etiketlendi. 2. **VM içi vCPU.** Ölçüm bir Docker sanal makinesinde koştu; P/E çekirdek ayrımı VM'den görünmez ve küme saati meşgul çekirdek sayısına bağlı. P değerleri fiziksel çekirdek değil, cpuset ile verilmiş vCPU grupları. 3. **P=2 gürültülü.** Nedenini doğrulayamadım. 4. **Adillik bayrağının kuralı.** Bayrak G, P, b ve park payına bakıyor; zamanlayıcı kaynaklı adaletsizliği ayırt edemiyor. Kuantum modelini (`expected_quantum_jain`) doğrulamadım, açıklamalarda kullanmadım. 5. **Gecikme çözünürlüğü.** Gecikme yüzdelikleri log kovalarının üst sınırıdır ve 1/16 oranında örneklenmiştir. `< 164 ns` saat çözünürlüğünün 4 katı olan tabandır. `starving_fraction` alanını örnek sayısı küçük olduğu için kullanmadım. 6. **İstek kanalı tamponsuz.** Tamponlu istek kanalı kanal modellerinin sonucunu değiştirebilir. 7. **G=1'de iki goroutine.** Sahip-goroutine modelleri G=1'de 2 goroutine koşuyor. 8. **Kapsam dışı.** `sync.Map` hiç ölçülmedi; S2'de atomic de ölçülmedi. Üretim iş yükü değil, sentetik bir sayaç ölçüldü. Kendi sayınızı üretmek için [go-sync-bench](https://github.com/muhammetsafak/go-sync-bench) deposunu kendi donanımınızda koşturun; P'yi kendi hedefinize göre seçin, çünkü bu kaydın en açık sonucu, cevabın P'ye bağlı olduğu. ## Bir çekirdek Go'da 14.330, PHP-FPM'de 5.152 OAuth2 isteği taşıyor - Kaynak: https://muhammetsafak.com/tr/research/go-php-oauth2-cekirdek-basina-kapasite/ Saniyede on binlerce isteği karşılayacak bir OAuth2 servisi için Go önerdiğimde ekibin sorusu haklıydı: ne kadar kazanıyoruz? "Go daha hızlı" cevabı bu soruyu karşılamıyor, çünkü kapasite tablosuna yazılan şey saniyede istek değil, **çekirdek**. Bu yüzden aynı API'yi üç kez yazdım ve üçünü de bir, iki ve dört çekirdekte, yarısı okuma yarısı yazma olan aynı trafiğin altında ölçtüm. Ham koşuların hepsi [php-go-bench](https://github.com/muhammetsafak/php-go-bench) deposunda duruyor. ## Ne ölçtüm Üç uç nokta, üç adayda da birebir aynı sözleşmeyle çalışıyor. Hepsi önce bearer token'ı doğruluyor: RS256 imzası, `iss`, `aud`, `exp`, `nbf` ve uç noktaya göre `scope`. Doğrulama hiçbir adayda önbelleğe alınmıyor. | Aday | HTTP | Veritabanı idiom | Havuz boyutu (1c / 2c / 4c) | | --- | --- | --- | --- | | Go | net/http | pgx, bağlantı başına statement önbelleği | 16 / 64 / 64 | | FrankenPHP (worker) | Caddy, gömülü | PDO, worker ömrü boyunca hazırlanmış statement | 8 / 8 / 8 | | PHP-FPM | nginx, aynı çekirdek bütçesinde | PDO, tek gidiş-dönüşlük parametreli çağrı | 8 / 16 / 32 | Framework yok. İki PHP adayı aynı Api.php sınıfını çalıştırıyor, yalnız giriş dosyaları farklı. Her aday, süreç ya da istek ömrünün izin verdiği en hızlı veritabanı idiom'unu kullanıyor. Yük karma: iki open-loop üreteç aynı anda koşuyor, biri `GET /events/{id}` ile milyon satırlık tablodan birincil anahtarla tek satır okuyor, diğeri `POST /events` ile tek `INSERT … RETURNING` yazıyor. İkisi de kendi access token'ını gönderiyor. **Burada yayınlanan "kapasite" bir doyum tavanıdır, bir servis seviyesi değildir.** Yani adayın yanıtları yetiştirdiği en yüksek hızdır; "p99 şu eşiğin altında kalarak" güvencesi taşımaz. Bunu ayrıca ölçmeye üç kez çalıştım ve üçünde de bu tezgâhta güvenilmez sonuç aldım; nedeni aşağıda, sınırlar bölümünde. Tavanın yanına o tavandaki gecikmeyi de bastım, böylece sayının neye mal olduğunu görebilirsiniz. ## Çekirdek başına kapasite **Karma OAuth2 trafiğinde doyum tavanı** Her sütun, o adayın o çekirdek bütçesinde yetiştirdiği toplam istek/sn. Okuma ve yazma birlikte. Kaynak: Hücre başına 5 tekrar, üreteç yoklamasından geçenlerin en iyisi — bench/capacity-report.mjs | | 1 çekirdek | 2 çekirdek | 4 çekirdek | | --- | --- | --- | --- | | Go | 16883 | 29449 | 57321 | | FrankenPHP (worker) | 6544 | 14306 | 25659 | | PHP-FPM | 6218 | 11211 | 20606 | Dört çekirdekte Go, PHP-FPM'in **2,78 katını**, FrankenPHP'nin 2,23 katını taşıyor. Çekirdek başına çevirdiğinizde fark daha okunur hâle geliyor: Go 14.330, FrankenPHP 6.415, PHP-FPM 5.152 istek. Üçü de çekirdek eklendikçe doğrusala yakın ölçekleniyor ama hiçbiri tam doğrusal değil. Bir çekirdekten dörde çıkarken Go teorik dördün %84,9'unu, PHP-FPM %82,8'ini alıyor. FrankenPHP'nin %98'i yanıltıcı: paydası, tek çekirdekte zaten düşük olan kendi rakamı. | Aday | Tavan (4c) | Çekirdek başına | Tavandaki p50 (okuma/yazma) | Tavandaki p99 | Tepe RSS | | --- | --- | --- | --- | --- | --- | | Go | 57.321/sn | 14.330 | 1,81 / 2,42 ms | 4,69 / 5,41 ms | 30,4 MiB | | FrankenPHP (worker) | 25.659/sn | 6.415 | 4,77 / 4,96 ms | 8,80 / 9,06 ms | 60,3 MiB | | PHP-FPM | 20.606/sn | 5.152 | 6,03 / 6,24 ms | 8,27 / 8,45 ms | 60,0 MiB | Doyum tavanındaki gecikme — yani adayın en zorlandığı andaki hâli. Go tavanını gecikmeyle satın almıyor: en yüksek hızı verirken aynı zamanda en düşük p99'u veriyor. ## Tabloya yazacağınız sayı bu değil Dört çekirdekteki 57.321 yalnız dört çekirdek için doğru. Kendi hedefinize taşıyan sayı çekirdek başına istek değil, **istek başına CPU**: **İstek başına uygulama CPU'su, doyum tavanında** Adayın cgroup CPU sayacı, cevaplanan istek sayısına bölündü. Veritabanının CPU'su dahil değil. Kaynak: Doyum tavanı koşuları, en iyi geçerli tekrar | | 1 çekirdek | 2 çekirdek | 4 çekirdek | | --- | --- | --- | --- | | Go | 57.8 µs | 63.65 µs | 66.77 µs | | FrankenPHP (worker) | 82.77 µs | 100.68 µs | 110.74 µs | | PHP-FPM | 157.07 µs | 173.83 µs | 187.74 µs | Bu sayı hedef hızla çarpıldığında gereken çekirdeği veriyor. Saniyede 30.000 karma istek için Go'da hesap 30.000 × 66,8 µs = **2,0 çekirdek**, FrankenPHP'de 3,3, PHP-FPM'de 5,6. Üç adayda da istek başına CPU çekirdek sayısı arttıkça **yükseliyor** — Go'da 57,8'den 66,77'ye, PHP-FPM'de 157,07'den 187,74'e. Bu, ölçeklemenin neden doğrusal olmadığının sayaç tarafındaki karşılığı: eşzamanlılık arttıkça bağlam değişimi, kilit ve önbellek kaçırma maliyeti istek başına payı büyütüyor. Planlama yaparken hedeflediğiniz instance boyutunun sütununu kullanın, tek çekirdeğin sayısını dörtle çarpmayın. ## FrankenPHP'nin kullanamadığı çekirdekler FrankenPHP'nin istek başına CPU'su PHP-FPM'inkinin %41 altında (110,74'e karşı 187,74 µs). Bu tasarruf kapasiteye tam dönüşmüyor, çünkü doyduğunda çekirdeklerini bitiremiyor: | Aday | 1 çekirdekte kullanılan | 2 çekirdekte | 4 çekirdekte | | --- | --- | --- | --- | | Go | 0,98 (%98) | 1,87 (%93,5) | 3,83 (%95,8) | | PHP-FPM | 0,98 (%98) | 1,95 (%97,5) | 3,87 (%96,8) | | FrankenPHP (worker) | 0,54 (%54) | 1,44 (%72) | 2,84 (%71) | Doyum anında adayın cgroup'unun gerçekten harcadığı çekirdek. Go ve PHP-FPM verilen bütçeyi bitiriyor; FrankenPHP tek çekirdekte yarısını, dört çekirdekte 1,16'sını boşta bırakıyor. Tek çekirdekte FrankenPHP, PHP-FPM'den yalnız %5 daha fazla istek taşıyor (6.544'e karşı 6.218) — oysa istek başına CPU'su neredeyse yarısı. Aradaki kazancın tamamı boşta kalan çekirdekte kayboluyor. İki ve dört çekirdekte darboğaz gevşiyor ve fark açılıyor (%28 ve %25), ama neredeyse üçte birlik bütçe hâlâ kullanılmıyor. Darboğazın nerede olduğunu bu kayıt ölçmedi. Havuz boyutu değil: 8, 16, 32 ve 64 worker denendi ve FrankenPHP her çekirdek bütçesinde **8**'i seçti, yani daha fazla eşzamanlılık ona yardım etmiyor. Caddy ile PHP thread'leri arasındaki geçişte ya da ZTS derlemesinde olabilir. Pratik sonuç şu: FrankenPHP'ye geçmek aynı işi daha az CPU zamanıyla yapıyor, ama ödediğiniz çekirdeğin neredeyse üçte birini size geri vermiyor. ## Darboğaz veritabanı değil FrankenPHP'nin boşta bıraktığı 1,16 çekirdek uygulama tarafında. Peki aynı yükü altta taşıyan PostgreSQL nerede duruyor? | Ölçüm | Değer | Kullanılan çekirdek (4 üzerinden) | | --- | --- | --- | | PostgreSQL tek başına, pgbench INSERT | 68.212 satır/sn | 3,45 | | Go doyarken veritabanının yaptığı iş | 24.930 yazma + 32.392 okuma/sn | 2,34 | | FrankenPHP doyarken | 12.575 yazma + 13.084 okuma/sn | 1,11 | | PHP-FPM doyarken | 10.128 yazma + 10.478 okuma/sn | 1,46 | pgbench satırı HTTP ve token olmadan, aynı satırı aynı tabloya aynı dayanıklılık ayarıyla yazıyor. Üç adayın hiçbiri veritabanını doyurmuyor. En hızlı aday tavanındayken bile PostgreSQL dört çekirdeğinin 2,34'ünü kullanıyor ve kendi tavanı o noktanın epey üstünde. Yani bu iş yükünde seçim tamamen uygulama runtime'ında: veritabanını değiştirmek kapasite tablosunu kıpırdatmıyor, dili değiştirmek 2,78 kat kıpırdatıyor. Bir ayrıntı yine de veritabanı tarafında görünüyor. İstek başına PostgreSQL CPU'su Go'da 40,75 µs, FrankenPHP'de 43,2 µs, PHP-FPM'de 71,07 µs. Statement'ı tekrar kullanabilen iki aday birbirine yakın; kullanamayan PHP-FPM veritabanına istek başına %64 ile %74 arasında (sırasıyla FrankenPHP ve Go'ya göre) daha fazla iş yaptırıyor. Muhtemel sebep her sorgunun yeniden ayrıştırılıp planlanması; bu kayıt bunu ayrıca ölçmedi. ## Her adaya kendi en iyi havuzu Bu tür karşılaştırmalara gelen ilk itiraz "PHP'yi kötü ayarlamışsın" olur. Bu yüzden havuz boyutu ölçümün öncesinde ayrı bir faz olarak süpürüldü: her aday, her çekirdek bütçesinde 8, 16, 32 ve 64 ile ikişer kez tam gaz koşturuldu ve kendi en iyisiyle ölçüme girdi. Havuz boyutu aynı zamanda veritabanı bağlantı tavanıdır, yani üç adayda da tek bir düğme. | Aday | 1 çekirdek | 2 çekirdek | 4 çekirdek | En iyinin en kötüye farkı | | --- | --- | --- | --- | --- | | Go | 16 | 64 | 64 | %70,6 (4c: 8 worker 32.666, 64 worker 55.715) | | PHP-FPM | 8 | 16 | 32 | %30,7 (1c: 64 worker 4.734, 8 worker 6.188) | | FrankenPHP (worker) | 8 | 8 | 8 | %29,6 (2c: 32 worker 11.252, 8 worker 14.580) | Seçilen havuz boyutları ve o hücrede en iyi ile en kötü seçim arasındaki fark. Üç adayda da en yüksek throughput'u veren boyut aynı zamanda en iyi p99'u verdi, yani seçim ölçütü gecikmeyi feda etmiyor. Sabit bir sayı üçünden hiçbirinin en iyisi değil ve yön de aynı değil: Go çekirdek arttıkça daha fazla eşzamanlılık istiyor, iki PHP adayı istemiyor. Tek çekirdekte 64 worker'lı php-fpm, 8 worker'lıdan **%24 daha az** iş çıkarıyor. ## Hedefinizi çekirdeğe çevirmek | Hedef hız | Go | FrankenPHP | PHP-FPM | | --- | --- | --- | --- | | 10.000 istek/sn | 1 | 1 | 1 | | 20.000 istek/sn | 1 | 1 | 1 | | 30.000 istek/sn | 1 | 2 | 2 | | 50.000 istek/sn | 1 | 2 | 3 | | 100.000 istek/sn | 2 | 4 | 5 | Dört çekirdekli instance sayısı, ölçülen tavana bölünüp yukarı yuvarlanarak. Yatay ölçeklemenin doğrusal olduğunu ve baş üstü boşluk bırakmadığınızı varsayıyor; veritabanı çekirdekleri dahil değil. > **Sonuç** > > Hedefiniz saniyede 20.000 karma isteğin altındaysa üç aday da tek bir dört > çekirdekli instance'a sığıyor ve runtime seçimi kapasite tablonuzda bir satır > bile değil. 30.000'in üstünde fark makineye dönüşüyor, 100.000'de Go'ya > ayıracağınız iki instance'a karşı PHP-FPM'e beş instance gerekiyor. Kendi > hesabınız için: hedef hız × istek başına CPU = çekirdek; FrankenPHP'de buna > boşta kalan yaklaşık %30 de ekleyin. ## Nasıl ölçtüm - **Bütçe.** Docker VM'inin 12 vCPU'su ayrık üç kümeye bölündü: aday 0-3, PostgreSQL 4-7, yük üreteci 8-11. php-fpm'in nginx'i de adayın bütçesinin içinde; Go ve FrankenPHP HTTP'yi kendileri karşılıyor. Bellek her çekirdek bütçesinde 1 GiB sabit, böylece eksen yalnız çekirdek. - **Her hücre aynı yerden başlıyor.** Her hücre, tohumlanmış veritabanının `CREATE DATABASE … TEMPLATE` ile alınmış bir kopyasıyla başlıyor; kopya `pg_prewarm` ile önbelleğe çekiliyor, `CHECKPOINT` alınıyor ve taze bir aday konteyneri açılıp ısıtılıyor. - **Üreteç yoklaması.** Her tekrardan önce, uygulama kodu çalıştırmayan bir nginx adayın kendi çekirdeklerine konuyor ve aynı iki üreteçle adayın beklenen tavanının %115'i isteniyor. Yoklamanın altında kalan tekrar adayı değil üreteci ölçmüş olur ve elenir. 45 tekrarın 45'i geçti. - **Tahminci en iyi tekrar.** Ölçüm bir dizüstünde koşuyor ve makinedeki her iş ölçümden düşüyor; girişim yalnız aşağı çekebilir. Bu yüzden her tekrarın yanına macOS'un o anki yükü de kaydedildi. - **Bağımsız doğrulama.** Aynı tavanlar, on iki saat önce farklı bir bağlantı sayısıyla koşan havuz süpürmesinde de ölçüldü ve iki bağımsız ölçüm birkaç yüzde içinde örtüştü. ## Sınırlar ve dürüstlük notları 1. **Servis seviyeli kapasite ölçülemedi.** "p99 10 ms'nin altında kalarak en fazla kaç istek" sorusunu üç ayrı yöntemle ölçmeye çalıştım; üçü de bu tezgâhta güvenilmez çıktı ve elendi. Sebep, open-loop ölçümde sınırın bir eğim değil **uçurum** olması: servis yavaşlayınca yük üreteci takvimden geri kalıyor ve gecikme düzeltmesi bunu üste yazıyor; uçurumun yeri de tekrarlar arasında geziyor. Aynı hücrenin tekrarları beş kata kadar ayrıştı. Elenen üç denemenin ham verisi ve teşhisi [EXCLUDED.md](https://github.com/muhammetsafak/php-go-bench/blob/main/results/capacity-2026-09-17/EXCLUDED.md) dosyasında duruyor. Bu yüzden yayınlanan sayı doyum tavanıdır ve `confidence` alanı `medium`. 2. **Tek makine, dizüstü.** 12 vCPU'nun tamamı Docker VM'ine verildiği için macOS'ta koşan her iş doğrudan ölçümden düşüyor. Bu bir varsayım değil, ölçülmüş bir olay: bir koşu sırasında ana makinede headless Chromium süreçleri on çekirdeğin üstünü kullandı ve aynı Go servisi 57.321 yerine 17.490 istek/sn ölçüldü. O koşu atıldı, yoklama kapısı ve ana makine yükü kaydı bu yüzden eklendi. 3. **Karışım %50/%50.** Kendi trafiğiniz okuma ağırlıklıysa üç adayın da tavanı yükselir, çünkü okuma, yazmadan ucuz. Oran sizin profilinizle değişir; istek başına CPU'yu okuma ve yazma için ayrı ayrı ölçmek bu kaydın kapsamında değildi. 4. **Disk garantisi doğrulanmadı.** Docker Desktop VM diskindeki `fsync`'in çıplak donanımla aynı garantiyi verip vermediğini kontrol etmedim. Mutlak yazma rakamları iyimser olabilir; üç adayın karşılaştırması etkilenmiyor, çünkü üçü aynı veritabanına yazıyor. 5. **Kapsam dışı.** Framework'ler, PHP JIT, Swoole ile RoadRunner, PostgreSQL'e alternatif veritabanları, token üreten authorization server ve kodu Go'ya taşımanın ekip maliyeti ölçülmedi. Framework'lerin kendi maliyeti [PHP framework yük testi](/tr/research/php-framework-yuk-testi/) kaydında. Kendi sayınızı üretmek için [php-go-bench](https://github.com/muhammetsafak/php-go-bench) deposunu kendi token biçiminiz ve kendi sorgunuzla, kendi donanımınızda koşturun. Tablonuza yazacağınız değer istek başına CPU; gerisi çarpma. ## Partial index on beş dakikada üç yüz beş kat şişti — ve autovacuum bir kez bile gelmedi - Kaynak: https://muhammetsafak.com/tr/research/kuyruk-tablosunda-bloat-ve-autovacuum/ [Partial index ölçümü](/tr/research/partial-index-kuyruk-tablosu/) otuz saniyelik pencerelerle çalıştı ve sınırlar kutusunda şunu yazdı: *"Autovacuum açık bırakıldı; 30 saniyelik koşular onun uzun vadeli etkisini ölçmeye yetmez, bu ayrı bir kayıt konusu."* Bu o kayıt. Sınanan iddia, Sor Bakalım cevabının tek ölçülmemiş maddesiydi: *"Bir satır pending'den done'a döndüğünde partial index'ten düşer — bu iyi. Ama yoğun update trafiği bloat üretebilir; autovacuum'un sağlıklı çalışması önemli. İyi haber: index küçük olduğu için vacuum'u da ucuzdur."* ## Bloat eğrisi **Index boyutu, sürekli churn altında** Kuyruk derinliği yaklaşık 845. saniyeye kadar beş bin satır civarında sabit. Büyüyen tek şey ölü index girdileri. Kaynak: pg_relation_size, 15 saniyede bir örnek, bench/endurance.sh | | 0 sn | 135 | 286 | 436 | 587 | 738 | 888 | | --- | --- | --- | --- | --- | --- | --- | --- | | partial | 0.1 MB | 5.9 MB | 12.4 MB | 18.8 MB | 25.3 MB | 31.7 MB | 38.2 MB | | composite | 301 MB | 321 MB | 344 MB | 366 MB | 388 MB | 409 MB | 427 MB | Partial index **üç yüz beş kat** büyüdü. Composite **%42**. Mutlak artış composite'te daha fazla (+126 MB), oransal olarak partial'da karşılaştırılamaz. Sebep tek cümlede duruyor: `pending → done` geçişi satırı partial index'ten düşürüyor, ama ölü girdi vacuum gelene kadar orada kalıyor. Yani index'in **küçüklüğü** canlı satır sayısından, **şişme hızı** iş hacminden geliyor — ve ikisi arasında hiçbir bağ yok. Beş bin satırlık bir kuyruk, saniyede iki bin iş işlerken, on beş dakikada otuz sekiz megabaytlık ölü girdi biriktiriyor. > **Sonuç** > > Cevabın "index küçük olduğu için vacuum'u da ucuzdur" cümlesi doğru ama eksik. > Vacuum ucuz — ama **sık** gerekiyor, çünkü bloat oranı index boyutuna değil > throughput'a bağlı. Küçük index, az vacuum demek değil; ucuz vacuum demek. ## Autovacuum hiç gelmedi On beş dakikada 1.753.949 ölü satır birikti. Autovacuum sayacı: **0.** Aritmetik acımasız: ``` eşik = autovacuum_vacuum_threshold + scale_factor × canlı satır = 50 + 0,2 × 10.005.000 ≈ 2.001.050 ölü satır ``` Eşiğin hemen altında kaldık. Varsayılan ayarlarla bu tablo autovacuum'u yaklaşık **on yedi dakikada bir** tetikleyecek ve arada index'ler serbestçe şişecek. Asıl mesele oran değil, **ölçekleme yönü**: eşik tablonun tamamına göre büyüyor, churn ise küçük canlı kümede oluyor ve tablo büyüdükçe hızlanmıyor. Yani tablo ne kadar büyükse autovacuum o kadar geç geliyor. > **Sınır** > > Kuyruk tablosunun en çok vacuum'a ihtiyaç duyduğu an, varsayılan ayarların ona > en az vacuum verdiği andır. `autovacuum_vacuum_scale_factor`'ı tablo bazında > düşürmek (ör. 0,01) ya da `autovacuum_vacuum_threshold`'u sabit bir sayıya > çekmek bu tabloya özgü bir ayar gerektiriyor — global varsayılan bu erişim > deseni için tasarlanmamış. ## Şişen index sürdürülebilir yükü taşımıyor Her iki koşu da saniyede 2.000 iş hedefiyle başladı. | Strateji | Başlangıç | 900. saniye | Kuyruk derinliği | | --- | --- | --- | --- | | partial | 2.024 tps · 1,7 ms | 2.016 tps · 4.906 ms | 5.003 → 14.364 | | composite | 2.000 tps · 0,52 ms | 1.204 tps · 61.533 ms | 5.000 → 126.024 | Gecikme pgbench'in zamanlama gecikmesini içeriyor: hedeflenen hıza yetişemeyen bir istemcinin borcu buraya yazılıyor. Composite hedefi tutamadı — 2.000'den 1.204 tps'ye düştü, gecikmesi 61 saniyeye çıktı, kuyruğu 126 bine tırmandı. Partial hedef hızında bitirdi ama gecikmesini koruyamadı: 855. saniye civarında o da geriye düşmeye başladı, 4,9 saniyelik gecikme ve 14.364'lük kuyrukla bitirdi. Bu, otuz saniyelik ölçümün **tersi** yönde bir sonuç. Orada ikisi neredeyse eşitti (10.795 vs 11.537 tps). Fark sürdürülebilir yükte açılıyor, büyük olasılıkla 427 megabayta şişmiş index artık belleğe sığmadığı ve taramalar diske gittiği için. Bu koşu cache isabetlerini kaydetmedi, yani neden bir çıkarım; fark ise ölçülmüş. > **Sınır** > > Ölçüm tek makinede, tek Postgres sürümünde ve tek varış hızında alındı. 2.000 > iş/sn bu donanımın tavanının çok altında bir hız — seçilme sebebi tüketicinin > kuyruğu boşaltıp boş tablo ölçmesini engellemek. Daha yüksek hızlarda bloat daha > hızlı birikir ve autovacuum eşiği daha erken gelir; buradaki "on yedi dakika" > sayısı bu iş yüküne özgüdür, evrensel değil. Autovacuum bir kez de koşmadığı > için vacuum'un **maliyeti** ölçülmedi; ölçülen, ne zaman geldiği. ## Cevaba dönüş Sor Bakalım cevabının altı maddesinden beşincisi buydu ve tek sınanmayan oydu. Sonuç: madde doğru ama uyarısı zayıf. "Autovacuum'un sağlıklı çalışması önemli" cümlesi, varsayılan ayarlarla autovacuum'un bu tabloda **sağlıklı çalışmadığını** söylemiyor. Partial index'i seçmenin bedeli iki kalemde: ölü index girdileri iş hacmiyle birikiyor, ve o birikimi temizleyecek mekanizma tablo bazında ayarlanmadıkça gelmiyor. ## Laravel'in preload eğrisi: 123 dosya, 1.912 dosyadan sekiz kat fazla kazandırıyor - Kaynak: https://muhammetsafak.com/tr/research/laravel-preload-egrisi/ [opcache ölçümü](/tr/research/opcache-preload-deploy-faturasi/) Laravel'i iki nokta arasında bıraktı: resmî preload dosyası yok, soğuk ilk istek 76 ms, ve classmap'ten körlemesine üretilen preload 14 ms'ye indiriyor ama php-fpm'in açılışına 2,3 saniye ekliyor. O kayıt bunu "tavan" diye ölçtü. Bu kayıt aradaki eğriyi çıkarıyor — ve tavanın aslında kötü bir seçim olduğunu gösteriyor. ## Eğri **Soğuk ilk istek, preload edilen dosya sayısına göre** Beş varyant iç içe: her nokta bir öncekine dilim ekliyor. Yatay eksen derlenen dosya sayısı. Kaynak: bench/preload-curate.sh, üç tekrarın medyanı | | 0 | 1.592 | 2.686 | 2.809 | 4.721 | | --- | --- | --- | --- | --- | --- | | soğuk ilk istek | 68.37 ms | 38.01 ms | 28.48 ms | 12.77 ms | 10.96 ms | | Dilim | Derlenen | Soğuk istek | fpm hazır | Marjinal kazanç | Marjinal bedel | | --- | --- | --- | --- | --- | --- | | preload yok | 0 | 68,37 ms | 382 ms | — | — | | laravel/framework | 1.592 | 38,01 ms | 1.553 ms | −30,4 ms | +1.171 ms | | + symfony | 2.686 | 28,48 ms | 1.538 ms | −9,5 ms | bedava | | + psr, carbon | 2.809 | 12,77 ms | 1.514 ms | −15,7 ms | bedava | | hepsi | 4.721 | 10,96 ms | 2.691 ms | −1,8 ms | +1.177 ms | Marjinal sütunlar bir önceki satıra göre. “Bedava” = açılış farkı ölçüm gürültüsünün içinde. ## Kaç değil, hangisi Eğrinin şekli hacimle orantılı değil ve bu, kaydın asıl bulgusu. **123 dosya, 1.912 dosyadan sekiz kat fazla kazandırıyor.** `psr` ve `carbon` dilimi yalnız 123 dosya ekliyor ve soğuk isteği 28,48'den 12,77 ms'ye düşürüyor. Ondan sonra gelen 1.912 dosya yalnız 1,8 ms alıyor. Büyük olasılıkla sebep tarih işleme, ama bu koşu bunu ayrı ölçmedi: Laravel'in ilk isteği Carbon'un yükleme zincirini tetikliyor gibi görünüyor ve o zincir derinden bağlantılı — preload edilmediğinde her soğuk başlangıçta yeniden derlenmesi gerekiyor. Bir framework'ün "ağır" olması, çok dosyası olması değil; ilk istekte hangi ağacı yürüdüğü. > **Sonuç** > > Preload'ı hacimle ölçmek yanlış soru. Doğru soru "ilk istek hangi ağacı > yürüyor". Laravel'de o ağacın büyük kısmı 2.809 dosyada bitiyor; kalan 1.912 > dosya isteğin uğramadığı koddur ve preload edilmesi yalnız açılışa yazılıyor. ## "Hepsini derle" en kötü seçim Önceki kayıt classmap tavanını iyimser bir üst sınır gibi ölçmüştü. Eğri onu başka türlü gösteriyor: 4.721 dosya, 2.809 dosyaya göre **1,8 ms** kazandırıp **1.177 ms** açılış ekliyor. Yani son dilimin fiyatı, kazandırdığı milisaniye başına 654 milisaniye. Günde on kez deploy eden bir servis için bu, günde on iki saniyelik bir açılış gecikmesi karşılığında ilk ziyaretçiye 1,8 ms. Sürekli trafiği olan bir serviste bile takas zayıf. ## Symfony'ye yetişemiyor Symfony kendi resmî dosyasıyla **2,50 ms**'ye iniyor. Laravel'in en iyi noktası 10,96 ms — dört kat geride, ve hepsini preload etmesine rağmen. Fark yapısal: Symfony'nin preload dosyası **derlenmiş container**'ından üretiliyor, yani servis tanımlarının çözümü zaten derleme anında yapılmış durumda. Laravel'in service container'ı çalışma anında çözüyor ve preload o işi ortadan kaldırmıyor, yalnız sınıf derlemesini kaldırıyor. Preload PHP kaynağını derlemekten kurtarır, uygulamanın kendi kurulum işinden kurtarmaz. > **Sınır** > > Filtreler bu uygulamanın bağımlılık ağacına göre seçildi; başka bir Laravel > uygulamasında `carbon` dilimi bu kadar belirleyici olmayabilir, ve kendi > paketleriniz eğriye yeni bir dilim ekler. Ölçüm bir liste değil bir **yöntem** > bırakıyor: kendi uygulamanızda `bench/preload-curate.sh`'ı kendi filtrelerinizle > koşup kendi eğrinizi çıkarın. Ayrıca "soğuk" burada opcache'in boş olması demek, > sayfa önbelleğinin soğuk olması değil — tek atışlık bir denemede aynı `preload yok` > varyantı 137 ms verdi, üç tekrarın medyanı 68,37 ms. ## Laravel'in eksik dosyası Bu ölçümün bıraktığı şey bir sayı değil, bir dosya: Laravel için `^/vendor/(laravel\/framework|symfony|psr|nesbot|carbon)/` filtresiyle üretilen preload, 12,77 ms ve 1,5 saniyelik açılışla, framework'ün kendisinin yayınlamadığı şeyi veriyor. Üretici script depoda duruyor ve herhangi bir Laravel uygulamasında koşuyor. ## Partial index kuyruk tablosunu kırk bir kat küçültüyor — planner onu seçtiği sürece - Kaynak: https://muhammetsafak.com/tr/research/partial-index-kuyruk-tablosu/ Ağustos başında [bir soru geldi](/tr/sor-bakalim/sadece-pending-satirlari-taranan-bir-kuyruk-tablosunda-partial-index-kullanmali-miyim/): milyonlarca `completed` satırın arasında birkaç bin `pending` satırı sorgulayan bir kuyruk tablosunda partial index mi, düz index mi? "Evet, bu partial index'in ders kitabı örneğidir" diye cevapladım ve altı madde saydım. Hiçbirini ölçmemiştim. Bu kayıt o cevabı sınıyor. ## Üç kademe, dört strateji | Strateji | 100k ölü satır | 1M | 10M | Index boyutu (10M) | | --- | --- | --- | --- | --- | | index yok | 2.001 tps | 247 tps | 7 tps | — | | (status) | 6.499 tps | 6.417 tps | 6.426 tps | 66,1 MB | | (status, created_at) | 12.414 tps | 11.202 tps | 10.795 tps | 310,4 MB | | partial (created_at) WHERE pending | 13.041 tps | 11.707 tps | 11.537 tps | 7,6 MB | 8 istemci, 30 saniye, üç tekrarın medyanı. Canlı küme her kademede 5.000 satır; değişen tek şey etrafındaki ölü satır sayısı. İlk satır cevabın gerekçesini doğruluyor: index'siz bir kuyruk tablosu ölü satırla birlikte çökmüyor, **yok oluyor**. 2.001'den 7 tps'ye. 10 milyon satırın arasından 5.000 satır bulmak sequential scan ile saniyede yedi kez yapılabilir. Düz `(status)` index'i de iddia edildiği gibi: kardinalitesi iki olan bir kolona index koymak yardım ediyor ama tavanı düşük — üç kademede de 6.400 civarında takılıyor. ## Asıl fark hızda değil, boyutta Partial ile composite arasında throughput farkı %6,9. Küçük. Ama boyut farkı tabloyla birlikte açılıyor: **Index boyutu, tablo büyüdükçe** Composite index bütün satırları indeksliyor, partial yalnız pending olanları. Canlı küme sabit olduğu için partial da sabit kalıyor. Kaynak: pg_relation_size, bench/run.sh | | 100k ölü satır | 1M | 10M | | --- | --- | --- | --- | | (status, created_at) | 11.2 MB | 40.3 MB | 310.4 MB | | partial | 6.5 MB | 7.6 MB | 7.6 MB | Composite index tabloyla büyüyor: 11,2 → 40,3 → 310,4 MB. Partial 7,6 MB'da duruyor, çünkü indekslediği şey tablo değil **kuyruk** — ve kuyruk sabit. On milyon satırlık tabloda **kırk bir kat** fark. Bu, cevabın "küçük olur, belleğe sığar, güncellemesi ucuzdur" cümlesinin sayısal karşılığı. 310 MB'lık bir index'i shared_buffers'da tutmak 7,6 MB'lık birini tutmakla aynı şey değil, ve her insert/update'te güncellenen ağacın boyutu da öyle. > **Sonuç** > > Partial index'i seçme sebebiniz hız olmamalı — orada fark %7. Sebep, index'in > tabloyla birlikte büyümemesi. Kuyruk tablosu tanımı gereği sonsuza kadar büyür; > onu indeksleyen şeyin büyümemesi mimari bir karar. ## Ve sonra planner fikrini değiştiriyor Cevabın ikinci maddesi şuydu: *"`status = $1` gibi parametreyle sorarsanız planner index'i seçemeyebilir; koşulu literal `'pending'` olarak yazın ki eşleşme kanıtlanabilsin."* Uyarıyı yazarken ne kadar ciddi olduğunu bilmiyordum. | 10M ölü satır, parametreyle | tps | Ortalama gecikme | Plan | | --- | --- | --- | --- | | partial · custom plan | 11.752 | 0,68 ms | Limit → LockRows → Index Scan | | partial · generic plan | 7 | 1.113 ms | Limit → LockRows → Sort → Seq Scan | | composite · custom plan | 11.105 | 0,72 ms | Index Scan | | composite · generic plan | 11.416 | 0,70 ms | Index Scan | Aynı tablo, aynı index, aynı sorgu. Değişen tek şey Postgres'in hazırlanmış deyim için genel mi yoksa o çağrıya özel mi plan kullandığı. **Bin altı yüz otuz beş kat.** Partial index generic planda taranmıyor — index tarama sayacı sıfır — ve sorgu index'i hiç olmayan tablonun sayısına düşüyor: 7 tps. Gecikme 0,68 ms'den 1,1 saniyeye çıkıyor. Composite index aynı koşulda hiç etkilenmiyor. Mekanizma plan ağacında görünüyor. Generic plan, `$1`'in ne olduğunu bilmeden kurulur. Partial index'in kullanılabilmesi için planner'ın `$1 = 'pending'` olduğunu **kanıtlaması** gerekir — çünkü index yalnız o satırları içeriyor. Kanıtlayamaz, dolayısıyla index'i eleyip sequential scan'e düşer. Composite index'in kanıtlanacak bir predicate'i yoktur; `status` kolonu index'in içindedir ve `$1` ne olursa olsun index taranabilir. > **Sınır** > > Bu, uyarının kapsamını **daraltıyor**: sorun parametreyle sormak değil, > **predicate'li bir index'i** parametreyle sormak. Cevabın 2. maddesi bu yüzden > düzeltme istiyor — ve düzeltmesi "literal yazın" değil, "predicate'li index'e > parametreyle gitmeyin" ya da "custom plan'da kalın". ## Bunun nasıl production'da patladığı `auto` modunda Postgres ilk beş çalıştırmada custom plan kullanır, sonra generic planın maliyetini karşılaştırıp ona geçebilir. Yani bu profil **ısındıkça bozulur**: uygulama açılır, ilk istekler hızlıdır, worker'lar birkaç dakika çalıştıktan sonra aynı sorgu bin kat yavaşlar. Staging'de görünmez, çünkü staging'de o deyim beş kez koşmadan test biter. > **Sonradan not — 23 Ağustos 2026** > > Yukarıdaki paragraf fazla ileri gitti ve [ölçüldüğünde yanlış çıktı](/tr/research/planner-generic-plana-ne-zaman-geciyor/). > Postgres bu geçişi **kendiliğinden yapmıyor**: partial index'te kırk çalıştırmanın > kırkı da custom plan kaldı (`pg_prepared_statements` sayacı 40/0). Reddetmesinin > sebebi tam olarak buradaki felaket — generic plan index'i kullanamayacağı için > tahmini maliyeti yüksek çıkıyor ve planner onu seçmiyor. Yani aşağıdaki 1.635 > katlık uçurum gerçek ama çitli: ona düşmek için `plan_cache_mode` ayarını elle > `force_generic_plan` yapmak gerekiyor. "Isındıkça bozulur" cümlesi bir çıkarımdı, > ölçüm değildi; bölümün kuralı gereği düzeltmesi de yayınlanıyor. Sor Bakalım'da [buna çok benzeyen başka bir soru](/tr/sor-bakalim/surekli-guncellenen-bir-sessions-tablosunda-stagingde-index-scan-alan-sorgu-productionda/) vardı: staging'de Index Scan alan sorgu production'da neden Seq Scan'e düşüyor? Orada teşhisi bayat istatistik ve tablo bloat'u diye koymuştum. Bu ölçüm üçüncü bir sebep gösteriyor, ve bu sebebin `ANALYZE` ile ilgisi yok. > **Sınır** > > Ölçüm tek makinede, tek Postgres sürümünde (17) ve tek erişim deseninde alındı. > Talep edilen satır `done` yerine `pending`'e döndüğü için insert trafiği yok — > yazma deseni ve talep başına ölü satır aynı, ama gerçek bir kuyruğun üretici > tarafı bu tezgâhta temsil edilmiyor. `force_generic_plan` bir üretim ayarı > değil; gerçek hayatta plan seçimi Postgres'e aittir ve ne zaman geçeceği iş > yüküne bağlıdır. Buradaki iki uç, aradaki gerçek davranışın sınırlarını çiziyor. > Autovacuum açık bırakıldı; 30 saniyelik koşular onun uzun vadeli etkisini > ölçmeye yetmez, bu ayrı bir kayıt konusu. ## Cevabın karnesi | Sor Bakalım cevabındaki iddia | Sonuç | | --- | --- | | Partial index küçük olur, belleğe sığar | doğru — 7,6 MB, tabloyla büyümüyor | | Düz (status) index'inden net üstün | doğru — 11.537 vs 6.426 tps | | ORDER BY kolonunu index'e koyun | doğru — Index Scan sıralamayı bedavaya alıyor | | Parametreyle sorarsanız planner seçemeyebilir | doğru ama dar: sorun predicate'li index | | Churn ve bloat'a dikkat | ölçülmedi — 30 sn autovacuum için kısa | Altı maddenin dördü sınandı. Beşincisi kendi kaydını hak ediyor. Bir tavsiyeyi ölçmenin değeri onu doğrulamak değil — dördü zaten doğruydu. Değeri, doğru olan bir cümlenin **hangi koşulda tersine döndüğünü** bulmakta. ## Postgres partial index'i generic plana çevirmedi: kırk çalıştırma, kırk custom plan - Kaynak: https://muhammetsafak.com/tr/research/planner-generic-plana-ne-zaman-geciyor/ [Partial index ölçümü](/tr/research/partial-index-kuyruk-tablosu/) `plan_cache_mode`'u iki uca sabitledi ve aralarında bin altı yüz otuz beş kat buldu: custom planda 11.752 tps, generic planda 7. Ölçmediği şey herkesin gerçekten koştuğu ayardı — `auto`. O kayıt orada bir cümle kurdu: *"profil ısındıkça bozulur... staging'de görünmez."* Makul bir çıkarımdı ve **yanlış** çıktı. ## Karar tahmin edilmedi, okundu Postgres hazırlanmış bir deyimi ilk beş çalıştırmada custom planla koşar, sonra generic planın **tahmini** maliyetini custom planların ortalamasıyla karşılaştırır ve generic o kadar pahalı görünmüyorsa ona geçer; custom plan ancak generic, her seferinde yeniden planlamayı daha mantıklı kılacak kadar pahalıysa korunur. `pg_prepared_statements` bu kararı sayaç olarak tutuyor, yani zamanlamadan çıkarsamaya gerek yok. | Strateji | İlk generic plan | Son sayaç (custom/generic) | İlk 5 (ms) | Son 5 (ms) | | --- | --- | --- | --- | --- | | partial | hiç | 40 / 0 | 0,36 | 0,20 | | composite | 6. çalıştırma | 5 / 35 | 0,40 | 0,21 | | (status) | hiç | 40 / 0 | 0,98 | 0,78 | Tek oturum, tek hazırlanmış deyim, kırk çalıştırma. Sayaçlar her çalıştırmadan sonra okundu. Composite'in geçişi belgelendiği gibi, tam beşinciden sonra: ``` #5 0,266 ms custom=5 generic=0 #6 0,275 ms custom=5 generic=1 ← geçti #7 0,185 ms custom=5 generic=2 ``` Partial index'te bu hiç olmuyor. Kırk çalıştırma, kırk custom plan. ## Reddetmesinin sebebi felaketin kendisi Planner generic planı seçmiyor çünkü **maliyetini doğru tahmin ediyor**. Generic plan `$1`'in ne olduğunu bilmez; partial index'i kullanabilmek için `$1 = 'pending'` olduğunu kanıtlaması gerekir, kanıtlayamaz, dolayısıyla o plan sequential scan'e düşer ve tahmini maliyeti tavan yapar. Custom planların ortalaması bunun çok altında kalınca karşılaştırma her seferinde custom lehine sonuçlanıyor. > **Sonuç** > > Uçurum gerçek ama çitli. Maliyet modeli tam olarak sizi ondan uzak tutmak için > çalışıyor ve bu işi kırk çalıştırma boyunca hiç şaşırmadan yapıyor. Ona düşmek > için `plan_cache_mode = force_generic_plan` yazmanız — yani ayarı elle kapatmanız > — gerekiyor. ## Gerçek takas daha küçük ve başka yerde Korumanın bir bedeli var: partial index'li sorgu **her çalıştırmada yeniden planlanıyor.** Kırk çalıştırma, kırk plan üretimi. Composite altıncıdan sonra aynı planı tekrar kullanıyor. Bu ölçekte fark ölçülemiyor — 0,20 ms'ye karşı 0,21 ms. Ama plan üretimi ucuz bir sorguda ucuz; çok tablolu bir join'de ya da uzun bir `IN` listesinde değil. Partial index'in gizli maliyeti bloat (bkz. [dayanıklılık ölçümü](/tr/research/kuyruk-tablosunda-bloat-ve-autovacuum/)) ve sürekli yeniden planlama; ikisi de bu iş yükünde küçük, ikisi de başka bir iş yükünde küçük kalmayabilir. > **Sınır** > > Ölçüm tek sorgu şekli, tek Postgres sürümü (17) ve sağlıklı istatistiklerle > alındı. Planner'ın kararı **tahmini** maliyete dayanıyor; istatistikler yanlışsa > tahmin de yanlış olur, dolayısıyla "asla geçmez" değil "bu koşullarda geçmedi" > denebilir. Bayat istatistikle aynı ölçümü tekrarlamak ayrı bir kayıt konusu — ve > o senaryo, partial index kaydının fazla alarmcı bulduğum cümlesini haklı > çıkarabilecek tek yol. ## Önceki kaydı düzeltiyor Partial index kaydının "ısındıkça bozulan profil" paragrafı fazla ileri gitmişti ve o kayda bu bulguya işaret eden bir not düşüldü. Ölçümün kendi yayınını düzeltmesi bu bölümün sözleşmesinin gereği: bir sayı yayınlanıyorsa, onu yanlışlayan sayı da yayınlanır. ## Chart.js'i nasıl import ettiğin, ziyaretçiye kaç kilobayt gönderdiğini belirliyor - Kaynak: https://muhammetsafak.com/tr/research/chartjs-import-stratejisi/ Bu sitede yıllardır tek bir kural vardı: **zorunlu istemci JS yok.** Research bölümünü açarken o kuralı bilerek deldim — çok serili bir ölçüm, tooltip'i olmayan bir grafikte okunmuyor. Ama "deldim" ile "önemsedim" arasındaki fark, gönderilen baytı ölçüp ölçmediğinde ortaya çıkıyor. Chart.js'in dokümantasyonu iki yol gösteriyor. Kısa yol: ```ts import Chart from 'chart.js/auto'; ``` Uzun yol: ```ts import { Chart, BarController, BarElement, CategoryScale, LinearScale } from 'chart.js'; Chart.register(BarController, BarElement, CategoryScale, LinearScale); ``` İkisi de çalışıyor. Sorum şuydu: aradaki fark, gerçek bir derlemede ölçülebilir bir sayı mı, yoksa mikro-optimizasyon mu? ## Neyi ölçtüm Sentetik bir bundler kurmadım — çünkü ölçmek istediğim şey "Chart.js ne kadar küçülebilir" değil, **bu depo ne kadar gönderiyor.** O yüzden dört koşunun dördü de sitenin kendi `astro build` hattından geçti: aynı MDX kaydı, aynı grafik bileşeni, aynı Preact adası. Koşular arasında değişen tek şey `src/components/research/chart-render.ts` dosyasının ilk on satırıydı. **Chart.js parçasının gzip boyutu, import stratejisine göre** Dördü de aynı grafiği çiziyor. Fark yalnızca hangi controller ve eklentinin pakete girdiğinde. Kaynak: astro build çıktısı, dist/_astro/chart-render.*.js, gzip -9 | | chart.js/auto | bar+line+tooltip+filler | bar+line, eklentisiz | yalnız bar | | --- | --- | --- | --- | --- | | gzip | 67.8 kB | 58.1 kB | 51.2 kB | 46 kB | Ham (sıkıştırılmamış) boyutlarla birlikte tablo şöyle: | Strateji | Ham | gzip | auto’ya göre | | --- | --- | --- | --- | | chart.js/auto | 204.965 B | 69.466 B | — | | bar + line + Tooltip + Filler | 172.313 B | 59.538 B | −9.928 B (%14,3) | | bar + line, eklentisiz | 149.793 B | 52.378 B | −17.088 B (%24,6) | | yalnız bar | 134.868 B | 47.114 B | −22.352 B (%32,2) | Ham boyut ile gzip boyutu aynı yönde ama aynı oranda hareket etmiyor: sıkıştırma, silinen kodun bir kısmını zaten sıkıştırıyordu. ## Sayı ne söylüyor Seçmeli register, `auto`'ya göre **9,7 kB gzip** kazandırdı. Bu, dokümantasyonun "tree-shaking destekleniyor" cümlesinin arkasındaki gerçek rakam — ve tek başına bakıldığında mütevazı: %14,3. Asıl bilgi ikinci ve üçüncü satırda. `Tooltip` ile `Filler` eklentilerini düşürmek 7,0 kB daha götürüyor; çizgi grafiği hiç kullanmayan bir sayfa 5,1 kB daha kazanıyor. Yani **maliyetin çoğu kütüphanenin çekirdeğinde değil, kullanılmayan çizim türlerinde.** `auto`'nun yaptığı şey de tam olarak bu: hepsini kaydediyor. > **Sonuç** > > Kural olarak şu çıkıyor: `auto`'dan kaçmak tek başına küçük bir kazanç. Kazancı > büyüten şey, sayfanın **gerçekten çizdiği** grafik türünü bilmek ve gerisini > hiç paketlememek. Bu sitede çubuk ve çizgi birlikte kullanılıyor, tooltip'ten > de vazgeçemiyorum — o yüzden 58,1 kB'da duruyorum, 46,0'a inemiyorum. ## Sayfanın tamamı ne ödüyor Chart.js parçası tek başına anlamlı değil; ziyaretçi ada hidratlandığında şunları indiriyor: | Parça | gzip | Ne | | --- | --- | --- | | chart-render | 58,1 kB | Chart.js + tema köprüsü | | preact | 4,9 kB | çalışma zamanı çekirdeği | | hooks | 0,8 kB | useRef / useEffect | | client | 0,8 kB | @astrojs/preact istemci köprüsü | | ChartIsland | 0,6 kB | bileşenin kendisi | | Toplam | 65,3 kB | | Ölçüm, tek grafikli bir research sayfasının ada maliyetidir. `signals.module` parçası derlemede üretiliyor ama sayfa onu hiç istemiyor — @astrojs/preact yalnız `data-preact-signals` varsa yüklüyor. > **Sonradan not — 22 Ağustos 2026** > > Bu tablo artık **her ziyaretçinin** ödediği bedeli değil, ödemeyi **seçen** > okuyucunun bedelini anlatıyor. Ada kaldırıldı: grafik `client:*` ile > hidratlanmıyor, statik SVG varsayılan katman ve Chart.js ancak "Etkileşimli > grafiği aç" düğmesine basıldığında dinamik `import()` ile iniyor. Grafikli bir > sayfanın varsayılan istemci JS'i bugün **1,1 kB gzip**. Aşağıdaki rakamlar > ölçüldükleri gün geçerliydi ve değişmedi; değişen, o baytın kime kesildiği — > kararın gerekçesi de zaten bu ölçümdü. Framework tarafı toplamın **%10'undan azı** (6,5 kB). Yani Preact yerine React seçmek burada asıl fark yaratmazdı demek yanlış olur — tam tersi: React'in `react-dom` maliyeti tek başına Chart.js'e yaklaşıyor. Ama bu ayrı bir ölçüm, ayrı bir kayıt. > **Sınır** > > Ölçüm **derleme çıktısının** boyutudur, ziyaretçinin gerçek indirme süresi > değil. Parçalar `/_astro/` altında bir yıl `immutable` cache'leniyor ve yalnız > okuyucu grafiği istediğinde iniyor; yani ilk ziyaretin ötesinde bu bayt bir daha > ödenmiyor. Ayrıca gzip ölçtüm — Cloudflare çoğu > tarayıcıya brotli sunuyor, oradaki rakam daha düşük olacaktır. ## Ne yaptım `chart-render.ts` bu dört satırla açılıyor ve `auto` girişini hiç kullanmıyor: ```ts import { BarController, BarElement, LineController, LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Filler, } from 'chart.js'; Chart.register( BarController, BarElement, LineController, LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Filler, ); ``` `Legend` listede yok: gösterge sunucuda HTML olarak basılıyor. Böylece hem sitenin kendi token'larıyla biçimleniyor hem de JavaScript hiç çalışmasa bile yerinde duruyor — bu sayfadaki grafiğin altındaki "Veri tablosu" katlamasıyla aynı mantık. ## opcache preload deploy faturasını on dört kata kadar siliyor — ama yedi framework'ün beşi onu size vermiyor - Kaynak: https://muhammetsafak.com/tr/research/opcache-preload-deploy-faturasi/ [Ayak izi ölçümü](/tr/research/php-framework-ayak-izi/) bir sayı bırakmıştı: taze bir konteynerin karşıladığı ilk istekte Phalcon 2,2 ms, Laravel 72,2 ms — otuz üç kat. Deploy'dan sonraki ilk ziyaretçinin ödediği derleme faturası bu. Aynı kaydın ortam künyesinde de bir satır vardı: **preload yok.** Bu kayıt o satırı kapatıyor. Tek değişken. ## Kazanç **Deploy sonrası ilk isteğin süresi, preload açık ve kapalıyken** Her adayın en iyi preload varyantı gösteriliyor: Symfony ve CodeIgniter'da kendi resmî dosyaları, diğerlerinde classmap'ten üretilen tavan. Kaynak: bench/preload.sh, üç tekrarın medyanı | | Laravel | Symfony | Laminas | CodeIgniter | Yii2 | Slim | Phalcon | | --- | --- | --- | --- | --- | --- | --- | --- | | preload yok | 76.31 ms | 35.58 ms | 26.7 ms | 20.75 ms | 16.98 ms | 7.46 ms | 2.68 ms | | preload açık | 13.98 ms | 2.5 ms | 3.81 ms | 3.13 ms | 4.8 ms | 1.86 ms | 1.99 ms | | Aday | preload yok | resmî | classmap | En iyi kazanç | fpm bedeli | | --- | --- | --- | --- | --- | --- | | Laravel | 76,31 ms | yok | 13,98 ms | 5,5× | +2.340 ms | | Symfony | 35,58 ms | 2,50 ms | kırıldı | 14,2× | −40 ms | | Laminas | 26,70 ms | yok | 3,81 ms | 7,0× | −7 ms | | CodeIgniter | 20,75 ms | 3,13 ms | 5,29 ms | 6,6× | −12 ms | | Yii2 | 16,98 ms | yok | 4,80 ms | 3,5× | +13 ms | | Slim | 7,46 ms | yok | 1,86 ms | 4,0× | +28 ms | | Phalcon | 2,68 ms | yok | 1,99 ms | 1,3× | +43 ms | fpm bedeli = konteynerin ilk 200 yanıtı verene kadar geçen sürenin preload'sız hâle göre farkı. Negatif değerler ölçüm gürültüsüdür, kazanç değil. Ayak izi kaydının otuz üç kat farkı preload'la kapanıyor: Symfony 2,50 ms'ye, yani Phalcon'un **çıplak** seviyesine iniyor. Fark framework'ün ağırlığından değil, opcache'in soğuk olmasından geliyormuş. ## Ama beşinde yok | Aday | Resmî preload | Nerede | | --- | --- | --- | | Symfony | var | var/cache/prod/*.preload.php (warm-up üretiyor) | | CodeIgniter | var | preload.php (başlangıç şablonunda) | | Laravel | yok | — | | Yii2 | yok | — | | Laminas | yok | — | | Slim | yok | — | | Phalcon | yok | gerekmiyor | Dosyalar imajın içinde arandı, listeye elle yazılmadı: yol bir framework kararıdır ve sürümler arasında taşınır. Tablonun ikinci sütunu birinci sütundan daha çok şey söylüyor. Kazanç var ama **beş adayda kullanıcının kendi yazmasını bekliyor** — ve en çok kazanacak olan Laravel (76,31 ms) tam da o beşin içinde. ## "Hepsini derle" doğru cevap değil Resmî dosyası olmayan framework için akla gelen ilk çözüm şu: Composer zaten bütün sınıfları biliyor, classmap'i gezip hepsini derle. Ölçüm bunu iki farklı biçimde çürütüyor. **Symfony'de konteyner hiç ayağa kalkmıyor.** Tam classmap'i yükleme anında derlemek, env değişkeni çözen sınıflara çarpıyor: ``` NOTICE: PHP message: [critical] Uncaught Exception: Environment variable not found: "DEFAULT_URI". ``` Preload php-fpm'in master sürecinde koşuyor; orada ölen bir şey servisi hiç başlatmıyor. Ölçüm bunu `ready: false` diye kaydediyor. **CodeIgniter'da daha kötü sonuç veriyor.** Resmî dosya 3,13 ms, classmap 5,29 ms. Elle seçilmiş kırk küsur sınıf, körlemesine derlenmiş 479 dosyadan iyi. Preload paylaşılan belleği dolduruyor ve gereksiz sınıflar yer kaplıyor. > **Sonuç** > > Preload bir ayar değil, bir **bakım işi**. Kazancı framework'ün ne kadar ağır > olduğuna değil, birinin oturup hangi sınıfların önden derleneceğini seçmiş > olmasına bağlı. Seçen varsa 14 kat; yoksa ya hiç, ya da kendi yazdığınız ve > kırılabilen bir dosya. ## Bedel kaybolmuyor, yer değiştiriyor Preload derleme işini isteğin içinden alıp php-fpm'in açılışına taşıyor. Küçük preload'larda bu bedel ölçülemiyor; büyük olanda çok net: | Aday | Derlenen dosya | fpm hazır olma süresi | Fark | | --- | --- | --- | --- | | Laravel | 4.721 | 2.755 ms | +2.340 ms | | Laminas | 1.195 | 352 ms | −7 ms | | Yii2 | 992 | 352 ms | +13 ms | | CodeIgniter | 479 | 324 ms | −18 ms | | Slim | 138 | 359 ms | +28 ms | | Phalcon | 3 | 362 ms | +43 ms | Yalnız classmap varyantı; Symfony kırıldığı için listede yok. Laravel'in classmap preload'ı her ziyaretçiden 62 ms alıp deploy'a 2,3 saniye yazıyor. Sürekli trafiği olan bir serviste bu iyi bir takas — ilk ziyaretçi bir kez ödüyor, sonrakiler hiç. Günde on kez deploy eden bir serviste ise takas tersine dönüyor: yirmi üç saniyelik açılış gecikmesi karşılığında birkaç yüz ziyaretçinin 62 ms'si. ## Phalcon negatif kontrol oldu Phalcon'un classmap'inden yalnız **3 dosya** derlendi — sınıfları PHP'de değil C eklentisinde. Kazanç da en düşük: 1,3×. Bu, ölçümün ne ölçtüğünü doğrulayan kontrol: preload PHP kaynak derlemesini siliyorsa, derlenecek PHP kaynağı olmayan adayda etkisiz olmalı. Öyle oldu. > **Sınır** > > "Soğuk" burada **opcache'in boş olması** demek, sayfa önbelleğinin soğuk olması > değil. İlk kez çalıştırılan bir imajın diskten okunması ayrı bir maliyet ve bu > ölçüm onu içermiyor; tek atışlık bir denemede Symfony 76,5 ms verdi, üç > tekrarın medyanı 35,58 ms. Rakamlar tek makinede, tek mimaride (aarch64) ve tek > PHP sürümünde alındı. `fpm bedeli` penceresi nginx'in kendi açılışını da > içeriyor — üç varyantta sabit olduğu için farklar anlamlı, mutlak değerler > değil. Ve `classmap` varyantı kimsenin dağıttığı bir yapılandırma değil; bir > tavan ölçüsü olarak duruyor. ## Ayak izi kaydına geri dönüş O kayıt "disk boyutu hiçbir şey öngörmüyor, gerçekten ayrışan tek şey opcache soğukken ödenen ilk istek" demişti. Bu ölçüm o cümleye bir şart ekliyor: **opcache soğuk kalmak zorunda değil** — ama kalmaması, framework'ünüzün size bir preload dosyası verip vermemesine bağlı. ## PHP ekosistemi yeni sürümü beklemiyor — ama desteklediğini de söylemiyor - Kaynak: https://muhammetsafak.com/tr/research/php-surum-destegi-beyani/ Sormak istediğim şey basitti: PHP 8.4 çıktıktan sonra ekosistem onu ne zaman desteklemeye başladı? Her ekibin sorduğu soru bu — "ne zaman yükseltebilirim". Cevabı `require.php` kısıtlarında aramak makul görünüyordu. Aradığım sayı çıkmadı. Çıkan şey daha ilginç: **o sayının neden aranamayacağı.** ## İlk cevap: kimse beklememiş Ham tarama şunu dedi: 500 paketin 481'i PHP 8.4'ü **daha çıkmadan önce** kabul ediyordu. Kurulum payıyla %99,7. Gerçekten bekleyip sonradan kısıtını genişleten paket sayısı sıfır. Bu sonuç doğru ama işe yaramaz, çünkü "kabul ediyordu" cümlesinin altında şunlar var: | Paket | Kabul eden ilk sürüm | Tarih | Kısıt | | --- | --- | --- | --- | | symfony/console | 2.0.4 | 2011-09-26 | yok | | psr/log | 1.0.0 | 2012-12-21 | yok | | guzzlehttp/psr7 | 1.0.0 | 2015-05-19 | >=5.4.0 | | psr/container | 1.0.0 | 2017-02-14 | >=5.3.0 | 2011'de yazılmış bir alt sınır, 2024'te çıkan bir sürüm hakkında hiçbir şey söylemez. Composer kurar; kodun çalışacağını kimse iddia etmiyor. 2015'te `>=5.4.0` yazan bir paket PHP 8.4'ü desteklediğini beyan etmiş olmuyor. Sadece bir alt sınır koymuş oluyor. Composer bu ikisini ayırmaz — kurulumu engellemeyen her şey "uyumlu" sayılır — ama okuyucu ayırmak zorunda. ## Kısıtların yarısı üst sınır yazmıyor En çok kurulan 500 paketin 223'ü üst sınırı hiç kapatmıyor. Kurulum payıyla **%56,4**. Bu kısıtlar bugün var olmayan PHP sürümlerini de kabul ediyor: | Paket | Kurulum | Bugünkü kısıt | Ne demiş oluyor | | --- | --- | --- | --- | | symfony/console | 1,18 milyar | >=8.4.1 | PHP 12 dahil her şey | | psr/log | 1,26 milyar | >=8.0.0 | PHP 12 dahil her şey | | symfony/polyfill-mbstring | 1,26 milyar | >=7.2 | PHP 12 dahil her şey | | psr/container | 1,10 milyar | >=7.4.0 | PHP 12 dahil her şey | Symfony 8 PHP 8.4'ün altını kesiyor ama üstünü açık bırakıyor. Bu bir ihmal değil, bilinçli bir politika — ve beyanı geleceğe dönük olarak bilgisiz kılıyor. Bu bir suçlama değil: üst sınır yazmak bakım maliyeti demektir, her yeni PHP sürümünde bütün bağımlılık ağacını dolaşıp sürüm çıkarmak demektir. Açık uç, bu maliyeti reddetmenin makul bir yolu. Ama sonucu şu: **ekosistemin yarısında "destekliyor mu" sorusunun kısıtta bir cevabı yok.** ## Üst sınır yazanlarda tarih anlam kazanıyor PHP 8.4 için 280 paketin kısıtı üstten kapanıyor — `^8.0` ya da `8.0 - 8.4` gibi. Burada tarih bir şey söylüyor, çünkü `^8.0` yazmak bilerek verilmiş ileriye dönük bir taahhüttür: "PHP 8'in tamamı." | PHP | Üst sınır yazan | Önceden taahhüt | Sonradan genişleten | Hiç üst sınır yazmayan | | --- | --- | --- | --- | --- | | 8.2 | 265 | 201 | 64 | 194 | | 8.3 | 274 | 223 | 51 | 199 | | 8.4 | 280 | 247 | 33 | 202 | Taahhüt edenlerin büyük çoğunluğu taahhüdünü sürüm doğmadan veriyor. `guzzlehttp/guzzle` PHP 8.4'ü **Ekim 2020'de** kapsamış — sürümden dört yıl önce, `^7.2.5 || ^8.0` yazarak. `phpunit/phpunit` Ağustos 2020'de, `doctrine/lexer` Mayıs 2020'de. Caret işi yapıyor: bir kez "PHP 8'in tamamı" dediğinizde 8.1, 8.2, 8.3 ve 8.4 için bir daha dosyaya dokunmuyorsunuz. > **Sonuç** > > Ekosistemin yeni bir PHP **minor** sürümünü hızlı benimsemesi bir çeviklik > göstergesi değil, caret'in mekanik sonucu. Bu mekanizma PHP 9'da sıfırlanacak: > `^8.0` yazan her paket 9.0'ı kabul etmeyecek ve aynı 280 paket aynı anda karar > vermek zorunda kalacak. Bu kayıt, o gün tekrar koşulacak bir ölçüm bırakıyor. ## Bekleyenler ne kadar bekliyor Gerçekten sonradan genişleten 33 paketin medyanı **325 gün**. Bir yıldan fazla. | Paket | Kurulum | Gecikme | Genişlettiği kısıt | | --- | --- | --- | --- | | guzzlehttp/promises | 1,06 milyar | 546 gün | ^7.2.5 \|\| ^8.0 | | theseer/tokenizer | 822 milyon | 362 gün | ^7.2 \|\| ^8.0 | | myclabs/deep-copy | 923 milyon | 253 gün | ^7.1 \|\| ^8.0 | | nette/utils | 454 milyon | 194 gün | 8.0 - 8.4 | Bu paketlerin çoğu test ve altyapı katmanında duruyor: doğrudan yazdığınız kod değil, bağımlılığınızın bağımlılığı. ## Trend diye okunan şey sansür Ham medyanlar sürümden sürüme düşüyor: 8.2 için 559 gün, 8.3 için 418, 8.4 için 325. "Ekosistem hızlanıyor" cümlesi buradan çıkar ve yanlıştır. Sebep basit: 8.2'yi üç yıl sekiz aydır izliyoruz, 8.4'ü bir yıl dokuz aydır. 8.4 için önümüzdeki bahar kısıtını genişletecek bir paket bugün görünmüyor, yani 8.4'ün medyanı dağılımın yalnız hızlı yarısından geliyor. Üçünü de en kısa pencereye (639 gün) kırpınca: | PHP | Ham medyan | İlk 639 günde genişleten | O pencerede medyan | | --- | --- | --- | --- | | 8.2 | 559 gün | 35 | 238 gün | | 8.3 | 418 gün | 33 | 215 gün | | 8.4 | 325 gün | 33 | 325 gün | Trend kaybolmuyor, yön değiştiriyor: eşit sürede bakınca 8.4 öncekilerden daha yavaş benimsendi. > **Sınır** > > Bu kaydın ölçtüğü şey **beyan edilen** destektir, sınanmış destek değil. `^8.0` > kısıtı 8.4'ü kabul eder; paketin 8.4'te çalıştığını söylemez. Evren de "en çok > kurulan" paketlerdir — geliştiricinin karşılaştığı ekosistemin makul bir > örneği, Packagist'in tamamının değil. Uzun kuyruktaki bir paketin eski bir > kısıtta oturma ihtimali çok daha yüksek, dolayısıyla buradaki sayılar > ekosistemin **iyimser** ucudur. Son olarak `guzzlehttp/promises` gibi tek tek > paketlerin gecikmeleri bakımcıya bir not değil: üst sınır yazmak gönüllü bir > bakım yüküdür ve yazmayanların çoğu bunu bilerek yapıyor. ## Pratikte ne işe yarıyor Bir yükseltmeyi planlarken `composer why-not php 8.4` çıktısına bakarsınız ve orada duran paketlerin çoğu bu taramanın "üst sınır yazan" kohortundan gelir. Diğer yarısı hiç görünmez — sizi engellemedikleri için değil, engelleyip engellemeyeceklerini kimsenin bilmediği için. Beyan yokluğu bir yeşil ışık değil, ölçülmemiş bir risk. Ölçümün kendisi tekrar edilebilir: depoda `scan.mjs` ve 22 Ağustos 2026 koşusunun ham JSON'u duruyor. PHP 9 çıktığında aynı komut, aynı 500 paket üzerinde çok farklı bir tablo verecek — ve asıl sınav o olacak. ## Bir PHP framework'ünü kurmanın sabit maliyeti: disk boyutu hiçbir şey söylemiyor - Kaynak: https://muhammetsafak.com/tr/research/php-framework-ayak-izi/ [Yük ölçümü](/tr/research/php-framework-yuk-testi/) framework'lerin saniyede kaç isteği karşıladığını söylüyor. Bu kayıt daha sessiz bir soruyu kovalıyor: o framework'ü kurmanın **sabit** maliyeti ne? Diskte ne kadar yer tutuyor, istek başına kaç dosya açıyor, ve deploy'dan sonraki ilk ziyaretçi ne ödüyor? Sorunun pratik tarafı şu: bu sayılardan hangisine bakarak bir framework'ün "ağır" olduğuna karar verebilirsiniz? Cevap, sandığımdan kısa çıktı. ## Diskteki boyut hiçbir şey öngörmüyor | Aday | Paket | vendor | Dosya | İstek başına yüklenen | Tepe bellek | composer install | | --- | --- | --- | --- | --- | --- | --- | | Phalcon | 0 | 0 MB | 12 | 7 | 502 kB | 0,2 sn | | Slim | 12 | 0,9 MB | 229 | 79 | 398 kB | 0,5 sn | | CodeIgniter | 3 | 4,6 MB | 781 | 96 | 535 kB | 0,6 sn | | Laminas | 23 | 7,7 MB | 1.362 | 269 | 607 kB | 0,6 sn | | Symfony | 51 | 15,8 MB | 3.362 | 224 | 517 kB | 0,9 sn | | Laravel | 76 | 29,6 MB | 6.447 | 359 | 703 kB | 1,2 sn | | Yii2 | 25 | 34,1 MB | 2.927 | 62 | 815 kB | 0,9 sn | vendor boyutuna göre sıralı. Son iki sütun aynı yönde hareket etmiyor: Yii2 en büyük ağacı diske yazıyor ve istek başına PHP ile yazılmış altı framework içinde en az dosyayı açıyor. Tablodaki en öğretici satır **Yii2**. 34,1 MB'lık vendor ağacıyla sahanın en büyüğü — Laravel'den bile büyük — ama bir isteği karşılarken yalnız **62 dosya** yüklüyor. Laravel 359, Laminas 269, Symfony 224 yüklüyor. Yii2'nin diske yazdığı şeyin çoğu bir isteğin yolunda hiç durmuyor. Tersi de doğru: **CodeIgniter** yalnız 3 composer paketiyle geliyor, Composer kullanan altı framework içinde en sade bağımlılık ağacı. Ama bu sadelik hıza dönüşmüyor. ## İstek başına dosya da öngörmüyor **Bir isteği karşılarken yüklenen dosya sayısı** Sezgi 'az dosya = hızlı' der. CodeIgniter 96 dosyayla 6.431 istek/sn veriyor, Symfony 224 dosyayla 13.067 — iki katı dosya, iki katı istek. Kaynak: get_included_files(), ısınmış konteynerde, tek istek | | Phalcon | Yii2 | Slim | CodeIgniter | Symfony | Laminas | Laravel | | --- | --- | --- | --- | --- | --- | --- | --- | | yüklenen dosya | 7 | 62 | 79 | 96 | 224 | 269 | 359 | Yük ölçümündeki sayıları bu tablonun yanına koyunca ilişki dağılıyor. CodeIgniter istek başına 96 dosya yükleyip **6.431 istek/sn** veriyor; Symfony 224 dosya yükleyip **13.067** veriyor. İki katından fazla dosya açan framework, iki katından fazla isteği karşılıyor. Sebebi opcache. `validate_timestamps=0` ile bir dosya bir kez derlenir ve paylaşımlı bellekte kalır; sonraki isteklerde "yüklemek", diskten okumak değil, hazır opcode'a bağlanmak demektir. Dosya sayısı o yüzden **sıcak** yolda ucuz. Nerede pahalı olduğunu görmek için opcache'i boşaltmak gerekiyor. ## Asıl fark: soğuk ilk istek **Taze konteynerin karşıladığı ilk isteğin süresi (opcache boş)** Her deploy'dan sonra ilk ziyaretçinin ödediği derleme faturası. Phalcon'un ödemediği şey bu: framework zaten PHP'ye eklenti olarak yüklü. Kaynak: Taze konteyner, ilk HTTP isteği, front controller'dan yanıta | | Phalcon | Slim | Yii2 | CodeIgniter | Laminas | Symfony | Laravel | | --- | --- | --- | --- | --- | --- | --- | --- | | soğuk ilk istek | 2.2 ms | 7.9 ms | 17.4 ms | 22.2 ms | 27.7 ms | 35.7 ms | 72.2 ms | Soğuk ile sıcak arasındaki oran, framework'ün derleme faturasını doğrudan veriyor: | Aday | Soğuk ilk istek | Sıcak istek | Oran | | --- | --- | --- | --- | | Phalcon | 2,2 ms | 0,41 ms | 5× | | Slim | 7,9 ms | 0,35 ms | 23× | | Yii2 | 17,4 ms | 0,45 ms | 39× | | CodeIgniter | 22,2 ms | 1,04 ms | 21× | | Laminas | 27,7 ms | 0,79 ms | 35× | | Symfony | 35,7 ms | 0,69 ms | 52× | | Laravel | 72,2 ms | 1,26 ms | 57× | Aynı istek, aynı konteyner. Tek fark opcache'in dolu olup olmaması. > **Sonuç** > > Bir framework'ün "ağırlığını" ölçmek istiyorsanız bakılacak sayı vendor > klasörünün boyutu değil, opcache soğukken ödenen ilk istek. Laravel'de bu 72 > milisaniye, Phalcon'da 2. Aradaki 70 ms, her deploy'dan sonra ve opcache her > sıfırlandığında yeniden ödenir. Opcache paylaşımlı bellekte yaşar; havuz her dosyayı deploy başına bir kez derler, worker başına değil. Trafiği yoğun bir sitede bu kısa bir sıçrama olarak görünür ve kaybolur; günde birkaç ziyaretçi alan bir sayfada ilk ziyaretçi bunu her seferinde öder. ## Tepe bellek: en az beklenen sıra Tablodaki en ters sonuç bellekte. İstek başına en çok dosya yükleyen Laravel (359 dosya) 703 kB tepe bellek kullanıyor; PHP kaynaklı altı framework içinde en az dosya yükleyen Yii2 (62 dosya) **815 kB** ile sahanın en yükseği. Dosya sayısı burada da yanıltıyor: bellek, kaç dosyanın açıldığına değil, o dosyaların kaç nesne kurduğuna bağlı. Slim 398 kB ile en düşüğü — beklendiği gibi, çünkü kurduğu nesne az. Phalcon'un 502 kB'ı ise ilginç: PHP tarafında neredeyse hiç kod çalıştırmıyor ama nesneleri yine de PHP'nin bellek alanında kuruyor. ## Atlanmayan bulgular 1. **Phalcon'un `composer install`'ı 0,2 saniye çünkü kuracak bir şey yok.** Depoda 12 dosya var ve `composer.lock` boş bir paket listesi taşıyor. Framework, PHP'ye yüklenen derlenmiş bir C eklentisi. Karşılığında bu adayın bir dezavantajı var: sürümü yükseltmek `composer update` değil, sunucuda eklenti derlemek demek. 2. **`ext-psr` yüzünden ortak imaj çatallandı.** Phalcon `ext-psr` istiyor ve o eklenti yüklüyken Symfony PHP 8.4'te fatal veriyor. Phalcon kendi runtime imajında koşuyor; PHP sürümü, opcache ayarı, nginx ve php-fpm havuzu birebir aynı, fark yalnız o iki eklenti. 3. **`ext-mysqli` yedisine birden kuruldu** çünkü CodeIgniter 4'ün PDO sürücüsü yok. Diğer altısı kullanmıyor; eklenti seti aynı kalsın diye orada duruyor. 4. **Laminas'ın resmî iskeleti PHP 8.4 için yayınlanmamış.** `laminas-mvc-skeleton` 2.4.0 `~8.1.0 || ~8.2.0 || ~8.3.0` diyor ve kurulum `--ignore-platform-req=php` olmadan reddediliyor. Bu, tablodaki 23 paket ve 1.362 dosyanın resmî olarak desteklenmeyen bir sürümde kurulduğu anlamına gelir. 5. **Yii2'nin 34,1 MB'ı büyük ölçüde bower/npm varlıklarıdır.** Temel iskelet jQuery ve Bootstrap paketlerini vendor ağacına indiriyor; hiçbiri bu ölçümün rotalarında kullanılmıyor. 6. **`composer install` süreleri paket önbelleği sıcakken ölçüldü.** Soğuk önbellekte rakamlar ağ hızına bağlı olur ve framework hakkında bir şey söylemez. Üç koşunun medyanı alındı; tablodaki tek gürültülü sütun budur. 7. **opcache preload hiçbir adayda açılmadı.** CodeIgniter kutuda bir `preload.php` ile geliyor, Laravel ve Symfony için de yaygın tarifler var. Açık preload soğuk ilk istek rakamlarını kökten değiştirir — ama yedisinin ortak zemini değil, o yüzden kapsam dışı bırakıldı. Ayrı bir ölçüm konusu. 8. **İmaj boyutları framework'ü ayırt etmiyor.** 486-518 MB arasındalar ve farkın neredeyse tamamı ortak PHP taban imajından geliyor; vendor ağacı yanında küçük kalıyor. Bu yüzden tabloya alınmadı, ham veride duruyor. ## Yedi PHP framework'ü aynı yükte ölçtüm: fark, istek gerçek iş yaptıkça küçülüyor - Kaynak: https://muhammetsafak.com/tr/research/php-framework-yuk-testi/ PHP framework karşılaştırması, Türkçe internette çoğunlukla yöntemsiz yapılıyor: bir sayı veriliyor, hangi sürümde, hangi donanımda, kaç tekrarla ölçüldüğü yazılmıyor. Bu kaydın katkısı sıralama değil — sıralamanın **yeniden türetilebilir** olması. Aşağıdaki her rakam [bench/run.sh](https://github.com/muhammetsafak/php-framework-bench) çıktısından geliyor ve ham koşuların hepsi depoda duruyor. ## Ne ölçtüm Yedi aday da ölçülen hattın son kararlı sürümünde, kendi resmî iskeletinde ve kendi dokümante ettiği production kurulumunda koştu. Değişken tek bir şey: framework. | Aday | Sürüm | Veri katmanı | Şablon | | --- | --- | --- | --- | | Slim | 4.15.2 | yok — ham PDO | yok — slim/php-view | | Yii2 | 2.0.55 | ActiveRecord | Yii View | | Phalcon | 5.19.0 | Phalcon Model | Volt | | Symfony | 8.1.4 | Doctrine ORM | Twig | | Laminas | 3.8.0 | laminas-db TableGateway | laminas-view | | CodeIgniter | 4.7.4 | CI Model | CI View | | Laravel | 13.26.1 | Eloquent | Blade | Slim'in kendi veri katmanı ve şablon motoru yok; o iki senaryoda diğerlerinden daha az iş yapıyor. Sonucu okurken bu satır akılda tutulmalı. Yedisi de **tam elli rota** kaydediyor ve parametreli rota her zaman 47. sırada duruyor; böylece rota çözümü tek kayıtlı bir tabloda değil, gerçekçi bir tabloda ölçülüyor. Senaryolar: düz metin, JSON, parametreli rota, beş katmanlı middleware zinciri, yüz satırlık şablon (veritabanı yok), birincil anahtarla tek satır ve yirmi satırlık liste. ## Sayı ne söylüyor **Saniyede karşılanan istek, üç senaryoda** Boş rotadan veritabanına inildikçe sütunlar birbirine yaklaşıyor: framework'ün kendi maliyeti, istek gerçek iş yaptığında baskın gider olmaktan çıkıyor. Kaynak: 9 tekrarın en iyisi, 64 bağlantı, 15 sn — bench/run.sh çıktısı | | Slim | Yii2 | Phalcon | Symfony | Laminas | CodeIgniter | Laravel | | --- | --- | --- | --- | --- | --- | --- | --- | | Boş rota | 25975 | 19834 | 15021 | 13067 | 8349 | 6431 | 5966 | | Şablon (100 satır) | 22780 | 16995 | 10563 | 9355 | 7419 | 5557 | 5290 | | Veritabanı (tek satır) | 14443 | 9366 | 5222 | 6972 | 5421 | 4527 | 3887 | En çarpıcı hareket **Phalcon'da**. Boş rotada üçüncü (15.021), şablonda yine üçüncü, ama veritabanı isteğinde **beşinciye** düşüyor (5.222) ve Symfony'nin altında kalıyor. Yirmi satırlık listede daha da geriye, altıncılığa iniyor (2.040) — Laminas ve CodeIgniter'ın gerisinde. Framework'ün C ile yazılmış olması, süreç MySQL'den yanıt beklerken hiçbir şey kazandırmıyor; kazandırdığı yer, PHP'nin kendi kodu çalıştırdığı yer. | Aday | Boş rota | JSON | Rota (47/50) | Middleware | Şablon | DB tek satır | DB 20 satır | | --- | --- | --- | --- | --- | --- | --- | --- | | Slim | 25.975 | 25.609 | 24.774 | 24.020 | 22.780 | 14.443 | 4.551 | | Yii2 | 19.834 | 18.731 | 18.775 | 18.877 | 16.995 | 9.366 | 3.245 | | Phalcon | 15.021 | 15.161 | 15.061 | 14.906 | 10.563 | 5.222 | 2.040 | | Symfony | 13.067 | 12.714 | 12.187 | 12.964 | 9.355 | 6.972 | 3.060 | | Laminas | 8.349 | 8.114 | 8.127 | 8.036 | 7.419 | 5.421 | 2.874 | | CodeIgniter | 6.431 | 6.233 | 5.960 | 6.037 | 5.557 | 4.527 | 2.494 | | Laravel | 5.966 | 5.848 | 5.603 | 5.704 | 5.290 | 3.887 | 1.293 | İstek/saniye, 9 tekrarın en iyisi. Sıralama boş rotadaki hıza göre. Farkın nasıl kapandığı tek bir satırda görünüyor: en hızlı adayın en yavaşa oranı boş rotada **4,4×**, tek satırlık veritabanı isteğinde **3,7×**, yirmi satırlıkta **3,5×**. Phalcon'un Laravel'e oranı ise boş rotada 2,5×'ten tek satırlık veritabanı isteğinde 1,3×'e düşüyor. ## Gecikme **JSON isteğinde gecikme yüzdelikleri** Yedisinden dördü p99'da milisaltı ve en uzun kuyruk 2,59 ms: bu yükte hiçbiri kullanıcıya yavaş gelmiyor. Fark, aynı donanımdan kaç kullanıcı geçirebildiklerinde. Kaynak: En iyi throughput'u veren tekrarın kendisinden, 64 bağlantı | | Slim | Yii2 | Phalcon | Symfony | Laminas | CodeIgniter | Laravel | | --- | --- | --- | --- | --- | --- | --- | --- | | p50 | 0.25 ms | 0.35 ms | 0.46 ms | 0.58 ms | 0.88 ms | 1.16 ms | 0.75 ms | | p95 | 0.43 ms | 0.59 ms | 0.73 ms | 0.89 ms | 1.52 ms | 1.84 ms | 1.97 ms | | p99 | 0.53 ms | 0.73 ms | 0.94 ms | 0.96 ms | 2.23 ms | 1.98 ms | 2.59 ms | Laravel'in p50'si (0,75 ms) Laminas ve CodeIgniter'dan **daha iyi**, ama p99'u (2,59 ms) en kötüsü. Yani Laravel tipik isteği hızlı bitiriyor, kuyruğun ucunu ise iyi yönetemiyor. ## Tartışılan şeyler ucuz, varsayılanlar pahalı Elli rotalık tabloda parametreli rota çözmek, boş rotaya göre adaylara **%0-7** arasında bir bedele mal oluyor. Beş katmanlı middleware zinciri **%1-8**. Yani geliştiricilerin karşılaştırırken en çok konuştuğu iki şey, ölçüldüğünde tek haneli yüzdeler. Sonra Laravel'in kutudan çıkan varsayılanına bakıyorsunuz: | Laravel rotası | İstek/sn | p50 | p99 | | --- | --- | --- | --- | | Middleware grubu yok | 5.858 | 1,32 ms | 2,88 ms | | Varsayılan web grubu | 2.176 | 1,73 ms | 71,89 ms | Aynı JSON yanıtı, aynı konteyner, aynı koşu. Tek fark rotanın web grubunun arkasında olması. p99 yirmi beş katına çıkıyor. Bu ölçüm **`file` session sürücüsüyle** alındı. Laravel'in kutudan çıkan varsayılanı `database` — yani her istekte bir okuma ve bir yazma daha. Rakam oradan daha da kötüye gider. > **Sonuç** > > Sonuç şu: framework seçiminiz size 4 kata kadar fark açabilir, ama tek bir > varsayılan middleware grubu %63 götürebiliyor. Karşılaştırma tablosuna bakmadan > önce kendi rotanızın arkasında ne olduğuna bakın. ## Doygunluk: tavan sekiz worker Eşzamanlı bağlantı sayısını 8'den 256'ya çıkardım. Yedisinde de throughput **düz kaldı**; tabloda iki tekil düşüş var (Symfony 64 bağlantıda, Laravel 32'de). | Bağlantı | Slim | Yii2 | Phalcon | Symfony | Laminas | CodeIgniter | Laravel | | --- | --- | --- | --- | --- | --- | --- | --- | | 8 | 25.812 | 19.242 | 14.894 | 12.817 | 8.149 | 6.193 | 5.909 | | 32 | 25.785 | 19.018 | 15.053 | 12.811 | 7.828 | 6.259 | 4.774 | | 64 | 26.234 | 18.964 | 14.802 | 9.324 | 8.114 | 6.276 | 5.890 | | 128 | 25.842 | 19.251 | 14.783 | 11.795 | 8.044 | 6.067 | 6.016 | | 256 | 25.802 | 19.808 | 15.109 | 12.129 | 8.075 | 6.140 | 5.901 | Sekiz php-fpm worker, dört çekirdek. Tavan sekiz bağlantıda zaten dolmuş; sonrası nginx'te kuyruğa giriyor. Bu tablo tavanı sınırlıyor, doyma eğrisi çizmiyor. ## Nasıl ölçtüğüm — ve iki kez baştan başladığım Bu ölçüm üç kez koşuldu. İlk ikisi **iptal edildi** ve ikisi de gerçek bir kusur buldu; ikisi de depoda gerekçesiyle duruyor. **Birinci iptal — makine sakin değildi.** Slim `/json` bir turda 15.260, ertesi turda 2.107 istek/sn verdi; **p50 ikisinde de 0,23 ms**. İstek başına maliyet aynı, istek sayısı yedide bir: uygulama yavaşlamamış, koşu CPU alamamıştı. Host bir dizüstü ve üstünde pencere sunucusu, terminal ve editör çalışıyor; cpuset konteyneri VM'in *içinde* izole eder, VM'i macOS'ten izole etmez. Bunun düzeltmesi ortalamayı almak değil. Girişim throughput'u yalnız **aşağı** çekebilir — bir koşu gürültülü komşu yüzünden yavaşlayabilir, ama donanımın izin verdiğinden hızlı olamaz. O yüzden raporlanan değer tekrarların **en iyisi**, yanında medyanı ve yayılımıyla. Ayrıca PHP'ye hiç uğramayan sabit bir nginx referansı her bloğun önünde ve arkasında örnekleniyor: 150 örneklemede en kötüsü en iyinin %84'ü. **İkinci iptal — port tükenmesi.** Veritabanı senaryolarında isteklerin yarısı 500 dönüyordu: `SQLSTATE[HY000] [2002] Cannot assign requested address`. php-fpm her istekte MySQL'e yeni bir TCP bağlantısı açıyor ve konteyner saniyede birkaç bin istekte ephemeral port'ları tüketiyor. Tek istekle yapılan ön kontrol bunu yakalayamaz. Yedisinde de kalıcı bağlantıya geçildi (`PDO::ATTR_PERSISTENT`, CodeIgniter'da `pConnect`) — php-fpm için dokümante edilmiş production ayarı. Etkisi büyük: Slim'in `/db` sonucu 3.594'ten 14.074'e çıktı. Yani önceki ölçüm veri katmanını değil port çalkantısını ölçüyormuş. > **Sınır** > > Üç sınır açıkça duruyor. **Bir:** host sakinleştirilemedi ve yavaş rejim ~4-8k > istek/sn'de tavan yapıyor, yani yalnız hızlı adayları kırpıyor — Laravel 9 > tekrarın 9'unda temizken Slim 3'ünde temiz. Bu yanlılık tek yönlü: hızlı adaylar > olsa olsa **eksik** raporlanır. **İki:** oha her koşuda deadline'da kesilen 64 > bağlantıyı istek sayıyor, bu yüzden `mean` ve `max` kirli — kanıtı aritmetik: > `max × istek/sn` yedi adayda ve üç senaryoda 59-65 milyon arasında çıkıyor, yani > `max` yalnız throughput'un fonksiyonu. Bu iki değer ham veride duruyor ama > raporlanmıyor; yüzdelikler etkilenmiyor. **Üç:** rakamlar bir Docker Desktop > sanal makinesinden, Apple Silicon üzerinde. Mutlak değerler bare-metal üretimi > temsil etmez; adaylar arası karşılaştırma geçerlidir, çünkü hepsi aynı VM'i, > aynı çekirdekleri ve aynı koşuyu paylaştı. > **Tan:** Aradaki fark küçüldüğünde framework seçimini hız değil, ekibin o framework’ü ne kadar iyi bildiği belirler. Bu tür bir ölçüm seçimi gerekçelendirmez, yalnızca elenecek adayı gösterir. Ekibinizin hangi adayla hızlı geliştirebildiği tabloda yok. ## Atlanmayan bulgular Ölçüm boyunca çıkan ve tabloya sığmayan her şey: 1. **Yedi framework'ün buluştuğu tek PHP sürümü var: 8.4.** Symfony 8.1 en az `8.4.1` istiyor, `laminas/laminas-mvc` 3.8 tavanı `~8.4.0`. Pencere tam bir minor sürüm geniş. 2. **Phalcon ile Symfony aynı PHP sürecinde çalışmıyor.** Phalcon `ext-psr` istiyor; o eklenti yüklüyken Symfony fatal veriyor: `Declaration of Symfony\Component\Cache\CacheItem::expiresAt(?DateTimeInterface $expiration): static must be compatible with PsrExt\Cache\CacheItemInterface::expiresAt($expiration)`. C eklentisi PSR arayüzlerini, userland paketlerinin kullandığı parametre tipleri olmadan tanımlıyor. "Tek imaj, tek eklenti seti" planı bu yüzden tam bir yerde çatallandı: Phalcon kendi runtime imajında koşuyor, geri kalan altısı ortak imajda. 3. **CodeIgniter 4'ün PDO sürücüsü yok** — MySQL desteği yalnız MySQLi. Eklenti seti aynı kalsın diye `ext-mysqli` yedisine birden kuruldu; diğer altısı kullanmıyor. 4. **Laminas'ın resmî iskeleti PHP 8.4 için yayınlanmamış.** `laminas/laminas-mvc-skeleton` 2.4.0 hâlâ `~8.1.0 || ~8.2.0 || ~8.3.0` diyor; kurulum `--ignore-platform-req=php` olmadan reddediliyor. Açtığı bileşen (`laminas/laminas-mvc` 3.8.0) `~8.4.0`'ı kabul ediyor. 5. **Yii2 hâlâ 2.0.x.** Yii 3, 31 Aralık 2025'te kararlı ilan edildi, ama paketlere bölünerek yeniden kurulmuş başka bir framework. Burada ölçülen hat Yii2 2.0.55; mimarisi diğerlerinin güncel sürümlerinden eski. Yii 3 ayrı bir kayıt ister. 6. **Middleware yedisinde aynı kavram değil.** CodeIgniter filtreleri ve Yii2 action filter'ları rotaya bağlı, diğer senaryolara hiçbir şeye mal olmuyor. Symfony, Laminas ve Phalcon'da rota bazlı bir pipeline yok; dokümante edilmiş karşılık global bir event listener, yani beş katman o uygulamalarda **her** istekte yoklanıp erken dönüyor. Bu onların mimarisi, buraya uydurulmuş bir handikap değil. 7. **Slim iki senaryoda daha az iş yapıyor.** Kendi veri katmanı yok (`/db` ham PDO üstünde), kendi şablon motoru yok (`slim/php-view`). Birincilik bu iki satırda bu bağlamla okunmalı. 8. **Bir hücre kendi kendisiyle çelişti ve takviye edildi.** Slim'in `/route` sonucu 9 tekrarda 6.778 istek/sn çıkmıştı — sahanın en düşük p50'si (0,23 ms) ile, kendi boş rotasının %26'sı. Dokuz tekrarın hiçbiri kırpılmamış pencereye denk gelmemişti. Önceden sabitlenmiş iki kural (temiz tekrar sayısı ≤2, veya throughput'un `8/p50`'nin yarısının altında kalması) altı hücreyi işaretledi; hepsi Slim ve Yii2, yani sahanın en hızlı ikilisi — yanlılığın öngörülen yönü. Altısına altışar tekrar eklendi, Slim `/route` 24.774'e oturdu ve hiçbir hücre işaretli kalmadı. 9. **Sıfır non-200.** 582 yük ölçümünün tamamı, kalıcı bağlantı düzeltmesinden sonra, %100 HTTP 200 döndü. 10. **JIT kapalı.** Yedisinden hiçbiri opcache JIT'i production adımı olarak dokümante etmiyor ve açmak ikinci bir değişken eklerdi. Açık JIT ile sonuç farklı olabilir; bu ayrı bir ölçüm. 11. **Worker mode ölçülmedi.** FrankenPHP/Octane gibi kalıcı süreç modelleri yedisinde de resmî olarak desteklenmiyor; klasik php-fpm hepsinin eşit zeminidir. Worker mode ayrı bir kayıt konusudur. # Uzmanlık ## PHP - Kaynak: https://muhammetsafak.com/tr/uzmanlik/php/ PHP geliştiricisi olarak işimin merkezinde tek bir soru duruyor: iş kuralı nerede yaşayacak? Framework seçimi, dizin düzeni ve test yüzeyi bu sorunun cevabından sonra geliyor. Ters sırayla kurulan projelerde kural uygulamanın her yerine dağılıyor ve bakım maliyeti tam orada başlıyor. Aşağıdakiler uygulama seviyesinde ne yaptığım ve kararları neye göre verdiğim. Mikroservis sınırları, event-driven tasarımın kendisi ve veritabanı iç mekaniği gibi konuları maliyetleriyle birlikte [sade.dev](https://sade.dev) tarafında yazıyorum. ## Backend & Altyapı - Kaynak: https://muhammetsafak.com/tr/uzmanlik/backend/ Backend geliştirme benim için bir dil değil, bir sorumluluk sınırı: kullanıcının gördüğü ekranın arkasında kalan her şey. Sözleşme, kuyruk, veritabanı ve sunucu aynı işin dört yüzü; biri hakkında verilen karar diğer üçünü de bağlıyor. Gözlemlenebilirlik, altyapının kod olarak yönetilmesi ve geri alma stratejisi gibi başlıkları mimari konular olarak [sade.dev](https://sade.dev) tarafında yazıyorum. ## Go - Kaynak: https://muhammetsafak.com/tr/uzmanlik/go/ Go benim için başka bir dilin alternatifi değil, belirli bir problem sınıfının cevabı. Karşı tarafta kurulum istemeyen, uzun süre ayakta kalan ve kaynağı ucuz kullanan bir servis ya da araç gerektiğinde ilk başvuru noktam burası. Dili probleme göre seçiyorum, sevdiğim için değil. Sistem tasarımının kendisini maliyetleriyle birlikte [sade.dev](https://sade.dev) tarafında yazıyorum. ## JavaScript & TypeScript - Kaynak: https://muhammetsafak.com/tr/uzmanlik/javascript/ Bu ekosistemdeki kimliğim tek cümleyle şu: ürünün arka ucunu yazan ve o ürünün web ile mobil yüzeyini aynı sözleşmeye oturtan kişi. Tasarım sistemi kurmuyorum; API ile konuşan arayüzü kuruyorum. JavaScript ve TypeScript bu sayfada bir tercih değil, ürünün kullanıcıya değdiği katmanın dili. Arayüz mimarisinin sistem tarafına değen kısımlarını — sunucu tarafında render kararları, önbellek stratejisi — [sade.dev](https://sade.dev) tarafında yazıyorum. ## Python - Kaynak: https://muhammetsafak.com/tr/uzmanlik/python/ Python benim için bir uzmanlık alanı değil, dar ve net bir araç. Bir dosyayı okuyup dönüştürmek ya da tekrarlayan bir işi otomatikleştirmek gerektiğinde, kurulum töreni istemeyen bir dile geçmek işi hızlandırıyor. Bu sayfa, o dar alanın sınırlarını olduğu gibi anlatıyor. Sayfanın kısalığı bir eksiklik değil, bir ölçü: iddiam ne kadarsa o kadar yazıyorum. Aynı soruyu daha geniş bir ekosistemde soruyorsanız [PHP sayfası](/tr/uzmanlik/php/) çok daha uzun — ve olması gerektiği gibi. ## Yazılım Mimarisi - Kaynak: https://muhammetsafak.com/tr/uzmanlik/architecture/ Mimariyi bir desen koleksiyonu olarak değil, belirli bir sistemde, belirli bir sınırla ve belirli bir bedelle verilmiş karar olarak yapıyorum. Bu sayfa üç konuyu kendi kapsamında tutuyor: Software Architecture, System Design ve Event-Driven Architecture. Üçünün kanıtı sitede duruyor. Teslim ve idempotency tarafı [outbox günlüğünde](/tr/blog/outbox-idempotent-tuketim-ve-alan-sifrelemesi/), yasal bir kısıtın tasarımı nasıl daralttığı [numara çakışması günlüğünde](/tr/blog/e-fatura-entegrasyonunda-numara-cakismasi/), teslimden sonraki determinizm sorusu ise [determinizm yazısında](/tr/blog/event-driven-mimaride-determinizm/). Rollerin kendisi [özgeçmişte](/tr/ozgecmis/), konunun kitap ölçeğindeki karşılığı [güvenilir event-driven sistemler](/tr/kitaplar/designing-reliable-event-driven-systems/) kitabında. Derinliğe inmek isterseniz [sade.dev](https://sade.dev) tarafındaki [mimari kararların neden çürüdüğü](https://sade.dev/tr/journal/mimari-kararlar-curur) yazısı ve [outbox ile idempotent tüketim](https://sade.dev/tr/systems/transactional-outbox-dual-write-ve-idempotent-tuketim) anlatısı doğru yer. # Kitaplar ## The Unreliable Systems Trilogy - Kaynak: https://muhammetsafak.com/tr/kitaplar/seri/the-unreliable-systems-trilogy/ Tek bir fikir üzerine üç ücretsiz kitap: güvenilir sistemler, bozulan parçaların etrafına kurulur — erişim, event'ler ve AI agent'lar. Paylaşılan bir admin parolası, event'i göndermeden kapanan bir instance, art arda 40 kez çağrılan bir lookup. Üç kitabın üç açılış sahnesi bunlar; üçü de kurgusal şirketlerde geçiyor ve hiçbirinde tek bir büyük hata yok. Birinci kitapta credential, verildiği işi tam olarak yaptı. İkincisinde kodun her satırı yazdığını yaptı. Bozulan, parçaların etrafındaki boşluktu: kimsenin tasarlamadığı bir kapı, bir commit ile bir ağ çağrısı arasındaki aralık. Birinci kitap, Production Veritabanı Erişim Rehberi, sorunu credential dağıtımında değil, yetkilendirme, yürütme ve hesap verebilirlikte arar. İkinci kitapta, Güvenilir Event-Driven Sistemler Tasarlamak, sorun message broker'larda değil failure semantics'tedir: bir mesaj geciktiğinde, kaybolduğunda, çoğaldığında ya da sırası bozulduğunda ne olacağına önceden karar vermek gerekir. Üçüncü kitap, Güvenilir AI Agent Mühendisliği, aynı mantığı karar vericinin kendisine uygular. Model bazen yanılacaksa, yanılmanın katlanılabilir olması için çevresindeki kontrol sistemi neyi garanti etmelidir? Üç kitapta da her kontrol maliyetiyle birlikte anlatılır: kimden ne istediği, hangi hataların sık yapıldığı ve ne zaman kullanılmaması gerektiği. ## Production Veritabanı Erişim Rehberi - Kaynak: https://muhammetsafak.com/tr/kitaplar/production-database-access-guide/ Kimlikler, credential'lar, just-in-time erişim, aracılı yürütme ve audit Bir production veritabanına kimin ve neyin, nasıl, ne kadar süreyle ve geride hangi kaydı bırakarak erişebileceğine karar vermek için pratik bir rehber. Perşembe akşamı tek satırlık bir düzeltme bir grup faturayı birden değiştirdi; veritabanı log'u ise yalnızca paylaşılan admin rolünü gösteriyordu. Sahne, faturalama ve ödeme işi yapan kurgusal bir şirkette, Ledgerly'de geçer ve içindeki hiçbir adım tek başına makul olmayan bir karar değildir: parola, ilk servisi ayağa kaldırmanın en hızlı yolu olduğu için paylaşılmış, istemci varsayılanı gereği otomatik commit etmiş, rol de hangi servisin hangi tabloya ihtiyacı olduğuna karar vermekten kolay olduğu için her tabloyu değiştirebilmiştir. Kitap bu sahneden tek bir iddia çıkarır: production veritabanı erişimi bir credential dağıtım problemi değil, bir yetkilendirme, yürütme ve hesap verebilirlik problemidir. Hikâyedeki credential meşruydu ve verildiği işi tam olarak yaptı; eksik olan, onun etrafındaki her şeydi. Kitap, beş kısım ve on altı bölümde Ledgerly'yi paylaşılan bir admin parolasından bilinçli olarak tasarlanmış bir erişim modeline kadar izler. Her kontrol, onu kuran ve işleten insanlardan bir şey ister; bu yüzden her bölüm kontrolün maliyetini, sık yapılan hataları ve ne zaman kullanılmaması gerektiğini yazar. Kitap savunan tarafın gözünden yazıldı: saldırı tekniği öğretmez, uyum rejimlerini yorumlamaz ve bir veritabanı yönetimi kitabı değildir. ## Güvenilir Event-Driven Sistemler Tasarlamak - Kaynak: https://muhammetsafak.com/tr/kitaplar/designing-reliable-event-driven-systems/ Mesajlaşma, retry, idempotency, outbox, ordering ve schema evolution Ağlar, broker'lar, deploy'lar ve insanlar yanlış davrandığında bile event-driven sistemleri doğru tutan örüntülere dair pratik bir rehber. Bir instance siparişi veritabanına yazdı ve event'i göndermeden kapandı; alarm çalmadı. Ertesi sabah bir alıcı "verildi" görünen bir siparişe bakıyordu: Payments hiçbir kartı yetkilendirmemiş, Inventory hiçbir ürünü ayırmamıştı. Sahne, Parcelly adlı kurgusal bir pazar yerinde geçer ve ortada alışılmış anlamda kodlanmış bir hata yoktur: her satır yazdığını yaptı, sistem veritabanı commit'i ile ağ çağrısı arasındaki boşlukta bozuldu. Kitabın iddiası, sorunun message broker'larda değil failure semantics'te olduğudur: bir mesaj geciktiğinde, kaybolduğunda, çoğaldığında ya da sırası bozulduğunda ne olacağına önceden karar vermek. Açılış sahnesindeki boşluğun adı dual-write problemidir; kitap onu transactional outbox ile kapatır, ardından teslimat, çoğalma, kurtarma ve replay konularına geçer. Kitap hiçbir broker'a özel değildir ve izlendiğinde sisteminizin doğru olacağını vaat etmez. Güvenilirlik sonradan eklenen bir özellik değil; hangi hataları nasıl ele alacağınıza ve hangilerini bilerek kabul edeceğinize verdiğiniz kararlardır. Her numaralı bölüm örüntünün maliyetini, sık yapılan hataları ve ne zaman kullanılmaması gerektiğini söyler. ## Güvenilir AI Agent Mühendisliği - Kaynak: https://muhammetsafak.com/tr/kitaplar/engineering-reliable-ai-agents/ Production'daki agent'lar için tool'lar, yetki, state, sınırlar ve kayıtlar Production'da güvenilebilecek AI agent'lar kurmak için pratik bir rehber: araçlar, yetki, durum, sınırlar ve kayıtlar; tasarımdan olay incelemesine. Bir run aynı sorguyu art arda 40 kez çağırdı; çalışmasını tesadüfen izleyen biri fark edene kadar kimse görmedi. Sahne, orta ölçekli kurgusal bir fulfillment şirketinde, Parcelport'ta, ilk agent'ın yayına girdiği hafta geçer. Çarşambaya gelindiğinde ekip üç şey öğrenmişti. Lookup döngüsünün yanında bir ticket, hiçbir tool'un döndürmediği bir teslimat tarihi vaat eden bir cevap üretmiş; bir müşteri e-postasındaki "asistana" hitap eden paragraf da Parcelport'tan gelen bir talimat gibi işlenmişti. Hiçbiri felaket değildi, hepsi öngörülebilirdi ve hiçbiri sonradan tam olarak yeniden kurulamadı, çünkü agent'ın ne yaptığına dair tek kayıt nihai yanıtıydı. Kitabın tezi tek cümle: AI agent'lar, güvenilir kontrol sistemlerinin içinde çalışan güvenilmez karar vericiler olarak tasarlanmalıdır. Model öneri üretir, kararı sistem verir; bu yüzden kitap daha iyi bir prompt'la değil şu soruyla başlar: model bazen yanılacaksa, yanılmak atlatılabilir olsun diye çevresindeki sistem neyi garanti etmelidir? Kitap prompt engineering, model eğitimi, framework ya da vendor üzerine değildir ve bir saldırı kitabı da değildir. Her kontrol maliyetiyle birlikte gelir; kitap bir kontrolün ne zaman gösterişten ibaret olduğunu da söyler. İçindeki sayılar örnektir; öneri ya da ölçüm değildir.