Turborepo ile Production'a Hazır Bir Monorepo Kurmak
Bir süredir bu portföy sitesini de içinde barındıran projelerimin çoğunu tek bir monorepo altında topluyorum: bir Next.js sitesi, bir NestJS API'si ve Vite ile yazılmış bir admin paneli — hepsi tek bir pnpm + Turborepo yapısı içinde, birbirinden bağımsız ama ortak paketleri paylaşarak çalışıyor. Bu yazıda "neden monorepo" sorusundan başlayıp, gerçek bir kurulumun anatomisine kadar iniyorum.
Neden ayrı repo değil de monorepo?
Üç ayrı uygulamayı (web, api, admin) üç ayrı repoda tutmanın maliyeti göründüğünden büyük: aynı Zod şemasını üç yerde senkron tutmak, tip değişince üç ayrı PR açmak, yerel geliştirmede üç terminal + üç .env yönetmek. Monorepo bu maliyeti tek bir yerde toplar:
- Tek kaynak, çoklu tüketici:
packages/schemasiçindeki bir Zod şeması hem API'nin doğrulama katmanında hem web'in fetch tiplerinde hem de admin formunda birebir aynı tipi üretir. Şema değişince TypeScript derleyicisi üç uygulamada da anında kırılan yerleri gösterir. - Atomik değişiklik: Bir API endpoint'inin response şeklini değiştirdiğinizde, onu tüketen frontend kodunu da aynı commit'te güncelleyebilirsiniz. İki repo arasında "önce API'yi deploy et, sonra frontend'i güncelle" senkronizasyon derdi yok.
- Paylaşılan tooling: Tek bir
tsconfigtabanı (@myself/typescript-config), tek bir lint/format standardı, tek bir CI pipeline.
Dizin yapısı
myself/
├── apps/
│ ├── web/ # Next.js — public site
│ ├── api/ # NestJS — REST API
│ └── admin/ # Vite + React — yönetim paneli
├── packages/
│ ├── schemas/ # Paylaşılan Zod şemaları + tipler
│ ├── api-hooks/ # Paylaşılan data-fetching hook'ları (useApiQuery, useApiMutation)
│ └── typescript-config/
├── pnpm-workspace.yaml
└── turbo.json
apps/* gerçek çalıştırılabilir uygulamalar, packages/* ise onların paylaştığı kütüphaneler. Bu ayrım basit ama önemli bir kural getiriyor: packages/* hiçbir zaman apps/*'a bağımlı olamaz, sadece tersi mümkün.
pnpm-workspace.yaml bu iki klasörü workspace olarak tanımlıyor:
packages:
- "apps/*"
- "packages/*"
Bundan sonra bir uygulama diğerine workspace:* protokolüyle referans veriyor:
{
"dependencies": {
"@myself/schemas": "workspace:*",
"@myself/api-hooks": "workspace:*"
}
}
pnpm install bu paketleri node_modules içine kopyalamak yerine symlink'liyor — yani packages/schemas içinde yaptığınız bir değişiklik, build adımından sonra anında apps/web'in node_modules/@myself/schemas altında görünür oluyor.
Turborepo'nun asıl işi: görev grafiği
Turborepo'yu "hızlı bir script runner" olarak görmek yaygın bir yanılgı. Asıl değeri, turbo.json'da tanımlanan görev bağımlılık grafiğinde yatıyor:
{
"tasks": {
"dev": { "cache": false, "persistent": true },
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"typecheck": { "dependsOn": ["^build"] },
"lint": { "dependsOn": ["^build"] }
}
}
^build işareti "önce bağımlı olduğum paketlerin build'i bitsin" anlamına geliyor. Yani pnpm typecheck çalıştırdığınızda Turborepo önce packages/schemas'ın build'ini (tip tanımları dist/ altına derlenmeden apps/api onu import edemiyor), sonra packages/api-hooks'u, en son da apps/* paketlerinin typecheck'ini sırayla çalıştırıyor — hem de bağımlılık grafiğine göre paralel olarak, gereksiz sıralı bekleme olmadan.
dev görevini cache: false ve persistent: true olarak işaretlemek kritik: bu bir "bitmeyen" süreç (dev server), Turborepo'nun onu önbelleğe alıp "zaten çalıştırıldı" deyip atlamasını istemiyoruz.
Paylaşılan paket anatomisi: packages/schemas
Bu paket sadece Zod şemaları barındırıyor, hiçbir framework'e bağımlı değil:
// packages/schemas/src/project.ts
export const projectCategorySchema = z.enum(["ecommerce", "corporate", "blog", "demo", "saas", "other"]);
export const projectSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1).max(120),
slug: z.string().regex(/^[a-z0-9-]+$/),
category: projectCategorySchema,
// ...
});
export type Project = z.infer<typeof projectSchema>;
Bu tek dosya üç farklı yerde üç farklı görev üstleniyor:
- API'de (
apps/api): NestJS'inZodValidationPipe'ı bu şemayı@Body()/@Query()doğrulamasında kullanıyor — geçersiz istek asla controller metoduna ulaşmıyor. - Web'de (
apps/web):fetchJson(url, projectSchema)API'den dönen JSON'ı runtime'da doğruluyor — backend'de bir alan tipini yanlışlıkla değiştirirseniz, frontend'de sessizceundefinedalmak yerine anında hata alırsınız. - Admin'de (
apps/admin): Form gönderiminde aynı şemacreateProjectSchema.parse(input)ile kullanılıyor, backend'e gitmeden önce yerel doğrulama sağlıyor.
Paket iki farklı module formatında derleniyor (tsconfig.cjs.json ve tsconfig.esm.json), çünkü NestJS (CommonJS ağırlıklı) ve Next.js/Vite (ESM) farklı module sistemleri bekliyor:
{
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/cjs/index.d.ts",
"exports": {
".": {
"types": "./dist/cjs/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
}
}
Framework çeşitliliğinin getirdiği sürtünme
Üç farklı framework'ün aynı monorepo'da yaşaması bedava değil. İki somut sürtünme noktasıyla karşılaştım:
Port çakışması. apps/web (Next.js), apps/api (NestJS) ve apps/admin (Vite) varsayılan olarak sırasıyla 3000, bir env değişkeni ve 5173 portlarını kullanıyor — tam da makinedeki başka projelerin de sıkça kullandığı portlar. Çözüm: her uygulamaya proje-özel, çakışma ihtimali düşük portlar sabitlemek (next dev -p 3900, PORT fallback'i 3901, Vite server.port: 3902) ve API_ORIGIN/ADMIN_ORIGIN gibi cross-app referansları da bu portlara göre güncellemek.
Prisma client konumu. Varsayılan olarak Prisma client node_modules/@prisma/client altına üretilir. Monorepo'da bu, hangi paketin hangi Prisma versiyonunu gördüğü konusunda belirsizlik yaratabiliyor. Bunun yerine schema.prisma'da explicit bir output belirtmek işi netleştiriyor:
generator client {
provider = "prisma-client-js"
output = "../src/generated/prisma"
}
Böylece apps/api/src/generated/prisma içindeki client'ın tam olarak hangi şemadan üretildiği belli oluyor, ve import path'i (from '../generated/prisma') belirsizlik bırakmıyor.
Sonuç
Bir monorepo'nun asıl kazancı "tüm kodun tek yerde olması" değil — asıl kazanç, paylaşılan tiplerin ve doğrulama mantığının tek bir kaynaktan üç farklı çalışma zamanına (Node/NestJS, Next.js SSR+client, Vite/browser) sızabilmesi. Turborepo'nun görev grafiği bunu pratikte çalıştırılabilir kılan parça: hangi paketin hangi paketi build etmeden önce beklemesi gerektiğini bilen, ve gereksiz yeniden çalıştırmaları önbellekleyen bir zamanlayıcı.