İçeriğe geç
·20 dk okuma

Turborepo Monorepo'ları: Gerçekten Ölçeklenen Kurulum

Birden fazla uygulamanın paketleri paylaştığı Turborepo monorepo'larını nasıl yapılandırıyorum. Workspace yapılandırması, pipeline önbelleği, paylaşılan TypeScript config'leri ve ilk ayda yaptığım hatalar.

Paylaş:X / TwitterLinkedIn

Kariyerimin ilk üç yılını repository'ler arasında utility fonksiyonları kopyalayarak geçirdim. Abartmıyorum. Yedi farklı projede yaşayan, her birinde biraz farklı bug'ları olan bir formatDate.ts'im vardı. Birinde timezone sorununu düzelttiğimde, diğer altısını unuturdum. Sonunda Avustralya'daki bir kullanıcı ticket açardı ve düzeltmenin o belirli uygulamada production'a ulaşmadığını keşfederdim.

Monorepo bunu çözdü. Trend olduğu için değil, Google yaptığı için değil, insan paket kayıt defteri olmaktan bıktığım için. Tek repository, paylaşılan kod, onu tüketen her uygulama boyunca atomik değişiklikler.

Ama monorepo'ların kendi başarısızlık modları var. Üç farklı araç denedim, bozuk önbellekle haftalarca uğraştım, gece yarısı döngüsel bağımlılık hatalarıyla savaştım ve "her şeyi tek repo'ya koy" tavsiyesinin detaylar olmadan ne kadar yararsız olduğunu öğrendim.

Bu, gerçekten kullandığım Turborepo kurulumu. On iki paylaşılan paketle dört production uygulaması çalıştırıyor. Uzaktan önbellek sayesinde build'ler 90 saniyenin altında sürüyor. Yeni geliştiriciler klonlayıp pnpm dev çalıştırabilir ve iki dakikada her şey çalışır durumda olur. Buraya ulaşmam yaklaşık bir ay hata yapmamı gerektirdi.

İlk Etapta Neden Monorepo#

Ödünleşimler konusunda dürüst olalım. Monorepo bedava değil. Bir dizi sorunu başka bir diziyle takas ediyorsun ve ne satın aldığını bilmen gerekiyor.

Ne Kazanıyorsun#

Yayınlamadan kod paylaşımı. Asıl büyük kazanç bu. packages/ui içinde bir React bileşen kütüphanesi yazıyorsun. Web uygulamanın, admin panelin ve dökümantasyon siten hepsi buradan import ediyor. Bir button bileşenini değiştirdiğinde, her uygulama bunu anında alıyor. Versiyon yükseltme yok, npm publish yok, "production hangi versiyonu kullanıyor?" sorusu yok.

typescript
// apps/web/src/components/Header.tsx içinde
import { Button, Avatar, Dropdown } from "@acme/ui";
import { formatDate } from "@acme/utils";
import { SITE_CONFIG } from "@acme/config";

Bu import'lar dış paketler gibi görünüyor. Workspace bağımlılıkları üzerinden çözülüyorlar. Ama aynı repository'deki kaynak kodunu gösteriyorlar.

Atomik çapraz paket değişiklikleri. Paylaşılan Button bileşenine bir isLoading prop'u eklemenin gerektiğini düşün. Polyrepo dünyasında, bileşen kütüphanesini değiştirirsin, yeni bir versiyon yayınlarsın, her tüketen uygulamada versiyonu güncellersin, sonra her repoda PR açarsın. Tek bir prop için üç ila beş PR.

Monorepo'da tek bir commit:

feat: Button bileşenine isLoading prop'u ekle

- packages/ui/src/Button.tsx (prop ekle, spinner render et)
- apps/web/src/pages/checkout.tsx (isLoading kullan)
- apps/admin/src/pages/users.tsx (isLoading kullan)

Tek PR. Tek review. Tek merge. Her şey senkron kalır çünkü fiziksel olarak sapması mümkün değil.

Birleşik araçlar. Tek ESLint config. Tek Prettier config. Tek TypeScript base config. Tek CI pipeline. ESLint yükselttiğinde, bir öğleden sonrada her yeri yükseltiyorsun, sekiz repoda üç sprint boyunca değil.

Ne Ödüyorsun#

Kökteki karmaşıklık. Kök package.json dosyan altyapıya dönüşür. CI pipeline'ının hangi paketlerin değiştiğini anlaması gerekir. IDE'n dosya sayısının çokluğuyla zorlanabilir. Repo büyüdükçe Git operasyonları yavaşlar (ama bu çoğu takım için yıllar alır).

CI süresi şişebilir. Her commit'te her şeyi build edersen, devasa miktarda işlem gücü harcarsın. Bağımlılık grafiğini anlayıp sadece değişenleri build eden bir araca ihtiyacın var. Turborepo'nun var olma nedeni tam olarak bu.

Onboarding sürtünmesi. Yeni geliştiricilerin workspace'leri, hoisting'i, dahili paketleri, build pipeline'ını anlaması gerekiyor. Sadece "klonla ve çalıştır" değil. Sonunda öyle olmalı, ama oraya ulaşmak bilinçli çaba gerektirir.

Dürüst değerlendirme: paylaşılan kodu olmayan tek bir uygulamanız varsa, monorepo faydası olmayan bir yüktür. İki veya daha fazla uygulamanız herhangi bir şeyi paylaşıyorsa — bileşenler, utility'ler, tipler, config'ler — monorepo ilk ay içinde kendini amorti eder.

