Sertaç Yıldırım saha notları

Ana sayfa → Teknik

Monorepo: Kod Yapısı Değil, Ekip Yapısı

Çarşamba 10:15. Ortak kütüphanede tek satır: hata kodu enum’una yeni bir değer. Yazması 20 dakika. Sonra sırayla: kütüphaneye versiyon bas, 8 servis reposunda bump PR’ı aç, her biri için CI bekle, review al, deploy et. Yedincisi Cuma 16:30’da canlıdaydı. Sekizinci PR hiç açılmadı; unutuldu. Ekip 3 kişi. Kimse kimseyi beklemiyor; repolar birbirini bekliyor.

Özet
  • Repo sınırı, ekip sınırıdır. 4 ekip 14 kişiyken 9 repo doğruydu: kimse kimsenin PR’ını beklemesin diye. Ekip 3 kişiye inince aynı sınır, engellemediği kimse için bedel almaya başladı. Bu Conway yasasının repo hâli.
  • Ortak kütüphanede 20 dakikalık iş, 3 günlük teslim. 7 bump PR, 7 CI, 7 review, 7 deploy; sekizinci servis unutuldu. Tek olay değil: common yılda 19 sürüm alıyor, 4 turda en az bir servis geride kaldı. Monorepoda aynı iş tek PR, tek review, tek commit.
  • Monorepo ≠ monolit. Tek repo, ama her servis ayrı deploy edilir; CI tetikleyicisi ile deploy tetikleyicisi ayrı. Ortak lib değişince 8 servisi birden çıkarmak zorundaysan sorun repoda değil, kütüphanenin sınırında.
  • Önce CI’ı böl, sonra kodu taşı. Path filtresi olmadan taşırsan her commit her şeyi tetikler; ikinci günde pipeline 31 dakikadır. Path filtresi de bedava değil: elle tutulan bir bağımlılık grafiği ve required check’le birleşince PR’ı sonsuza kadar kilitler.
  • Geçmişi taşı, kopyalama. git filter-repo ile, tag’leri yeniden adlandırarak; yoksa iki yıllık git blame gider, 8 repodaki v1.0.0 çakışır.
  • Asıl soru servis sayısı. 3 kişiye 8 servis; 8 pipeline, 8 dashboard, 8 alarm seti demek. Repo birleştirmek servis sayısını azaltmaz; 12 aylık co-change verisi hangi ikisinin tek servis olduğunu söyledi.

Repo sınırı neyi çözer?

Projeye üç yıl önce 4 ekip, 14 kişiyle başladık. Ödeme ekibi odeme ve bildirim servislerini, hesap ekibi hesap, limit ve mutabakat’ı, raporlama ekibi raporlama ve yonetim’i, entegrasyon ekibi entegrasyon’u yazıyordu. Her servisin kendi reposu; ortada bir common kütüphanesi. 9 repo. O gün için doğru karardı ve sebepleri somuttu:

  • Ödeme ekibinin PR’ı, raporlama ekibinin yarım kalmış işini beklemiyordu.
  • CI küçük ve hızlıydı: repo başına 4 dakika.
  • Entegrasyon reposunu dışarıdan bir ajans yazıyordu; yalnızca o repoyu görüyordu.
  • Release ritmi farklıydı: ödeme haftada 3 çıkıyor, raporlama ayda 1.

Üç yılda ekip küçüldü. Bugün 3 kişiyiz ve aynı 8 servisi işletiyoruz. Repolar olduğu yerde duruyor; sebepleri durmuyor. Conway yasası “sistem, onu üreten organizasyonun iletişim yapısını kopyalar” der; 9 repo, 4 ekibin iletişim yapısının kopyasıydı. Organizasyon değişti, kopya kaldı.

Poly repo’nun verdiği şey14 kişiyken3 kişiyken
Bağımsız review4 ekip birbirini beklemiyorZaten aynı 3 kişi review yapıyor
Ayrı erişimDış ajans tek repo görüyorDış ajans yok
Küçük, hızlı CI4 dkYine 4 dk; ama ortak kod değişince 8 repoda 8 PR’ı aynı kişi açıp izliyor. Makineler paralel çalışır, insan çalışmaz
Ayrı release ritmiÖdeme haftada 3, rapor ayda 1Hepsini aynı kişi çıkarıyor
Bedeli: ortak kod koordinasyonu4 ekibe yayılıyorEkibin tamamına biniyor
Repo sınırı, ekip sınırını yansıtır. Ekip tekse repo da tek olabilir.

