Kendi Proje Template'in: dotnet new ile Bookstore Şablonu ve Angular Schematics'e Kısa Bakış
dotnet new template'i, içinde .template.config/template.json olan sıradan bir .NET solution'ı; sourceName, parametreler ve koşullu içerikle yeni bir Bookstore servisini tek komutta üretir. Template'i paketlemeyi, yerel feed'den kurmayı, port generator'ı, NoDefaultExcludes eksikliğinin düşürdüğü .dockerignore'u, 1.1.0 güncellemesinin eski projeleri değiştirmediğini gösteren diff'i ve Angular'da özel bir schematic'i gerçek çıktılarla anlatıyoruz.
Sürüm notu: Bu yazıdaki template .NET 10 (SDK 10.0.404) ile paketlenip kurulmuş, üretilen projeler xUnit v3 ve Microsoft.AspNetCore.Mvc.Testing 10.0.15 ile test edilmiş, Docker image'ı olarak build edilip çalıştırılmıştır. Angular schematic'i Angular 22.2 ve @angular-devkit/schematics 22.2.7 ile denenmiştir.
Önce Tahmin Et
Bookstore'da (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) her yeni servis aynı yerden başlıyor: ekibin bookstore-service template'inden. Shipping servisi üç ay önce template'in 1.0.0 sürümüyle üretildi. Bu hafta Emre template'e EnforceCodeStyleInBuild ayarını ekleyip 1.1.0'ı yayımladı, sen de makinende dotnet new update çalıştırdın.
Shipping servisinin Directory.Build.props dosyasında şimdi ne yazıyor? Yeni ayar oraya da geldi mi, gelmediyse kim getirecek? Yazının ortasında gerçek diff çıktısıyla cevaplıyoruz.
Template Engine Nasıl Çalışır?
dotnet new arkasında .NET template engine'i çalıştırır. Bir template, içinde .template.config/template.json dosyası olan sıradan bir klasör. Engine klasördeki dosyaları çıktı klasörüne kopyalar ve kopyalarken template.json'daki kuralları uygular. Template'in kaynağı özel bir dosya formatı değil, normal bir .NET solution; yani template'i build edip test edebilir, debug edebilirsin.
Bookstore'un template klasörü:
templates/
├── Bookstore.Templates.csproj
├── README.md
└── bookstore-service/
├── .template.config/template.json
├── .dockerignore
├── Bookstore.ServiceName.slnx
├── Directory.Build.props
├── Dockerfile
├── global.json
├── src/Bookstore.ServiceName/
└── tests/Bookstore.ServiceName.Tests/
template.json'un baş kısmı:
{
"$schema": "https://json.schemastore.org/template",
"author": "Bookstore team",
"classifications": ["Bookstore", "Web", "API"],
"identity": "Bookstore.Templates.Service",
"name": "Bookstore service",
"shortName": "bookstore-service",
"description": "ASP.NET Core service with Bookstore's SDK pin, analyzer settings, test project and Dockerfile.",
"sourceName": "Bookstore.ServiceName",
"preferNameDirectory": true,
"tags": {
"language": "C#",
"type": "solution"
}
}
Zorunlu alanlar üç tane: identity (template'in benzersiz kimliği), name (listede görünen ad) ve shortName (dotnet new komutuna verilen ad). sourceName en çok iş yapan alan: kaynakta geçen Bookstore.ServiceName metni, hem dosya adlarında hem dosya içeriklerinde kullanıcının -n ile verdiği adla değiştirilir. Klasör adı, .csproj adı, namespace, Dockerfile'daki DLL (derlenmiş assembly dosyası) adı ve test içindeki beklenen string tek bir ayarla değişiyor. preferNameDirectory ad verilip çıktı klasörü verilmediğinde dosyaları o adla yeni bir klasöre yazar.
tags.type üç değer alıyor: item tek bir ya da birkaç dosya üretir (bir configuration class'ı, bir .editorconfig), project bir projeyi, solution birden çok projeli bir solution'ı. Bizimki servis projesi ve test projesiyle birlikte bir .slnx ürettiği için solution. classifications değerleri dotnet new list çıktısındaki Tags sütununa gider.
Symbol'ler: Parametre, Koşullu İçerik ve Üretilen Port
Kullanıcının seçebileceği her şey symbols altında tanımlanır. Her parameter symbol'ü komut satırında bir option olur:
"symbols": {
"Docker": {
"type": "parameter",
"datatype": "bool",
"defaultValue": "true",
"description": "Add a Dockerfile and .dockerignore."
},
"HealthChecks": {
"type": "parameter",
"datatype": "bool",
"defaultValue": "true",
"description": "Map /health and add a test for it."
},
"HttpPort": {
"type": "generated",
"generator": "port",
"parameters": {
"low": 5100,
"high": 5199
},
"replaces": "5099"
}
},
"sources": [
{
"modifiers": [
{
"condition": "(!Docker)",
"exclude": ["Dockerfile", ".dockerignore"]
}
]
}
]
Docker dosya düzeyinde çalışıyor: sources altındaki modifiers, koşul doğruysa listedeki dosyaları çıktıya almıyor. HealthChecks ise dosyanın içinde çalışıyor. C# dosyasında preprocessor syntax'ı kullanılıyor:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
#if (HealthChecks)
builder.Services.AddHealthChecks();
#endif
WebApplication app = builder.Build();
app.MapGet("/", () => "Bookstore.ServiceName");
#if (HealthChecks)
app.MapHealthChecks("/health");
#endif
app.Run();
Güzel tarafı, bu dosya template kaynağında da derleniyor. C# derleyicisi HealthChecks symbol'ünü tanımadığı için bloğu atlıyor; template engine ise üretim sırasında bloğu parametreye göre bırakıyor ya da siliyor ve #if satırlarını kaldırıyor. JSON ve XML dosyalarında aynı iş için //#if ve <!--#if --> biçimleri var.
HttpPort kullanıcıya sorulmayan bir symbol. generated tipi ve port generator'ı, 5100–5199 arasında boş bir port seçip launchSettings.json'daki 5099 metninin yerine yazıyor. ASP.NET Core'un kendi template'leri de bu yüzden her projede farklı port veriyor; iki servisi aynı anda çalıştırınca port çakışması yaşamıyorsun.
Kurmak ve Denemek
Geliştirirken template'i klasörden kurmak en hızlısı:
$ dotnet new install ./bookstore-service
The following template packages will be installed:
~/src/bookstore/templates/bookstore-service
Success: ~/src/bookstore/templates/bookstore-service installed the following templates:
Template Name Short Name Language Tags
----------------- ----------------- -------- -----------------
Bookstore service bookstore-service [C#] Bookstore/Web/API
-h ile template'in parametrelerini görürsün; Docker ve HealthChecks symbol'leri kısa adlarıyla birlikte otomatik olarak option'a dönüşmüş:
$ dotnet new bookstore-service -h
Bookstore service (C#)
Author: Bookstore team
Description: ASP.NET Core service with Bookstore's SDK pin, analyzer settings, test project and Dockerfile.
...
Template options:
-D, --Docker Add a Dockerfile and .dockerignore.
Type: bool
Default: true
-H, --HealthChecks Map /health and add a test for it.
Type: bool
Default: true
Dosya yazmadan önce --dry-run ne olacağını gösterir. İki parametreyi kapatınca Dockerfile ve .dockerignore listeden düşüyor:
$ dotnet new bookstore-service -n Bookstore.Notifications --Docker false --HealthChecks false --dry-run
File actions would have been taken:
Create: ./Bookstore.Notifications.slnx
Create: ./Directory.Build.props
Create: ./global.json
Create: ./src/Bookstore.Notifications/Bookstore.Notifications.csproj
Create: ./src/Bookstore.Notifications/Program.cs
Create: ./src/Bookstore.Notifications/Properties/launchSettings.json
Create: ./tests/Bookstore.Notifications.Tests/Bookstore.Notifications.Tests.csproj
Create: ./tests/Bookstore.Notifications.Tests/EndpointTests.cs
Gerçek üretimde Program.cs dört satıra iniyor, #if blokları ve içerikleri tamamen gidiyor:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();
app.MapGet("/", () => "Bookstore.Notifications");
app.Run();
Klasörden kurulan template'i aynı yolla kaldırırsın: dotnet new uninstall ./bookstore-service. Kurulu template'ler kullanıcı profilinde, .templateengine klasöründe tutulur.
Paketlemek ve Dağıtmak
Ekibe dağıtmak için template bir NuGet paketine konur. Paketleme projesi kod derlemiyor, yalnız klasörü pakete taşıyor:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageId>Bookstore.Templates</PackageId>
<PackageVersion>1.0.0</PackageVersion>
<Title>Bookstore templates</Title>
<Authors>Bookstore team</Authors>
<Description>dotnet new templates for Bookstore services.</Description>
<PackageTags>dotnet-new;templates;bookstore</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageType>Template</PackageType>
<TargetFramework>net10.0</TargetFramework>
<IncludeContentInPack>true</IncludeContentInPack>
<IncludeBuildOutput>false</IncludeBuildOutput>
<ContentTargetFolders>content</ContentTargetFolders>
<NoWarn>$(NoWarn);NU5128</NoWarn>
<NoDefaultExcludes>true</NoDefaultExcludes>
</PropertyGroup>
<ItemGroup>
<Content Include="bookstore-service\**\*" Exclude="**\bin\**;**\obj\**" />
<None Include="README.md" Pack="true" PackagePath="\" />
<Compile Remove="**\*" />
</ItemGroup>
</Project>
PackageType değerinin Template olması paketi template paketi yapar. IncludeBuildOutput false olduğu için pakete DLL girmez, Compile Remove template'teki .cs dosyalarının paketleme projesinde derlenmesini engeller. NoDefaultExcludes satırını sildiğimde ne olduğuna baktım:
warning NU5119: File 'bookstore-service/.dockerignore' was not added to the package. Files and folders starting with '.' or ending with '.nupkg' are excluded by default. To include this file, use -NoDefaultExcludes from the commandline [Bookstore.Templates.csproj]
NuGet nokta ile başlayan dosyaları varsayılan olarak pakete almıyor. Paket yine oluşuyor, template yine kuruluyor, ama üretilen servislerde .dockerignore yok ve Docker build'i bin/ ile obj/ klasörlerini de image'a kopyalamaya başlıyor. Bu uyarıyı CI'da hata saymak iyi bir fikir.
Paketi bir feed'e koymak için şirketin NuGet feed'i ya da Azure Artifacts gibi bir servis kullanılır. Denemek için bir klasör de feed olur. dotnet pack -c Release -o ./feed sonrası, feed'i nuget.config ile tanıtıyorum:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="bookstore-local" value="./feed" />
</packageSources>
</configuration>
$ dotnet new install Bookstore.Templates
The following template packages will be installed:
Bookstore.Templates
Success: Bookstore.Templates@1.0.0 installed the following templates:
Template Name Short Name Language Tags
----------------- ----------------- -------- -----------------
Bookstore service bookstore-service [C#] Bookstore/Web/API
İlk denemede feed'i --nuget-source ./feed diye komut satırından vermiştim ve kurulum şu hatayla durdu: Warning: Failed to load NuGet source ./feed: the source is not valid. Bu option'a göreli bir klasör yolu verince kabul etmiyor; nuget.config içindeki göreli yol ise config dosyasının bulunduğu yere göre çözülüyor ve çalışıyor.
Üretilen Projeyi Doğrulamak
Template'in asıl testi, üretilen projenin build olması ve testlerinin geçmesi:
$ dotnet new bookstore-service -n Bookstore.Shipping
The template "Bookstore service" was created successfully.
$ cd Bookstore.Shipping && dotnet build
0 Warning(s)
0 Error(s)
$ dotnet test
Test run summary: Passed!
total: 2
failed: 0
succeeded: 2
skipped: 0
duration: 648ms
Port generator launchSettings.json'a "applicationUrl": "http://localhost:5128" yazdı, Notifications servisine ise 5109 düştü. Üretilen Dockerfile da çalışıyor:
$ docker build -t prodseries-shipping:dev .
...
Successfully tagged prodseries-shipping:dev
$ docker run -d --name prodseries-shipping -p 127.0.0.1:52094:8080 prodseries-shipping:dev > /dev/null
$ curl -i http://127.0.0.1:52094/health
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Sun, 15 Nov 2026 12:09:25 GMT
Server: Kestrel
Cache-Control: no-store, no-cache
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Pragma: no-cache
Transfer-Encoding: chunked
Healthy
Testlerden biri WebApplicationFactory<Program> kullanıyor. .NET 10 öncesinde bunun için Program.cs'in sonuna public partial class Program; eklemek gerekiyordu; .NET 10 SDK'da bu satır olmadan da derleniyor, template'e bu yüzden koymadım.
Versiyonlama ve Güncelleme
Önce Tahmin Et'teki değişiklik: template'in Directory.Build.props dosyasına EnforceCodeStyleInBuild eklendi ve paket sürümü 1.1.0 oldu. Feed'e yeni paket konunca:
$ dotnet new update --check-only
An update for template packages is available:
Package Current Latest
------------------- ------- ------
Bookstore.Templates 1.0.0 1.1.0
To update the package use:
dotnet new install <package>@<version>
dotnet new install Bookstore.Templates@1.1.0
To update all the packages use:
dotnet new update
$ dotnet new update
The following template packages will be updated:
Bookstore.Templates@1.1.0
Bookstore.Templates@1.0.0 was successfully uninstalled.
Success: Bookstore.Templates@1.1.0 installed the following templates:
...
Şimdi 1.1.0 ile yeni bir Payments servisi üretip eski Shipping servisiyle karşılaştırıyorum:
$ diff Bookstore.Shipping/Directory.Build.props Bookstore.Payments/Directory.Build.props
7a8
> <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
Cevap bu: template güncellemesi yalnız bundan sonra üretilecek projeleri etkiler. Template bir kez kopyalanan bir başlangıç noktası, üretilen projeyle arasında bir bağ kalmıyor. Shipping servisine yeni ayar ancak biri elle eklerse gelir. Bu farka template drift denir ve "Sık Düşülen Tuzaklar"da nasıl azaltılacağına bakacağız.

dotnet new her örnek oluşturmada da güncelleme kontrolü yapıyor; --no-update-check ile kapatılabiliyor. Sürüm numarasını NuGet kurallarıyla ver: davranışı bozan değişiklik (bir dosyanın adı değişti, bir parametre kalktı) major sürüm demek.
Angular Tarafı: Schematics'e Kısa Bakış
Angular CLI'da ng generate arkasında schematic'ler çalışır. Bir schematic, projeyi temsil eden sanal bir dosya ağacı (Tree) üzerinde değişiklik yapan bir Rule fonksiyonu; değişiklikler önce bellekte toplanır, hata yoksa diske yazılır. Bookstore'da her API resource'u için aynı servisi yazıyorduk, bunu bir schematic'e çevirdim. Collection dosyası:
{
"$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
"schematics": {
"api-service": {
"description": "Service that calls a Bookstore API resource with HttpClient.",
"factory": "./api-service/index#apiService",
"schema": "./api-service/schema.json"
}
}
}
Factory, files/ klasöründeki template'leri okuyup isimleri doldurup hedef klasöre taşıyor:
import { normalize, strings } from '@angular-devkit/core';
import { Rule, apply, applyTemplates, mergeWith, move, url } from '@angular-devkit/schematics';
interface ApiServiceOptions {
name: string;
path: string;
}
export function apiService(options: ApiServiceOptions): Rule {
const source = apply(url('./files'), [
applyTemplates({ ...strings, name: options.name }),
move(normalize(options.path)),
]);
return mergeWith(source);
}
Template dosyasının adı __name@dasherize__-api.ts.template: __name@dasherize__ kısmı book olur, .template uzantısı düşer. İçeride <%= classify(name) %> gibi ifadeler var:
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { <%= classify(name) %> } from './<%= dasherize(name) %>';
@Injectable({ providedIn: 'root' })
export class <%= classify(name) %>Api {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/<%= dasherize(name) %>s';
getAll(): Observable<<%= classify(name) %>[]> {
return this.http.get<<%= classify(name) %>[]>(this.baseUrl);
}
}
Paketi tools/schematics altında TypeScript 6.0 ile build edip Angular projesine yerel bağımlılık olarak ekledim (npm install --save-dev ../../tools/schematics). package.json'daki "schematics": "./dist/collection.json" alanı CLI'a collection'ın yerini söylüyor:
$ npx ng generate @bookstore/schematics:api-service book --dry-run
CREATE src/app/book-api.spec.ts (622 bytes)
CREATE src/app/book-api.ts (406 bytes)
NOTE: The "--dry-run" option means no changes were made.
$ npx ng generate @bookstore/schematics:api-service book
CREATE src/app/book-api.spec.ts (622 bytes)
CREATE src/app/book-api.ts (406 bytes)
$ npx ng test --watch=false
Test Files 3 passed (3)
Tests 3 passed (3)
Ne zaman zahmetine değer? Üretilen dosyalar birden fazlaysa, birbirine bağlıysa (servis, spec, route kaydı) ya da mevcut bir dosyayı güncellemek gerekiyorsa (bir route'u app.routes.ts'e eklemek gibi) schematic iyi bir yatırım. Tek bir dosyalık kalıp için önceki yazıdaki snippet yeter; schematic'in build adımı, sürümü ve Angular güncellemeleriyle birlikte bakımı var.
Üretimde Dikkat
Template'i CI'da test et: paketledikten sonra temiz bir klasörde en az iki varyant üret (varsayılan ve --Docker false --HealthChecks false), her birinde dotnet build ve dotnet test çalıştır. Yukarıdaki Shipping ve Notifications servisleri tam olarak bu iki varyant; ikisinin testleri de geçiyor. Template kaynağı kendi başına derlenebilse de #if blokları ancak üretim sonrasında gerçek koda dönüşüyor; asıl hata orada çıkar. CI'da kurulumun kullanıcı profiline yazmaması için DOTNET_CLI_HOME değişkenini geçici bir klasöre yönlendir.
Belge, template'lerin oluşturma sırasında MSBuild task'ları ve kod çalıştırabileceğini, bu yüzden yalnız güvendiğin kaynaklardan kurulması gerektiğini vurguluyor. Şirket içi template'ler için iç feed doğru yer. Visual Studio 2026'nın "Create a new project" penceresi dotnet new install ile kurulan project ve solution template'lerini ek ayar gerekmeden listeliyor; item template'ler ise Add New Item penceresinde çıkmıyor, onlar yalnız CLI'dan kullanılıyor. .template.config klasörüne konan 128×128 bir icon.png Visual Studio'da template'in yanında görünür.
Bu bölümde resmî belgelerden yararlanılmıştır.
Sık Düşülen Tuzaklar
Birincisi drift. Template'ten üretilen her proje kendi yoluna gider. Azaltmanın yolu, değişmesi muhtemel ayarları template'ten kopyalanan dosyalarda değil merkezde tutmak: tek bir repo'daki servisler kökteki Directory.Build.props ve Directory.Packages.props dosyalarını miras alır, template bu dosyaları taşımaz. Ayrı repo'lardaki servisler için analyzer ve build ayarlarını bir NuGet paketine (MSBuild props içeren bir SDK paketi) koymak ve sürümünü Dependabot ya da Renovate ile güncellemek, template'i yeniden üretmekten daha sağlam. Template ilk günün iskeleti, kuralların kaynağı değil.
İkincisi ortama özel değer gömmek. Template'e geliştiricinin makinesindeki connection string'i, iç feed'in kişisel token'ı ya da sabit bir port yazılırsa her üretilen projeye kopyalanır. Port için port generator'ı, secret'lar için user-secrets ya da ortam değişkeni kullan; template yalnız anahtarı (ConnectionStrings__Bookstore) bilsin.
Üçüncüsü sourceName'in çok genel olması. sourceName olarak Service gibi bir kelime seçersen engine ServiceCollection içindeki Service'i de değiştirir. Kaynakta başka hiçbir yerde geçmeyecek bir ad seç; Bookstore.ServiceName bu yüzden uzun. Dördüncüsü, yukarıda gördüğümüz NoDefaultExcludes eksikliği ve göreli --nuget-source yolu.
Küçük Bir Deneme
★☆☆ bookstore-service template'ine Framework adında bir choice parametresi ekle (net10.0 ve net11.0) ve Directory.Build.props'taki TargetFramework değerini replaces ile bu parametreye bağla. --dry-run ve -h çıktısında yeni parametreyi gör.
★★☆ Template'e --Database adında bir bool parametre ekle; true olduğunda Microsoft.EntityFrameworkCore.SqlServer paket referansını .csproj'a <!--#if (Database) --> bloğuyla eklesin. İki varyantı üretip ikisinde de dotnet build çalıştır.
★★★ Schematic'e ikinci bir rule ekle: üretilen servisi kullanan bir route'u app.routes.ts dosyasına eklesin. Değişikliği --dry-run ile kontrol et ve var olan dosyayı güncelleyen bir schematic'in neden snippet'ten daha fazla bakım istediğini not al.
Template ile yeni bir servisi bir komutta üretebiliyoruz; ama o servisi çalıştırmak, test etmek, veritabanını sıfırlamak ve formatlamak hâlâ README'deki dağınık komutlarla yapılıyor. Bir sonraki yazıda bu komutları justfile, Makefile, npm scripts, yerel .NET tool'ları ve C# file-based app'lerle tek bir giriş noktasında topluyoruz.