Turborepo vs Nx vs Lerna#

Üçünü de kullandım. İşte kısa versiyonu.

Lerna JavaScript için orijinal monorepo aracıydı. Versiyonlama ve yayınlamayı yönetir. Ama build pipeline'larını veya önbelleklemeyi anlamaz. Terk edildi, Nx tarafından yeniden canlandırıldı ve şimdi bağımsız bir araçtan çok bir uyumluluk katmanı gibi hissettiriyor. Paketleri npm'e yayınlaman gerekiyorsa, Lerna + Nx bunu halledebilir. Ama sadece kendi uygulamaların arasında kod paylaştığın dahili monorepo'lar için, ihtiyacından fazla seremoni.

Nx güçlü. Gerçekten güçlü. Generatör'leri, her framework için plugin'leri, görsel bağımlılık grafiği, dağıtık görev yürütmesi var. Ayrıca dimdik bir uçurum gibi görünen bir öğrenme eğrisi var. Herhangi bir ürün kodu yazmadan Nx'i yapılandırmak için iki hafta harcayan takımlar gördüm. 50'den fazla geliştiricisi ve yüzlerce paketi olan bir şirketteysen, Nx muhtemelen doğru seçim. Benim kullanım alanlarım için, küreğe ihtiyacım varken buldozer.

Turborepo üç şeyi iyi yapıyor: bağımlılık grafiğini anlıyor, build çıktılarını önbelleğe alıyor ve görevleri paralel çalıştırıyor. Hepsi bu. Tüm yapılandırma tek bir turbo.json dosyası. Tamamını iki dakikada okuyabilirsin. Kod üretmez, plugin'leri yok, build aracını değiştirmeye çalışmaz. Neyin çalıştırılması gerektiğini ve neyin atlanabileceğini bilme konusunda çok ama çok iyi olan bir görev çalıştırıcısı.

Turborepo'yu seçtim çünkü tüm kurulumu yeni bir takım arkadaşına 15 dakikada açıklayabiliyordum. Nx ile aynı konuşma bir saat sürüyordu ve hâlâ soruları oluyordu.

Özellik           | Turborepo     | Nx              | Lerna
------------------+---------------+-----------------+----------------
Config karmaşıklığı | Düşük (1 dosya) | Yüksek (birden fazla) | Orta
Öğrenme eğrisi    | ~1 gün        | ~1 hafta        | ~2 gün
Build önbelleği   | Evet (uzaktan) | Evet (uzaktan)  | Hayır (yerel)
Görev orkestrasyonu| Evet          | Evet            | Temel
Kod üretimi       | Hayır         | Evet (kapsamlı) | Hayır
Framework plugin  | Hayır         | Evet            | Hayır
En iyi uyum       | Küçük-orta    | Büyük org.      | Yayınlama

Repository Yapısı#

İşte gerçek dizin ağacı. "Başlangıç" örneği değil — bu, altı ay sonra bir production monorepo'sunun gerçekte nasıl göründüğü.

acme-monorepo/
├── apps/
│   ├── web/                    # Ana müşteri uygulaması
│   │   ├── src/
│   │   ├── public/
│   │   ├── next.config.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── admin/                  # Dahili admin paneli
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── docs/                   # Dökümantasyon sitesi
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── api/                    # Express/Fastify API sunucusu
│       ├── src/
│       ├── package.json
│       └── tsconfig.json
├── packages/
│   ├── ui/                     # Paylaşılan React bileşen kütüphanesi
│   │   ├── src/
│   │   │   ├── components/
│   │   │   │   ├── Button.tsx
│   │   │   │   ├── Input.tsx
│   │   │   │   ├── Modal.tsx
│   │   │   │   └── index.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── utils/                  # Paylaşılan utility fonksiyonları
│   │   ├── src/
│   │   │   ├── date.ts
│   │   │   ├── string.ts
│   │   │   ├── validation.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── types/                  # Paylaşılan TypeScript tipleri
│   │   ├── src/
│   │   │   ├── user.ts
│   │   │   ├── api.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── config-typescript/      # Paylaşılan tsconfig preset'leri
│   │   ├── base.json
│   │   ├── nextjs.json
│   │   ├── node.json
│   │   └── package.json
│   ├── config-eslint/          # Paylaşılan ESLint yapılandırmaları
│   │   ├── base.js
│   │   ├── next.js
│   │   ├── node.js
│   │   └── package.json
│   └── config-tailwind/        # Paylaşılan Tailwind preset'leri
│       ├── tailwind.config.ts
│       └── package.json
├── turbo.json
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json

apps/ ile packages/ Ayrımı#

Bu temel düzenleme ilkesi. apps/ deploy edilebilir şeyleri içerir — web uygulamaları, API'lar, dev veya start komutu olan her şey. packages/ kütüphaneleri içerir — uygulamalar veya diğer paketler tarafından tüketilmek için var olan kod.

Kural basit: uygulamalar paketleri tüketir. Paketler asla uygulamalardan import yapmaz. Paketler diğer paketlerden import yapabilir.

Bir paket bir uygulamadan import etmeye başlarsa, bağımlılık grafiğini tersine çevirmişsin demektir ve Turborepo bunu döngü olarak yakalar.