Sahadan: bir enum değeri, üç gün

Girişteki olayın dökümü; 11 Şubat 2026, Çarşamba. Yeni bir limit kuralı için ErrorCode enum’una tek değer eklenecek: LimitExceeded. Değişiklik common’da; tüketen 8 servis var, o sabah sekizi de 1.13’te.

ZamanNe olduMaliyet
Çarşamba 10:15common’da enum değeri eklendi, PR açıldı20 dk
Çarşamba 11:00Review, merge, 1.14.0 tag, paket yayınlandı45 dk
Çarşamba 11:30–17:007 serviste bump PR (1.13 → 1.14). İkisinde derleme hatası: bir switch expression yeni değeri kapsamıyor, TreatWarningsAsErrors açık olduğu için CS8509 uyarısı bizde hata7 PR, 2 düzeltme
Perşembe7 CI; biri flaky çıktı, iki kez yeniden koşuldu. 7 review, 5 deploy9 CI koşusu, bir kişinin günü
Cuma 16:30Kalan 2 deploy. Sekizinci PR hiç açılmadı: raporlama hâlâ 1.13’te, o hafta kimse dokunmadı, bump unutuldu7/8

Kimse hata yapmadı. Süreç, 4 ekip birbirini engellemesin diye tasarlanmıştı; 3 kişilik ekipte engellenecek kimse kalmayınca geriye yalnızca süreç kaldı. Üstüne bir de versiyon matrisi: Cuma akşamı tablo 7 servis 1.14, raporlama 1.13. Tek satır fark; ama o satırı iki hafta sonra kimse hatırlamayacaktı ve “hangi servis hangi common’da?” sorusunun cevabı yine bir tabloya bakmak olacaktı.

Bu tek olay olsaydı yazı olmazdı. Geçişten önceki 12 ayda common 19 sürüm aldı (minor + patch). 19 × 8 = 152 bump PR açılmalıydı; açılan 131. Aradaki 21 PR ya unutuldu ya “sonraki turda” dendi; 4 turda en az bir servis geride kaldı, ikisinde bunu canlıda bir hatayla fark ettik. Yılda 130 PR’ın işi yalnızca bir versiyon numarasını değiştirmek.

Engellediği kimse kalmayan bir sınırın tek işlevi, bedel almaktır.

Ucuz alternatif: bump’ı robota vermek

Karar toplantısından önce ilk itiraz kendi içimizden geldi: “Renovate kur, bump PR’larını robot açsın, CI yeşilse auto-merge olsun; repo yapısına dokunmadan biter.” Doğru bir itiraz. Neyi çözdüğünü ve çözmediğini yan yana yazdık:

Robot çözer
  • 7 PR’ı elle açmak: sıfıra iner.
  • Unutulan servis: robot unutmaz, paket çıkınca dakikalar içinde 8 PR açar.
  • Versiyon matrisinin dağınıklığı: geride kalan servis en fazla bir CI süresi geride kalır.
Robot çözmez
  • 7 CI, 7 deploy: aynen durur.
  • 7 review: auto-merge’e güvenirsen review yok; iki derleme hatası çıkan tur bize güvenmemeyi öğretti.
  • Asıl olan: ortak koddaki değişiklik ile onu kullanan servisteki değişiklik yine iki PR, iki repo, iki zaman. Bir interface’i değiştirmek için önce eski + yeni imzayı ekle, sürüm bas, 8 servisi geçir, eskiyi kaldır, tekrar sürüm bas. Expand/contract, 3 kişilik ekipte her küçük refactor için.

Hesap şu: common yılda iki kez değişseydi Renovate yeter, taşımazdık. Yılda 19 kez değişiyor ve o değişikliklerin çoğu “bir alan ekle, kullananı güncelle” biçiminde; yani iki repoya bölünmüş tek iş. Robot PR açmayı alır, bölünmüşlüğü almaz.

Karar: monorepo — ama monolit değil

Karar tek repo. Ne değişiyor, ne değişmiyor; ikisini de baştan yazdık, çünkü “monorepo” kelimesi ekipte iki farklı şey çağrıştırıyordu.