Workspace Yapılandırması#

Kök pnpm-workspace.yaml dosyası pnpm'e paketlerin nerede olduğunu söyler:

yaml
packages:
  - "apps/*"
  - "packages/*"

Dosyanın tamamı bu. pnpm bu dizinleri tarar, her package.json'u okur ve bir workspace haritası oluşturur. apps/web bağımlılık olarak "@acme/ui": "workspace:*" bildirdiğinde, pnpm npm registry'sine bakmak yerine yerel packages/ui'a bağlar.

Kök package.json#

json
{
  "name": "acme-monorepo",
  "private": true,
  "scripts": {
    "build": "turbo build",
    "dev": "turbo dev",
    "lint": "turbo lint",
    "test": "turbo test",
    "clean": "turbo clean",
    "format": "prettier --write \"**/*.{ts,tsx,md,json}\""
  },
  "devDependencies": {
    "prettier": "^3.4.2",
    "turbo": "^2.4.4",
    "typescript": "^5.7.3"
  },
  "packageManager": "pnpm@9.15.4",
  "engines": {
    "node": ">=20"
  }
}

Dikkat: kök package.json dosyasında çalışma zamanı bağımlılığı yok. Tamamen orkestrasyon. turbo görev çalıştırıcısı, prettier biçimlendirmeyi halleder (çünkü paket başına yapılandırma gerektirmeyen tek araç) ve typescript hoist edilmiş durumda böylece her paket aynı versiyonu kullanır.

İsimlendirme Kuralları#

Her paket scope'lu bir isim alır: @acme/ui, @acme/utils, @acme/config-typescript. Scope, npm paketleriyle çakışmayı önler ve herhangi bir import ifadesinde dahili mi yoksa dış mı kod kullandığını anında belli eder.

typescript
// Dış paket - npm'den
import { clsx } from "clsx";
 
// Dahili paket - monorepo'muzdan
import { Button } from "@acme/ui";

Config paketlerini görsel olarak gruplamak için config- ön ekini kullanıyorum: @acme/config-typescript, @acme/config-eslint, @acme/config-tailwind. Bazı takımlar @acme/tsconfig, @acme/eslint-config kullanıyor. İkisi de çalışır. Tutarlı ol yeter.

turbo.json Pipeline Yapılandırması#

Turborepo'nun hak ettiğini kazandığı yer burası. turbo.json dosyası görev pipeline'ını tanımlar — hangi görevlerin var olduğu, neye bağlı oldukları ve ne ürettikleri.

json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": [
    "**/.env.*local"
  ],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
      "inputs": ["src/**", "package.json", "tsconfig.json"]
    },
    "lint": {
      "dependsOn": ["^build"],
      "cache": true
    },
    "test": {
      "dependsOn": ["^build"],
      "cache": true,
      "inputs": ["src/**", "test/**", "**/*.test.ts", "**/*.test.tsx"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "clean": {
      "cache": false
    },
    "type-check": {
      "dependsOn": ["^build"],
      "cache": true
    }
  }
}

Her alanı açıklayayım çünkü çoğu insanın kafasının karıştığı yer burası.

dependsOn ve ^ Notasyonu#

"dependsOn": ["^build"] şu demek: "Bu görevi X paketinde çalıştırmadan önce, X'in bağlı olduğu her pakette build çalıştır."

^ sembolü "upstream bağımlılıkları" demek. Bu olmadan, "dependsOn": ["build"], "önce aynı pakette build çalıştır" anlamına gelir — test görevinin aynı paket içinde önce bir build'e ihtiyaç duyması durumunda faydalı.

İşte somut bir örnek. Bağımlılık grafiğin şöyle görünüyor:

apps/web → @acme/ui → @acme/utils
                    → @acme/types