Değişen
  • Tek repo: services/ altında 8 servis, libs/ altında ortak kod.
  • common paket değil, kaynak olarak referanslanır. Versiyon matrisi kaynak seviyesinde biter: her servis her commit’te aynı common’la derlenir.
  • Ortak kod değişikliği + onu kullanan servisler tek PR, tek review, tek commit. Atomik refactor mümkün: rename, interface değişikliği.
  • Yeni gelen biri git clone ile her şeyi görür.
Değişmeyen
  • Her servisin kendi Dockerfile’ı, kendi pipeline’ı, kendi deploy’u.
  • Aynı PR’da değişen 8 servis yine ayrı ayrı, sırayla deploy edilir. Deploy penceresinde iki common sürümü aynı anda canlıdır; geriye dönük uyumluluk zorunluluğu aynen durur.
  • Servisler çalışma zamanında birbirini nasıl çağırıyorsa öyle çağırmaya devam eder.
  • “Hepsini birden deploy et” diye bir buton yok. Olmayacak.

Sağdaki listenin ikinci maddesi enum örneğinde şu demek: LimitExceeded’ı üreten limit ile onu işleyen hesap ve bildirim, deploy sırasında birkaç dakika farklı sürümde çalışır; hesap, tanımadığı bir hata kodunu Unknown’a düşürmek zorundadır. Bu zorunluluk poly repo’da da vardı, Çarşamba ile Cuma arasında iki gün boyunca; sadece kimse yazmamıştı.

Sağdaki liste soldakinden önemli. Monorepoya geçip common değişince 8 servisi birden deploy etmek zorunda kalıyorsan bir şey kazanmadın; sorun repoda değil, kütüphanenin sınırındaydı. Poly repo bunu versiyon matrisinin arkasına saklıyordu. Monorepo saklamaz: her PR’da kaç servis dizinine dokunduğun görünür. O sayı sürekli 8/8 ise ortak kütüphane fazla şey biliyor.

Monorepo, ortak kütüphanenin sınır hatasını çözmez; gösterir.

Nerede batar: beş tuzak

1. Her commit her şeyi tetikler

İlk denemede tek pipeline yazdık: push gelince 8 servisi build et, test et. 4 dakikalık CI 31 dakika oldu, ikinci günde. Çözüm, değişen dizine göre tetiklemek; her servisin kendi workflow’u var ve yalnızca kendi dizini ya da bağımlı olduğu ortak kod değişince çalışıyor. Bazel ya da dotnet-affected gerekmedi; GitHub Actions’ın paths filtresi yetti. İkinci kural: CI tetikleyicisi ile deploy tetikleyicisi ayrı dosyada, ayrı koşulla.

Servis başına CI workflow’u, path filtresiyle
# .github/workflows/odeme-ci.yml
name: odeme-ci
on:
  push:
    branches: [main]
    paths:
      - "services/odeme/**"
      - "libs/common/**"        # ortak kod degisince bu servis de derlenir, test edilir
      - ".github/workflows/odeme-ci.yml"
  pull_request:                 # dikkat: burada "paths" yok; sebebi 2. tuzakta

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 1          # 470 MB gecmis yerine 4 sn'lik checkout
      - uses: dorny/paths-filter@v3
        id: chg
        with:
          filters: |
            odeme:
              - "services/odeme/**"
              - "libs/common/**"
      - if: steps.chg.outputs.odeme == 'true'
        run: dotnet test services/odeme     # sadece bu servis; 4 dk, eskisi gibi
                                            # degismediyse job 20 sn'de yesil biter
Deploy ayrı workflow, ayrı tetikleyici
# .github/workflows/odeme-deploy.yml
name: odeme-deploy
on:
  push:
    branches: [main]
    paths:
      - "services/odeme/**"     # libs/common YOK: ortak kod tek basina deploy tetiklemez
  workflow_dispatch:            # ortak koddaki duzeltme hemen cikmali ise elle; 6 ayda 3 kez

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 1 }
      - run: docker build -t registry/odeme:${{ github.sha }} services/odeme
      - run: ./deploy.sh odeme ${{ github.sha }}   # image etiketi = commit; geri alma bu etiketle

libs/common değişince 8 CI çalışır, sıfır deploy tetiklenir. Ortak koddaki değişikliğe ihtiyacı olan servis zaten aynı PR’da değişmiştir ve kendi deploy’u çalışır; ihtiyacı olmayan servis yeni common’ı bir sonraki deploy’unda alır. “Hepsini birden deploy et” butonu olmamasının anlamı bu.