turbo build çalıştırdığında, Turborepo grafiği çözer:

  1. @acme/types build et (bağımlılığı yok)
  2. @acme/utils build et (bağımlılığı yok)
  3. @acme/ui build et (types ve utils'e bağlı — onları bekler)
  4. apps/web build et (ui'a bağlı — onu bekler)

1 ve 2. adımlar paralel çalışır. 3. adım ikisini de bekler. 4. adım 3'ü bekler. Turborepo bunu package.json bağımlılık bildirimlerinden çözer. Sırayı elle belirtmen gerekmez.

outputs#

Bu, önbellek için kritik. Turborepo bir build'i önbelleğe aldığında, outputs'ta listelenen dosyaları saklar. Bir önbellek isabeti aldığında, bu dosyaları geri yükler. Bir çıktı dizinini listelemezsen, önbellek "başarılı" olur ama dosyaların görünmez. İlk haftadaki en büyük hatam buydu — .next/**'ı atladım ve Next.js uygulamam neden sürekli yeniden build ediliyor anlayamıyordum.

json
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]

!.next/cache/** istisnası önemli. Next.js'in kendi önbelleği .next/cache/ içinde var. Bir önbelleğin önbelleğini saklamak istemezsin — uzaktan önbellek depolamanı şişirir ve yardımcı olmaz.

inputs#

Varsayılan olarak, Turborepo bir paketteki her dosyanın hash'ini alarak değişip değişmediğini belirler. inputs alanı bunu daraltır. Sadece src/ içindeki dosyalar build çıktısını etkiliyorsa, README.md'yi değiştirmek önbelleği geçersiz kılmamalı.

json
"inputs": ["src/**", "package.json", "tsconfig.json"]

Bununla dikkatli ol. Build'ini etkileyen bir config dosyası ekler (diyelim postcss.config.js) ve bunu inputs'a dahil etmezsen, bayat önbellek çıktısı alırsın. Emin değilsen, inputs'u ayarlamadan bırak ve Turborepo'nun her şeyi hash'lemesine izin ver.

globalDependencies#

Burada listelenen dosyalar değiştiğinde her paketin önbelleği geçersiz olur. Ortam dosyaları klasik örnek — .env.local değişirse, process.env'den okuyabilecek her şeyin yeniden build edilmesi gerekir.

json
"globalDependencies": ["**/.env.*local"]

Kök seviyedeki tsconfig.json'u da buraya ekliyorum çünkü temel TypeScript config'im tüm paketleri etkiliyor:

json
"globalDependencies": [
  "**/.env.*local",
  "tsconfig.json"
]

persistent ve dev#

dev görevinin "cache": false ve "persistent": true ayarları var. Dev sunucuyu önbelleğe almak mantıklı değil — uzun süren bir süreç. persistent bayrağı Turborepo'ya bu görevin bitmediğini söyler, böylece diğer görevleri çalıştırmadan önce onu beklemez.

turbo dev çalıştırdığında, Turborepo tüm dev sunucuları paralel başlatır — Next.js uygulamanı, API sunucunu, dökümantasyon siteni — hepsi tek terminalde iç içe çıktılarla. Her satır paket adıyla öne eklenir böylece hangisinin hangisi olduğunu ayırt edebilirsin.

Paylaşılan Paketler Detaylı#

packages/ui — Bileşen Kütüphanesi#

Bu, her takımın önce oluşturduğu paket. Tüm frontend uygulamaları arasında kullanılan paylaşılan React bileşenleri.

json
{
  "name": "@acme/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "default": "./src/index.ts"
    },
    "./styles.css": "./src/styles.css"
  },
  "scripts": {
    "build": "tsup src/index.ts --format esm,cjs --dts --external react",
    "dev": "tsup src/index.ts --format esm,cjs --dts --external react --watch",
    "clean": "rm -rf dist",
    "lint": "eslint src/",
    "type-check": "tsc --noEmit"
  },
  "dependencies": {
    "@acme/utils": "workspace:*",
    "@acme/types": "workspace:*"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "@acme/config-eslint": "workspace:*",
    "tsup": "^8.3.6",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "typescript": "^5.7.3"
  },
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0"
  }
}

Dikkat edilecek birkaç şey:

"version": "0.0.0" — Dahili paketlerin gerçek versiyonlara ihtiyacı yok. workspace:* protokolü pnpm'e her zaman yerel kopyayı kullanmasını söyler. Versiyon numaraları anlamsız.

"private": true — Bu paket asla npm'e yayınlanmayacak. Eğer yayınlamak istersen, bu bayrağı kaldır ve düzgün bir versiyonlama stratejisi kur.

exports alanı — Paket giriş noktalarını tanımlamanın modern yolu. main, module ve types alanlarını değiştirir. "." export'u varsayılan import yolu. Detaylı import'lar için alt yol export'ları ekleyebilirsin:

json
{
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "default": "./src/index.ts"
    },
    "./Button": {
      "types": "./src/components/Button.tsx",
      "default": "./src/components/Button.tsx"
    },
    "./styles.css": "./src/styles.css"
  }
}

Önemli karar: build etmek mi etmemek mi? Bazı takımlar dahili paketler için build adımını tamamen atlar. tsup ile derlemek yerine, exports'u doğrudan TypeScript kaynağına yönlendirir ve tüketen uygulamanın bundler'ının (Next.js, Vite) derlemeyi halletmesine izin verirler. Turborepo terminolojisinde buna "dahili paketler" denir ve daha basittir:

json
{
  "name": "@acme/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts"
  }
}

Build adımı yok. dist klasörü yok. Tüketen uygulamanın next.config.ts'inde transpilePackages gerekir:

typescript
// apps/web/next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  transpilePackages: ["@acme/ui", "@acme/utils"],
};
 
export default nextConfig;

Çoğu dahili paket için build etmeden yaklaşımı kullanıyorum. Geliştirme sırasında daha hızlı ve bozulacak bir şey daha az. İstisna, TypeScript olmayan tüketicileri desteklemesi gereken veya karmaşık build gereksinimleri olan paketler.

packages/utils — Paylaşılan Utility'ler#

json
{
  "name": "@acme/utils",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts",
    "./date": "./src/date.ts",
    "./string": "./src/string.ts",
    "./validation": "./src/validation.ts"
  },
  "dependencies": {
    "@acme/types": "workspace:*"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "typescript": "^5.7.3"
  }
}

Alt yol export'ları tüketen uygulamaların sadece ihtiyaç duyduklarını import etmesine olanak tanır:

typescript
// Sadece tarih utility'lerini import eder, tüm paketi değil
import { formatRelativeDate, parseISO } from "@acme/utils/date";
 
// Her şeyi import eder (barrel export)
import { formatRelativeDate, slugify, validateEmail } from "@acme/utils";

src/index.ts'deki barrel export her şeyi yeniden dışa aktarır:

typescript
// packages/utils/src/index.ts
export * from "./date";
export * from "./string";
export * from "./validation";

Barrel export'lar hakkında bir uyarı: pratik ama bazı bundler'larda tree-shaking'i bozabilirler. apps/web @acme/utils'dan tek bir fonksiyon import ederse, safça bir bundler her şeyi dahil edebilir. Next.js yerleşik tree-shaking'iyle bunu iyi halledebilir, ama bundle boyutu sorunları fark edersen, alt yol export'ları kalıbı daha güvenli.

packages/types — Paylaşılan TypeScript Tipleri#

json
{
  "name": "@acme/types",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts",
    "./api": "./src/api.ts",
    "./user": "./src/user.ts"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "typescript": "^5.7.3"
  }
}

Bu paketin sıfır çalışma zamanı bağımlılığı var. Sadece TypeScript tip ve interface'leri içerir. Build edilmesine gerek yok çünkü tipler derleme zamanında siliniyor.

typescript
// packages/types/src/user.ts
export interface User {
  id: string;
  email: string;
  name: string;
  role: "admin" | "user" | "viewer";
  createdAt: Date;
  updatedAt: Date;
}
 
export interface CreateUserInput {
  email: string;
  name: string;
  role?: User["role"];
}
 
export interface UserFilters {
  role?: User["role"];
  search?: string;
  page?: number;
  limit?: number;
}
typescript
// packages/types/src/api.ts
export interface ApiResponse<T> {
  data: T;
  meta?: {
    page: number;
    limit: number;
    total: number;
  };
}
 
export interface ApiError {
  code: string;
  message: string;
  details?: Record<string, string[]>;
}
 
export type ApiResult<T> =
  | { success: true; data: T }
  | { success: false; error: ApiError };

packages/config-typescript — Paylaşılan TSConfig'ler#

Bu aldatıcı derecede önemli. Monorepo'da TypeScript yapılandırması hızlıca karışıklığa dönüşen yer.

json
{
  "name": "@acme/config-typescript",
  "version": "0.0.0",
  "private": true,
  "files": [
    "base.json",
    "nextjs.json",
    "node.json",
    "react-library.json"
  ]
}

Tüm paketlerin genişlettiği temel yapılandırma:

json
// packages/config-typescript/base.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true
  },
  "exclude": ["node_modules"]
}

Next.js'e özel config:

json
// packages/config-typescript/nextjs.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "./base.json",
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "noEmit": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      { "name": "next" }
    ]
  }
}

Backend paketleri için Node.js config'i:

json
// packages/config-typescript/node.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "./base.json",
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

Şimdi her uygulama veya paket uygun preset'i genişletir:

json
// apps/web/tsconfig.json
{
  "extends": "@acme/config-typescript/nextjs",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}