2. Path filtresi elle tutulan bir bağımlılık grafiğidir

Yukarıdaki paths listesi bir bağımlılık grafiğinin en basit hâli ve iki yönde bozulur. Kolay olanı: birisi filtreyi fazla genişletir, CI yine 30 dakikaya çıkar; bunu p95 görür. Tehlikeli olanı ters yön: bildirim servisi libs/sablon’a bağımlılık ekler, workflow’undaki paths listesini güncellemez. O günden sonra libs/sablon değişince bildirim derlenmez, test edilmez ve kimse fark etmez. Grafik csproj’da bir yerde, YAML’da başka bir yerde yazılıdır ve ikisi kendiliğinden senkron kalmaz. Bizim çözümümüz 9 satırlık bir bekçi:

Her PR’da: csproj’daki lib referansı, workflow’un paths listesinde var mı?
for wf in .github/workflows/*-ci.yml; do
  svc=$(basename "$wf" -ci.yml)
  grep -oh 'libs/[a-z]*' services/$svc/*.csproj | sort -u | while read -r lib; do
    grep -q "$lib/\*\*" "$wf" || echo "EKSIK: $svc -> $lib ($wf)"
  done
done
# bir satir bile yazarsa exit 1; PR kirmizi

İkinci tuzak GitHub’a özgü ve monorepo’ların en klasik hatası: workflow’u branch protection’da required check yaparsın, sonra pull_request tetikleyicisine paths koyarsın. Yalnızca raporlama’ya dokunan bir PR’da odeme-ci hiç başlamaz; başlamayan check “skipped” bile demez, hiç rapor vermez; PR sonsuza kadar “waiting for status” kalır. Bize pilot serviste, ilk gün oldu. İki çıkış var: aynı name’e sahip ikinci bir workflow’u paths-ignore ile ters koşula bağlayıp içine boş bir job koymak (GitHub’ın kendi dokümanındaki yol) ya da bizim yaptığımız: PR tarafında paths’i kaldırıp filtreyi job’un içine almak. Check her PR’da rapor verir; işi yalnızca gerektiğinde yapar. Merge queue kullanıyorsan aynı sorun orada da var; ayarını orada da kontrol et.

Servis sayısı büyüyünce bu listeler bir kişinin kafasında tutulamaz olur ve o gün dotnet-affected ya da Bazel konuşulur. Sınır kaç servis, bilmiyoruz; bizde 8 servis, 2 lib ve 10 satırlık liste var, 3 kişi ezbere biliyor.

3. Paket basmaya devam etmek

Kodu taşıyıp common’ı hâlâ internal NuGet paketi olarak basıyorsan hiçbir şey kazanmadın: bump PR’ları aynı repoda açılır, o kadar. Referans kaynak olmalı.

Önce: paket, versiyon matrisi
<!-- services/odeme/Odeme.csproj -->
<PackageReference Include="Company.Common" Version="1.13.0" />
<!-- 8 serviste 8 farkli satir; hangisi guncel, kimse bilmiyor -->
Sonra: kaynak, her zaman HEAD
<!-- services/odeme/Odeme.csproj -->
<ProjectReference Include="../../libs/common/Common.csproj" />
<!-- common degisince derlenmeyen servis, PR'da kirmizi yanar; Cuma'ya kalmaz -->

Girişteki iki derleme hatası burada, PR’ın içinde, Çarşamba 10:35’te ortaya çıkardı. Bir ayrıntı: onları yakalayan derleyici değil, TreatWarningsAsErrors. Enum’a değer eklemek C#’ta derleme hatası vermez; switch expression’da eksik dal CS8509 uyarısıdır. O ayar kapalıysa iki servis sessizce derlenir ve LimitExceeded çalışma zamanında default dalına düşer. Unutulan raporlama’ya gelince: unutulacak adım yok; aynı PR’da yeni common’la derlenir, CI’ı çalışır.

Kaynak referansının bedeli de var; dördünü yazıyorum:

  • Eski sürümde kalma hakkı gider. Bir servis “bu değişikliği şimdi alamam” diyemez; common’daki her değişiklik ya geriye uyumludur ya da 8 servisi aynı PR’da geçirir. Bu bir bedel; bizim için aynı zamanda amaç.
  • Transitive paket sürümleri hizalanmak zorunda. common Newtonsoft 13’e geçince 8 servis geçer. Bunu Directory.Packages.props ile merkezi yönetiyoruz; sürüm tek dosyada.
  • Target framework tek. yonetim hâlâ net6.0’daydı; net8.0’a taşımak geçişin ön şartı oldu, 2 gün.
  • Build süresi. Her servisin build’i common’ı da derler: servis başına +25 saniye. Kabul.

Kuralın istisnası: common’ı repo dışından tüketen varsa (ajans, mobil, başka bir ekip) paket basmaya devam edersin; o zaman kütüphanenin iki müşterisi vardır, içeridekiler kaynaktan, dışarıdakiler paketten. Bizde dış tüketici yok. Olsaydı istisna oydu ve versiyon matrisi o müşteri için sürerdi.

4. Geçmişi kopyala-yapıştırla taşımak

En ucuz görünen yol: eski repoyu klonla, dosyaları yeni dizine kopyala, tek commit. Bedeli üç ay sonra ödenir: git blame her satır için aynı commit’i gösterir, “bu neden böyle yazılmış” sorusunun iki yıllık cevabı gider. Geçmiş, yollar yeniden yazılarak taşınır:

Her eski repo için, geçmişiyle birlikte
# 1) eski repoyu taze klonla (filter-repo, uzerinde calisilmis klonu reddeder)
git clone git@github.com:company/odeme-service.git /tmp/odeme
cd /tmp/odeme

# 2) butun gecmiste yollari services/odeme/ altina tasi;
#    tag'leri servis adiyla onekle: v1.4.2 -> odeme/v1.4.2
#    (8 repoda da v1.0.0 var; oneklemezsen merge'de cakisir)
git filter-repo --to-subdirectory-filter services/odeme --tag-rename :odeme/

# 3) monorepoya cek; iki gecmisin ortak atasi yok, izin ver
cd ~/monorepo
git remote add odeme /tmp/odeme
git fetch odeme --tags
git merge --allow-unrelated-histories odeme/main -m "odeme servisini tasi (gecmisiyle)"
git remote remove odeme

# kontrol: eski commit'ler yeni yolda gorunuyor mu? (yollar yeniden yazildi, --follow gerekmez)
git log --oneline -- services/odeme/Odeme.csproj | tail -3

Servis başına 15 dakika komut; devir listesi daha uzun sürüyor. Eski repoyu arşivlemeden önce: açık PR’lar (bizde toplam 6; merge ya da kapat, yeni repoda yeniden aç), issue’lar (GitHub transfer), webhook’lar (3 Slack, 1 deploy), deploy credential’ları (yeni repo secrets’ına), README badge’leri, wiki ve dış dokümanlardaki linkler. Sonra repo archived; README’nin ilk satırı “Taşındı: monorepo/services/odeme”. Silinmez; bir link, bir eski doküman hâlâ oraya bakıyor olabilir.

Boyut: 9 reponun toplamı 470 MB’tı, monorepo da 470 MB; sürpriz yok. Sürpriz olan CI’daydı: tam klon 55 saniye ve 8 workflow bunu 8 kez yapıyordu. fetch-depth: 1 ile checkout 4 saniye. Sparse checkout’a gerek kalmadı. LFS kullanmıyoruz; kullanıyor olsaydın filter-repo LFS nesnelerini taşımaz, önce onları ayrıca planlaman gerekir.

5. Tek kapı, tek anahtar

Poly repo’nun sessiz faydası erişim kontrolüydü: ajans yalnızca entegrasyon reposunu görüyordu. Monorepoda herkes her şeyi görür; GitHub’da dizin bazlı okuma izni yok. CODEOWNERS, branch protection’da “require review from Code Owners” açıkken review’ı sınırlar; okumayı hiçbir ayarla sınırlamaz. Bugün 3 kişiyiz, sorun değil. Yarın dışarıdan biri girerse iki seçenek var, ikisini de not olarak yazdık: ajansın çalışacağı servisi git subtree split ile ayrı bir repoya çıkarıp iki yönlü senkron tutmak; ya da o servisi monorepoya hiç almamak. İlk aday entegrasyon: neredeyse hiç kimseyle birlikte değişmiyor (aşağıda rakamı var). Çözülmesi gereken gün bellidir: ilk dış hesap açıldığı gün.

Tek repoda sürüm, geri alma, hotfix

“Bir servisin hatası diğerinin deploy’unu geri almaz” cümlesi, mekanizması yazılmadan havada kalır. Bizdeki mekanizma üç kural:

  • Tag servis başına, önekle. odeme/v1.4.2, hesap/v2.1.0. Sürüm numaraları eski repodan kaldığı yerden devam eder. Release notu: git log odeme/v1.4.1..odeme/v1.4.2 -- services/odeme libs/; yalnızca o servisi ilgilendiren commit’ler.
  • Geri alma git seviyesinde değil, image seviyesinde. Her deploy commit SHA’sıyla etiketli bir image; geri almak önceki image’ı tekrar deploy etmek. Git’e dokunulmaz, diğer 7 servis etkilenmez. common’daki bir hatayı revert edersen 8 CI çalışır, ama yine sıfır deploy tetiklenir; hangi servisin yeni build’e ihtiyacı varsa onu çıkarırsın.
  • Hotfix main’e. Düzeltme main’e girer, path filtresi yalnızca o servisin deploy’unu tetikler. Release branch yok; 6 ayda cherry-pick sayısı sıfır. Şartı: main her an deploy edilebilir olmalı, o servise ait yarım iş feature flag arkasında durmalı. Bu şartı poly repo’da da koymuştuk; monorepo onu zorunlu kıldı.

Asıl soru: 3 kişi kaç servis?

Repo sayısını konuşurken asıl sayıyı atlıyorduk. 3 kişi, 8 servis. Bu şu demek: 8 pipeline, 8 dashboard, 8 alarm seti, güvenlik yaması çıkınca 8 dependency update. Servis sınırı da repo sınırı gibi ekip sınırıdır; 4 ekip için çizilmiş 8 sınır, 1 ekip için fazladır.

Hangilerini birleştireceğimize göz kararıyla değil, geçmişe bakarak karar veriyoruz. Monorepo burada beklemediğimiz bir şey verdi: geçmiş tek yerde olunca “hangi servisler hep birlikte değişiyor?” sorusu tek komut oldu. Poly repo’da bu soruyu sormak için 8 repodan tarih birleştirmek gerekiyordu; kimse sormamıştı. Komutun iki tuzağı var, ikisine de düştük:

  • “Aynı commit” ölçütü taşınmış geçmişte çalışmaz. filter-repo ile gelen eski commit’lerin her biri tek servis dizinine dokunur; merge --allow-unrelated-histories commit’leri birleştirmez, aynı grafiğe koyar. Geçiş öncesi için cevap tanım gereği sıfırdır; ilk denememiz bunu verdi. Doğru ölçüt zaman yakınlığı: filter-repo tarihleri korur, aynı gün değişen servis çiftlerini saymak taşınmış geçmişte de çalışır.
  • Mekanik commit’ler her çifti şişirir. Toplu format, .editorconfig, global paket yükseltmesi: tek commit 8 dizine dokunur, 28 çiftin hepsine bir puan yazar. Bir günde 4’ten fazla servis dizinine dokunulduysa o günü saymıyoruz. Poly dönemin bump commit’leri de aynı cinsten: common çıkınca aynı gün 3 servise giren bump PR’ı kuplaj değil; yalnızca .csproj ya da lock dosyası değişen commit’leri saymıyoruz.
İki dönem ayrı ayrı: hangi servis çiftleri aynı gün değişti? (4’ten fazla servise dokunulan gün gürültü, yalnızca csproj/lock değişen commit bump sayılır)
cochange() {   # $1 = baslangic, $2 = bitis
  git log --since="$1" --until="$2" --date=short --pretty=format:'DAY %ad' --name-only \
    | awk '
        # %ad = author date: rebase/cherry-pick ile tasinmis gecmiste committer tarihi goc gunune coker,
        # author tarihi kalir. (--since/--until committer tarihine bakar; pencere sinirinda 1-2 gun kayabilir.)
        /^DAY /  { day = $2; next }
        /^services\// && !/\.csproj$/ && !/packages\.lock\.json$/ {
                   # sadece csproj/lock degisen commit = bump, kuplaj degil; kaynak dosya sart
                   split($0, p, "/"); k = day SUBSEP p[2]
                   if (!(k in seen)) { seen[k] = 1; list[day] = list[day] " " p[2] } }
        END {
          for (day in list) {
            n = split(list[day], s, " ")
            if (n > 4) { skipped++; continue }   # toplu format / global yukseltme gunu: atla
            for (i = 1; i <= n; i++)
              for (j = i + 1; j <= n; j++) {
                a = s[i]; b = s[j]
                if (a > b) { t = a; a = b; b = t }  # gun icindeki sira rastgele; cifti alfabetik sabitle
                pair[a " + " b]++
              }
          }
          for (k in pair) print pair[k], k
          print skipped + 0, "(atlanan gun)"
        }
      ' | sort -rn | head -5
}
cochange 2025-09-20 2026-03-20     # poly repo donemi, 6 ay
cochange 2026-03-20 2026-09-18     # monorepo donemi, 6 ay

# cikti (bizde):                 poly 6 ay    mono 6 ay
#   hesap + limit                   29           38
#   hesap + odeme                   14           24
#   bildirim + odeme                 6            9
#   hesap + raporlama                1            3
#   (atlanan gun)                    4            5
# entegrasyon'un girdigi en yuksek cift: 1 / 2 (tesadufen ayni gun)

İki yarı aynı şeyi ölçmüyor, bunu kabul ederek okuyoruz: poly dönemde iki servisi birlikte değiştiren iş iki repoda iki commit ve bump gecikmesiyle çoğu zaman iki ayrı güne düşer, düşük sayar; mono dönemde tek PR, tek gün, yüksek sayar. O yüzden iki yarıyı toplamıyoruz. Güvendiğimiz şey rakam değil, sıra: iki dönemde de aynı dört çift, aynı sırada. hesap ile limit poly yarıda 29, mono yarıda 38 gün birlikte değişmiş; bu iki servis değil, ağ üstünden konuşan tek servis. Birleştirme adayı bir numara. entegrasyon neredeyse hiç kimseyle birlikte değişmemiş, en yüksek çifti 2 gün; ayrı kalır, dokunulmaz. Birleştirme, geçişle aynı PR’da yapılmaz; önce taşı, sonra ölç, sonra birleştir. hesap + limit birleştirmesi Ekim’de; ayrı yazı.

Üç kişiye sekiz servis, sekiz alarm seti demek. Repo birleştirmek bunu değiştirmez.

Geçiş sırası

Karar Şubat sonunda; geçiş 2–20 Mart 2026, üç hafta, servis başına bir PR. Sıra:

Bu sırayla
  1. Boş monorepoda iskelet CI’ı yaz. Servis başına CI ve deploy workflow şablonu, path filtresi. Henüz üstünde denenecek servis yok; şablon.
  2. Ön şartları kapat. Target framework hizası, Directory.Packages.props. Bizde 2 gün.
  3. Ortak kütüphaneleri taşı. En çok acı orada, en hızlı kazanım orada; servisler proje referansına ancak bundan sonra geçebilir.
  4. Pilot: en az bağımlı tek servisi taşı. Paket referansı → proje referansı, tag’ler öneklenmiş, devir listesi kapanmış. İskelet CI ilk kez gerçek bir servisle koşar; required check tuzağı bize burada çıktı.
  5. Kalan servisleri aynı yolla taşı. Servis başına bir PR.
  6. Devri doğrula, sonra arşivle. Eski repoda webhook, deploy credential’ı, açık PR ve dış link kalmadığını kontrol et; bir deploy’u yeni repodan uçtan uca çalıştır. Arşiv geri alınabilir, Pazartesi sabahı fark edilen kopuk deploy zinciri değil. Sonra arşivle; silme.
  7. Co-change analizi çalıştır. Gün bazlı ölçüt taşınmış geçmişte de çalışır; beklemek gerekmez. Birleştirme adaylarını listele; birleştirme ayrı iş.
Bunları yapma
  • Hepsini bir hafta sonunda taşımak. Pazartesi hiçbir CI çalışmaz.
  • “CI’ı sonra düzeltiriz.” Sonra, 31 dakikalık pipeline demek.
  • Required check’e pull_request.paths koymak. İlk ilgisiz PR sonsuza kadar bekler.
  • Paket basmaya devam etmek.
  • Tag’leri öneklemeden merge etmek. 8 tane v1.0.0.
  • Servis birleştirmeyi taşımayla aynı PR’da yapmak. İki değişiklik, tek geri alma noktası.
  • Eski repoları silmek.

Ne izlemeli? Ve 6 ay sonra ne oldu?

Yazı boyunca ölçüm dedim; ölçmeden bitirmek olmaz. 20 Mart’tan 18 Eylül’e, 6 ay:

  • Ortak değişiklik lead time. libs/common’da PR açılmasından, o değişikliğe ihtiyacı olan son servisin deploy’una. Önce 3 gün; sonra 11 PR’da medyan 5 saat, en uzunu 1 gün (review bekledi). Unutulan servis: 0, çünkü unutulacak adım yok.
  • PR başına dokunulan servis dizini. Sürekli 8/8 ise ortak kütüphane fazla şey biliyor; sınırı düzelt. Bizde 11 common PR’ının 2’si 8/8’e dokundu (ikisi de log formatı), geri kalanı 1–3 servis.
  • Servis başına CI süresi, p95. Path filtresi çalışıyorsa 4 dakikada kalır. Bizde ilk hafta 4 dk 40 sn (klon büyüdü), fetch-depth: 1 sonrası 4 dk 10 sn. 10’u geçerse birisi filtreyi genişletmiştir; 4’ün altına düşen bir servis varsa daraltmıştır, o da kötü.
  • Haftalık deploy sayısı. Düşüyorsa monorepo korkutuyor demektir. Bizde geçiş öncesi ortalama 9, sonra 14; artışın çoğu common değişikliğinin aynı gün çıkmasından.
  • Versiyon matrisi. Sıfır satır olmalı. Bir satır bile varsa biri paket basıyor. Bizde sıfır.

Kontrol listesi

Geçmeden önce
  • Repo sınırı bugün hangi ekibi hangi ekipten koruyor? Cevap “kimseyi” ise geç.
  • Ortak kütüphane yılda kaç sürüm alıyor? Son bir yılda kaç bump PR açıldı, kaçı açılmadı?
  • Renovate/Dependabot ile auto-merge yeter miydi? Hangi işi çözmezdi?
  • Her servisin kendi CI ve deploy workflow’u var mı? libs/ değişince hangi CI’lar çalışıyor, kaç deploy tetikleniyor?
  • Required check’lerde pull_request.paths var mı? İlgisiz bir PR merge olabiliyor mu?
  • csproj’daki lib referansları ile workflow’daki paths listesi eşleşiyor mu? Bunu kim kontrol ediyor?
  • Ortak kod paket olarak mı, kaynak olarak mı referanslanıyor? Dışarıdan tüketen var mı?
  • Geçmiş filter-repo ile mi taşındı? Tag’ler öneklendi mi? git log eski commit’leri görüyor mu?
  • Eski repodan neler devredildi: açık PR, issue, webhook, secret, badge, link?
  • Tek servisi geri almak nasıl yapılıyor: image mı, git mi? Hotfix nereye giriyor?
  • Kaç kişi kaç servis işletiyor? Hangi çiftler aynı gün değişti; poly ve mono dönemi ayrı baktın mı? Gürültü günlerini ve bump commit’lerini eledin mi?
  • Dışarıdan biri repoya girerse ne görür? O gün için hangi seçenek yazılı?
  • Eski repolar arşivlendi mi, silindi mi?

Sonuç

Üç yıl önce 9 repo doğru karardı. Bugün doğru bir kararı geri almıyoruz; sebebi ortadan kalktığı için sınırı kaldırıyoruz. Repo sınırı, 4 ekibin birbirini beklememesi için vardı. 3 kişilik ekipte bekleyecek kimse yok; sınır olduğu yerde durdu ve enum’a eklenen tek değer üç güne mal oldu, bir servis de yolda unutuldu. Yılda 19 kez.

Monorepo bu bedeli kaldırdı: 3 gün, 5 saat oldu; unutulan servis sıfır. Başka bir şeyi de gizlemiyor: ortak kütüphane fazla şey biliyorsa artık her PR’da görünür, 8 servis 3 kişiye fazlaysa git log söyler. İkisini de düzeltmek ayrı iş; ama ikisini de ancak tek repoda ölçebildik.

Akılda kalacak cümle Conway’in cümlesinin repo hâli: repo yapısı bir mimari tercih değil, ekip yapısının yansımasıdır. Ekip değiştiyse repo da değişir. Değişmiyorsa, bedelini kim ödüyor diye bak.