json
// apps/api/tsconfig.json
{
  "extends": "@acme/config-typescript/node",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

@/* Yol Alias'ı#

Her uygulama kendi src/ dizinine işaret eden aynı @/* yol alias'ını alır. Yani apps/web'deki @/components/Header, apps/web/src/components/Header'a çözülür ve apps/admin'deki @/components/Header, apps/admin/src/components/Header'a çözülür.

Bunlar uygulama-yerel yollar. Paket sınırlarını aşmazlar. Paketler arası import'lar için her zaman paket adını kullanırsın: @acme/ui, @/../../packages/ui değil. Eğer paket kökünü aşan bir göreceli import görürsen (../../packages/something), bu kötü bir kalıp. Onun yerine workspace bağımlılığını kullan.

packages/config-eslint — Paylaşılan Linting#

javascript
// packages/config-eslint/base.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
 
export default tseslint.config(
  js.configs.recommended,
  ...tseslint.configs.recommended,
  {
    rules: {
      "@typescript-eslint/no-unused-vars": [
        "error",
        { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }
      ],
      "@typescript-eslint/no-explicit-any": "warn",
      "@typescript-eslint/consistent-type-imports": [
        "error",
        { prefer: "type-imports" }
      ],
    },
  }
);
javascript
// packages/config-eslint/next.js
import baseConfig from "./base.js";
import nextPlugin from "@next/eslint-plugin-next";
import reactPlugin from "eslint-plugin-react";
import hooksPlugin from "eslint-plugin-react-hooks";
 
export default [
  ...baseConfig,
  {
    plugins: {
      "@next/next": nextPlugin,
      "react": reactPlugin,
      "react-hooks": hooksPlugin,
    },
    rules: {
      ...nextPlugin.configs.recommended.rules,
      ...nextPlugin.configs["core-web-vitals"].rules,
      "react-hooks/exhaustive-deps": "warn",
    },
  },
];

Her uygulama kendi config'ine referans verir:

javascript
// apps/web/eslint.config.mjs
import nextConfig from "@acme/config-eslint/next";
 
export default [
  ...nextConfig,
  {
    ignores: [".next/"],
  },
];

Uzaktan Önbellek#

Yerel önbellek güzel — geliştirme sırasında değişmemiş paketleri yeniden build etmeni engeller. Ama uzaktan önbellek dönüştürücü. CI sunucunun senin yerel build'lerinden faydalanması ve tam tersi demek.

Nasıl Çalışır#

Turborepo bir görevi çalıştırdığında, şunlara dayalı bir hash hesaplar:

  • Kaynak dosyalar (içerik hash'i)
  • Görevin inputs yapılandırması
  • Ortam değişkenleri
  • Upstream bağımlılıkların hash'leri

Hash daha önce önbelleğe alınmış bir sonuçla eşleşirse, Turborepo görevi çalıştırmak yerine outputs'u önbellekten geri yükler. Uzaktan önbellekte, bu önbellek çıktıları herhangi bir makinenin — laptopunun, iş arkadaşının laptopunun, CI'ın — erişebildiği paylaşılan bir konumda saklanır.

Vercel Uzaktan Önbellek#

En basit kurulum. Vercel, önbellek altyapısını ücretsiz (limitlerle) sürdürür:

bash
# Vercel'e giriş yap (tek seferlik kurulum)
npx turbo login
 
# Repoyu bir Vercel projesine bağla
npx turbo link

Hepsi bu. Turborepo artık önbellek artefaktlarını Vercel sunucularına itip çekecek. CI'da ortam değişkenlerini ayarlarsın:

yaml
# .github/workflows/ci.yml
env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Token'ı Vercel panelinden takım ayarların altından oluştur. TURBO_TEAM takımının slug'ı.

Self-Hosted Uzaktan Önbellek#

Vercel kullanamıyorsan (hava boşluklu ortam, veri yerleşim gereksinimleri), kendin barındırabilirsin. Turborepo uzaktan önbellek API'ı iyi belgelenmiş ve birçok açık kaynak implementasyon var:

  • ducktors/turborepo-remote-cache — S3, GCS veya yerel dosya sisteminde artefakt saklayan bir Node.js sunucusu
  • fox1t/turborepo-remote-cache — Benzer, Azure Blob desteğiyle
bash
# docker-compose.yml self-hosted önbellek için
services:
  turbo-cache:
    image: ducktors/turborepo-remote-cache:latest
    ports:
      - "3000:3000"
    environment:
      TURBO_TOKEN: "your-secret-token"
      STORAGE_PROVIDER: "s3"
      S3_ACCESS_KEY: "${AWS_ACCESS_KEY_ID}"
      S3_SECRET_KEY: "${AWS_SECRET_ACCESS_KEY}"
      S3_REGION: "eu-central-1"
      S3_ENDPOINT: "https://s3.eu-central-1.amazonaws.com"
      S3_BUCKET: "turbo-cache"

Sonra monorepo'nu bunu kullanacak şekilde yapılandır:

json
// .turbo/config.json
{
  "teamId": "team_acme",
  "apiUrl": "https://turbo-cache.internal.acme.com"
}

Önbellek İsabet Oranları#

Deneyimlerime göre, iyi yapılandırılmış bir Turborepo CI'da %80-95 önbellek isabet oranı elde eder. Bu, herhangi bir CI pipeline'ında görevlerin sadece %5-20'sinin gerçekten çalıştığı anlamına gelir. Geri kalanı saniyeler içinde önbellekten geri yüklenir.

Yüksek önbellek isabet oranlarının anahtarları:

  1. inputs'unu daralt. Sadece src/ build'i etkiliyorsa, README değişikliğinin önbelleği geçersiz kılmasına izin verme.
  2. globalDependencies'e değişken veri koyma. Her deploy'da değişen bir .env dosyası önbelleğini öldürür.
  3. Bağımlılıklarını sabitle. Lockfile değişiklikleri downstream'deki her şeyi geçersiz kılar.
  4. Paketleri küçük ve odaklı tut. 200 dosyalı devasa bir packages/utils, herhangi bir utility'deki değişiklik tüm paketin önbelleğini bozdığu için sık sık geçersiz olur.

CI/CD Turborepo ile#

İşte kullandığım GitHub Actions workflow'u. Düzinelerce iterasyon üzerinden rafine edildi.

yaml
# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}
 
jobs:
  ci:
    name: Build, Lint, Test
    runs-on: ubuntu-latest
    timeout-minutes: 15
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 2   # Değişiklik tespiti için üst commit gerekli
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Build
        run: pnpm turbo build
 
      - name: Lint
        run: pnpm turbo lint
 
      - name: Type Check
        run: pnpm turbo type-check
 
      - name: Test
        run: pnpm turbo test

--filter Bayrağı#

--filter bayrağı, belirli paketler için veya neyin değiştiğine göre görev çalıştırmanın yolu. Verimli CI için zorunlu:

bash
# Sadece web uygulamasını ve bağımlılıklarını build et
pnpm turbo build --filter=apps/web...
 
# Sadece main'den beri değişen paketleri build et
pnpm turbo build --filter=...[main]
 
# Sadece @acme/ui ve ona bağımlı olan her şeyi build et
pnpm turbo build --filter=...@acme/ui
 
# Sadece packages/ dizinindeki paketleri build et
pnpm turbo build --filter="./packages/*"

Üç nokta söz dizimi önemli:

  • --filter=@acme/ui... — Paket ve tüm bağımlılıkları (ihtiyaç duyduğu şeyler)
  • --filter=...@acme/ui — Paket ve tüm bağımlıları (ona ihtiyaç duyan şeyler)
  • --filter=@acme/ui — Sadece paketin kendisi

Etkilenen Paket Tespiti#

Pull request'ler için muhtemelen her şeyi build etmek istemezsin. Sadece değişikliklerden etkileneni build etmek istersin:

yaml
# Sadece main'e kıyasla değişenleri build/test et
- name: Build affected
  run: pnpm turbo build --filter="...[origin/main]"
 
- name: Test affected
  run: pnpm turbo test --filter="...[origin/main]"

[origin/main] söz dizimi Turborepo'ya mevcut commit'i origin/main'e kıyaslamasını ve sadece değişikliği olan paketler için görev çalıştırmasını söyler. Uzaktan önbellekle birleştirildiğinde, büyük monorepo'lar için bile çoğu PR pipeline'ı 2 dakikanın altında tamamlanır.

Yaygın Tuzaklar#

Bu bölüme koskoca bir bölüm ayırıyorum çünkü ilk ayda bunların her birine çarptım. Dökümantasyondan öğrenmek verimli. Hatalarımdan öğrenmek bedava.

1. Döngüsel Bağımlılıklar#

Bağımlılık grafiği bir DAG (yönlü döngüsüz graf) olmalı. @acme/ui @acme/utils'dan import ediyorsa ve @acme/utils @acme/ui'dan import ediyorsa, bir döngün var. Turborepo şikayet edecek ve build etmeyi reddedecek.

Düzeltme neredeyse her zaman paylaşılan kodu üçüncü bir pakete çıkarmak:

ÖNCE (bozuk):
@acme/ui → @acme/utils → @acme/ui  ← döngü!

SONRA (düzeltilmiş):
@acme/ui     → @acme/types
@acme/utils  → @acme/types

Paylaşılan tipleri veya sabitleri @acme/types'a taşı ve her iki paketin de ona bağlı olmasını sağla.

Bir diğer yaygın neden: @acme/utils'a @acme/ui'dan bir bileşen import eden bir React hook koyarsın. UI bileşenlerine bağlı hook'lar @acme/ui'da olmalı, @acme/utils'da değil. Utility'ler mümkün oldukça framework-agnostik olmalı.

2. Eksik outputs Önbelleği Geçersiz Kılıyor#

En sinir bozucu bug bu. Yerelinde her şey çalışıyor gibi görünüyor. CI build'leri geçiyor. Ama her build tam süreyi alıyor çünkü hiçbir şey önbelleğe alınmamış.

Senaryo: paketin dist/ yerine build/'e build ediyor, ama turbo.json'da outputs'ta sadece dist/** listeleniyor:

json
// turbo.json
"build": {
  "outputs": ["dist/**"]  // ama paketin build/ klasörüne build ediyor
}

Düzeltme: her paketin build çıktı dizinini denetle ve turbo.json'un hepsini kapsadığından emin ol:

json
"build": {
  "outputs": ["dist/**", "build/**", ".next/**", "!.next/cache/**"]
}

Veya turbo.json'da paket başına override'lar kullan:

json
{
  "tasks": {
    "build": {
      "outputs": ["dist/**"]
    }
  },
  "packages": {
    "apps/web": {
      "build": {
        "outputs": [".next/**", "!.next/cache/**"]
      }
    },
    "apps/api": {
      "build": {
        "outputs": ["build/**"]
      }
    }
  }
}

3. Dev Sunucu Koordinasyonu#

turbo dev çalıştırmak tüm dev sunucuları aynı anda başlatır. Uygulamaların aynı portu kullanmaya çalışana kadar sorun yok. Açık port ataması zorunlu:

json
// apps/web/package.json
"scripts": {
  "dev": "next dev --port 3000"
}
 
// apps/admin/package.json
"scripts": {
  "dev": "next dev --port 3001"
}
 
// apps/docs/package.json
"scripts": {
  "dev": "next dev --port 3002"
}
 
// apps/api/package.json
"scripts": {
  "dev": "tsx watch src/index.ts"  // Config'den port 4000 kullanır
}

Diğer bir sorun: paylaşılan paketler için hot module replacement. packages/ui'da bir dosyayı düzenlediğinde, değişikliğin çalışan Next.js dev sunucusuna yansıması gerekir. "Dahili paketler" yaklaşımını kullanıyorsan (build adımı yok, doğrudan TypeScript import'ları), bu otomatik çalışır — Next.js symlink'li node_modules üzerinden kaynak dosyaları izler.

Paketin bir build adımı varsa, paketin dev script'inin watch modunda çalışması gerekir:

json
// packages/ui/package.json
"scripts": {
  "dev": "tsup src/index.ts --format esm,cjs --dts --external react --watch"
}

Turborepo tüm dev görevlerini paralel çalıştırır, böylece paket değişiklikte yeniden build olur ve tüketen uygulama yeni çıktıyı alır.

4. Paketler Arası Versiyon Sapması#

Bu incelikli ve tehlikeli. apps/web React 19 kullanırken apps/admin yanlışlıkla React 18 kullanıyorsa, @acme/ui'dan bir bileşenin her uygulamada farklı davranmasına kadar fark etmeyebilirsin.

Çözüm: paylaşılan bağımlılıkları kök seviyesinde yönet. pnpm'in kök package.json'daki overrides alanı her yerde tek versiyonu zorlar:

json
// Kök package.json
{
  "pnpm": {
    "overrides": {
      "react": "^19.0.0",
      "react-dom": "^19.0.0",
      "typescript": "^5.7.3"
    }
  }
}

Her şeyin aynı versiyonu kullandığını doğrulamak için düzenli olarak pnpm ls react --recursive çalıştır.

5. Paylaşılan Paketlerde Ortam Değişkenleri#

Paylaşılan paketler doğrudan process.env okumamalı. @acme/utils process.env.API_URL okursa, tüketen uygulamanın ortamına görünmez bir bağlantı oluşturur. Bunun yerine, yapılandırmayı açıkça geçir:

typescript
// KÖTÜ: packages/utils/src/api.ts
const API_URL = process.env.API_URL;  // Hangi ortam? Hangi uygulama?
 
export function fetchData(path: string) {
  return fetch(`${API_URL}${path}`);
}
 
// İYİ: packages/utils/src/api.ts
export function createApiClient(baseUrl: string) {
  return {
    fetch: (path: string) => fetch(`${baseUrl}${path}`),
  };
}

Tüketen uygulama yapılandırmayı sağlar:

typescript
// apps/web/src/lib/api.ts
import { createApiClient } from "@acme/utils";
 
export const api = createApiClient(process.env.NEXT_PUBLIC_API_URL!);

Bu, paketleri saf ve test edilebilir tutar.

6. Hayalet Bağımlılıklar#

pnpm varsayılan olarak bağımlılıklar konusunda katıdır — paketleri npm'in yaptığı gibi hoist etmez. Bu bir özellik, hata değil. @acme/ui clsx'i import edip package.json'unda listemezse, pnpm hata verir. npm onu sessizce üst node_modules'dan çözerdi.

Tüketen paketin package.json'unda her import'u her zaman bildir. Artık hoisting'e güvenme yok.

7. IDE Performans Düşüşü#

15+ paketle, TypeScript dil sunucun zorlanmaya başlayabilir. Birkaç ipucu:

  • Kök tsconfig.json'a "exclude": ["node_modules", "**/dist/**"] ekle
  • VS Code'un "Files: Exclude" ayarıyla dist/, .next/ ve .turbo/ klasörlerini gizle
  • Go to Definition seni node_modules derinlerindeki .d.ts dosyalarına gönderiyorsa, tsconfig'de "disableSourceOfProjectReferenceRedirect": true kullanmayı düşün

Docker için Budama#

Monorepo'dan tek bir uygulama deploy ederken, tüm repoyu Docker image'ına kopyalamak istemezsin. Turborepo'nun bir prune komutu var:

bash
pnpm turbo prune --scope=apps/web --docker

Bu, sadece apps/web ve bağımlılıkları için gereken dosyaları içeren bir out/ dizini oluşturur. Dockerfile'ın bu budanmış çıktıyı kullanır:

dockerfile
FROM node:20-alpine AS base
 
FROM base AS builder
WORKDIR /app
COPY out/json/ .
COPY out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN corepack enable pnpm && pnpm install --frozen-lockfile
 
COPY out/full/ .
RUN pnpm turbo build --filter=web...
 
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
 
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/public ./apps/web/public
 
EXPOSE 3000
CMD ["node", "apps/web/server.js"]

Docker image'ı sadece web uygulamasını ve bağımlılıklarını içerir. Admin uygulaması yok, API sunucusu yok, kullanılmayan paketler yok. Monorepo büyüse bile image boyutları makul kalır.

Bir Ay Sonra: Neleri Farklı Yapardım#

Şimdi bildiklerimi bilerek baştan başlasaydım:

  1. Dahili paketlerle başla (build adımı olmadan). Sadece Next.js uygulamalarımın tükettiği paketler için tsup build'leri yapılandırarak iki hafta harcadım. Next.js config'deki transpilePackages daha basit ve daha hızlı. Sadece gerçekten ihtiyacın olduğunda build adımı ekle.

  2. packages/ sayısını başlangıçta düşük tut. Erken dönemde çok agresif bölüştürdüm. Başlamak için packages/utils, packages/types ve packages/ui yeterli. Bir paket hantal hale geldiğinde daha sonra bölebilirsin. Erken bölmek, bakım edilecek daha fazla package.json dosyası ve bağımlılık grafiğinde daha fazla kenar demek.

  3. Uzaktan önbelleği ilk gün kur. npx turbo login && npx turbo link 30 saniye sürer. Sadece ilk haftada build'lerde kazanılan zaman bunu haklı çıkarır.

  4. Workspace komutlarını belgele. Yeni geliştiriciler pnpm --filter @acme/ui add lodash'ın belirli bir pakete kurulum yaptığını veya pnpm turbo build --filter=apps/web...'in sadece gerekeni build ettiğini bilmez. Katkıda bulunma rehberindeki basit bir "Monorepo Kopya Kağıdı" bölümü herkesin zamanını kurtarır.

  5. Bağımlılık yönünü ilk günden zorla. Bir uygulamadan bir pakete tek bir import'a bile izin verirsen, sınır hızla aşınır. Lint kuralı veya CI kontrolü ekle. Yön apps → packages → packages. Asla tersi.

Monorepo amaç değil. Amaç kendi kod tabanınla savaşmadan özellik çıkarmak. Turborepo, bunu çalıştırmak için bulduğum en hafif araç. Görev grafiğini yapıyor, önbelleklemeyi yapıyor ve geri kalan her şey için yolundan çekiliyor.

Basit başla. Karmaşıklığı repo gerektirdiğinde ekle. Ve portlarını sabitle.

İlgili Yazılar