Solution İskeleti: Aspire AppHost, ServiceDefaults ve Tek Komutla Lokal Ortam
Aspire AppHost, Bookstore'un beş service'ini, service başına bir SQL Server database'ini, Redis'i, RabbitMQ'yu ve Keycloak'u tek C# dosyasında tarif edip tek komutla çalıştırıyor; ServiceDefaults telemetri, health check, service discovery ve resilience'ı her service'e tek satırla ekliyor. Migration'ları service binary'sini --migrate ile ayrı bir resource olarak çalıştırıp WaitForCompletion ile bekliyoruz; WaitFor'un neden yetmediğini gerçek başlama saatleriyle gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Aspire 13.6 (Aspire.Hosting.Keycloak 13.6 preview), Entity Framework Core 10 ve OpenTelemetry .NET 1.21 ile derlenip denenmiştir; container'lar SQL Server 2025, Redis 8.6, RabbitMQ 4.3 ve Keycloak 26.6.
Önce Tahmin Et
Bu yazıda Catalog'un migration'larını ayrı bir process'te, catalog-migrator adlı bir resource olarak çalıştırıyoruz. Catalog service'inin bu process'i beklemesi gerekiyor. Aspire'da beklemek için iki method var: WaitFor(catalogMigrator) ve WaitForCompletion(catalogMigrator). İlkini kullanırsan Catalog ne zaman başlar: migrator başladığında mı, migrator bitip çıktığında mı? Migrator bir hata yüzünden çıkarsa Catalog'a ne olur? İki sorunun cevabını da gerçek çalıştırmada, resource'ların başlama saatleriyle göreceğiz.
AppHost ve ServiceDefaults'u Repo'ya Eklemek
01'de sitedeki serilerin ortak örnek uygulaması Bookstore'u (bir online kitapçı) beş boş service'e böldük. Şimdi bu service'leri, veritabanlarını ve broker'ı tek komutla ayağa kaldıracak lokal ortamı kuruyoruz. Aspire'ın ne olduğu, resource modeli ve dashboard'un ayrıntıları Aspire ile Dağıtık Uygulamalar serisinin konusu; burada yalnız Bookstore'da nasıl kurduğumuza bakıyoruz.
Aspire iki proje getiriyor. AppHost, bütün sistemi C# ile tarif eden ve geliştirmede onu çalıştıran console uygulaması. ServiceDefaults ise her service'in referans verdiği küçük bir library: OpenTelemetry, health check, service discovery ve resilience ayarlarını tek bir extension method'da topluyor. Var olan bir repo'ya ikisini template'lerle ekledik:
dotnet new install Aspire.ProjectTemplates::13.6.5
dotnet new aspire-apphost -o src/Bookstore.AppHost -n Bookstore.AppHost
dotnet new aspire-servicedefaults -o src/BuildingBlocks/Bookstore.ServiceDefaults -n Bookstore.ServiceDefaults
dotnet sln add src/Bookstore.AppHost src/BuildingBlocks/Bookstore.ServiceDefaults
Aspire CLI'ın aspire init komutu da aynı işi yapıyor, ama bizim denememizde AppHost'u repo kökünde bir klasöre koydu, solution'a eklemedi ve ServiceDefaults oluşturmadı. Klasör yapısını 01'de belirlediğimiz için template'leri doğrudan kullanmak daha az iş çıkardı. ServiceDefaults'u src/BuildingBlocks altına koyduk; 01'deki architecture test'in izin verdiği iki paylaşılan projeden biri bu.
Template'ler paket sürümlerini .csproj'a yazıyor. Repo'da Central Package Management açık olduğu için sürümleri Directory.Packages.props'a taşıdık; template'in getirdiği OpenTelemetry 1.17 ve Microsoft.Extensions.Http.Resilience 10.10 yerine NuGet'teki son kararlı sürümleri (1.21 ve 10.11) kullandık. AppHost'un kendisi bir MSBuild SDK'sı:
<Project Sdk="Aspire.AppHost.Sdk/13.6.5">
<PropertyGroup>
<OutputType>Exe</OutputType>
<AspireUseCliBundle>true</AspireUseCliBundle>
<UserSecretsId>bookstore-apphost</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Keycloak" />
<PackageReference Include="Aspire.Hosting.RabbitMQ" />
<PackageReference Include="Aspire.Hosting.Redis" />
<PackageReference Include="Aspire.Hosting.SqlServer" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Services\Catalog\Bookstore.Catalog.Api\Bookstore.Catalog.Api.csproj" />
<ProjectReference Include="..\Services\Inventory\Bookstore.Inventory.Grpc\Bookstore.Inventory.Grpc.csproj" />
<ProjectReference Include="..\Services\Notifications\Bookstore.Notifications.Api\Bookstore.Notifications.Api.csproj" />
<ProjectReference Include="..\Services\Ordering\Bookstore.Ordering.Api\Bookstore.Ordering.Api.csproj" />
<ProjectReference Include="..\Services\Payments\Bookstore.Payments.Api\Bookstore.Payments.Api.csproj" />
</ItemGroup>
</Project>
AppHost'un service projelerine verdiği referanslar normal bir project reference değil: Aspire SDK'sı bunlardan Projects.Bookstore_Catalog_Api gibi tipler üretiyor ve AppHost service'in kodunu çağırmıyor, yalnız onu ayrı bir process olarak başlatıyor. Bu yüzden 01'deki "bir service başka bir service'e referans veremez" kuralı AppHost için geçerli değil; test zaten yalnız src/Services altına bakıyor. UserSecretsId önemli bir ayrıntı: Aspire'ın ürettiği parolalar buraya yazılıyor, birazdan göreceğiz.
Altyapı Resource'ları: SQL Server, Redis, RabbitMQ ve Keycloak
AppHost.cs'in ilk yarısı container'ları tarif ediyor:
IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args);
// Lokalde tek SQL Server container'ı, her service için ayrı bir database
IResourceBuilder<SqlServerServerResource> sql = builder.AddSqlServer("sql")
.WithImageTag("2025-latest")
.WithContainerName("msapp-sql")
.WithDataVolume("msapp-sql-data")
.WithLifetime(ContainerLifetime.Persistent);
IResourceBuilder<SqlServerDatabaseResource> catalogDb = sql.AddDatabase("catalogdb");
IResourceBuilder<SqlServerDatabaseResource> orderingDb = sql.AddDatabase("orderingdb");
IResourceBuilder<SqlServerDatabaseResource> inventoryDb = sql.AddDatabase("inventorydb");
IResourceBuilder<SqlServerDatabaseResource> paymentsDb = sql.AddDatabase("paymentsdb");
IResourceBuilder<RedisResource> redis = builder.AddRedis("redis")
.WithContainerName("msapp-redis");
IResourceBuilder<RabbitMQServerResource> rabbitmq = builder.AddRabbitMQ("rabbitmq")
.WithContainerName("msapp-rabbitmq")
.WithManagementPlugin()
.WithDataVolume("msapp-rabbitmq-data")
.WithLifetime(ContainerLifetime.Persistent);
builder.AddKeycloak("keycloak", 8180)
.WithContainerName("msapp-keycloak")
.WithDataVolume("msapp-keycloak-data")
.WithLifetime(ContainerLifetime.Persistent);
Her satırın bir sebebi var. WithImageTag("2025-latest"): Aspire 13.6'nın SQL Server için varsayılan image'ı 2022-latest; Bookstore SQL Server 2025 kullandığı için tag'i açıkça veriyoruz. RabbitMQ (rabbitmq:4.3-management), Redis (redis:8.6) ve Keycloak (quay.io/keycloak/keycloak:26.6) için Aspire'ın seçtiği image'larla devam ediyoruz. RabbitMQ'nun 4.4 serisi Ekim 2026'da çıktı ama 4.3 Ocak 2027'ye kadar topluluk desteğinde; Aspire varsayılanı değiştirene kadar beklemek bir sorun değil.
ContainerLifetime.Persistent: varsayılanda Aspire container'ları AppHost'la birlikte başlatıp durduruyor. SQL Server'ın, RabbitMQ'nun ve Keycloak'un her açılışta sıfırdan kurulması, özellikle ilk açılışta, dakikalar sürüyor. Persistent container AppHost kapandığında çalışmaya devam ediyor ve bir sonraki aspire run'da yeniden kullanılıyor. Redis'i bilerek session lifetime'da bıraktık: içinde kalıcı bir şey tutmuyoruz ve her açılışta temiz başlaması işimize geliyor. WithDataVolume veriyi named volume'a koyuyor; container silinse bile veritabanları ve Keycloak'un ayarları kalıyor. WithContainerName ise container'lara sabit ad veriyor, docker ps --filter name=msapp- ile bu seriye ait olanları tek komutta görebiliyorsun.
Keycloak'a sabit bir host port'u (8180) verdik. Aspire, Keycloak integration dokümanında da port'u sabitlemeyi öneriyor: tarayıcıdaki oturum cookie'leri port'a bağlı, port her açılışta değişirse 10. yazıda kuracağımız login akışı her seferinde kırılır. Bir uyarı: Aspire.Hosting.Keycloak paketi yazım günü hâlâ preview (13.6.5-preview.1). Lokal geliştirme için sorunsuz çalışıyor; production'da Keycloak'u Aspire'ın değil, kendi deploy yönteminin kuracağını varsayarak ilerliyoruz.
Parolaları biz yazmadık. Aspire SQL Server, Redis, RabbitMQ ve Keycloak için birer parola parametresi üretiyor ve UserSecretsId olan AppHost'ta bunları user secrets'a kaydediyor. Persistent container'lar için atanan port'lar da aynı yerde duruyor; ikinci açılışta parola ve port değişmediği için var olan container'a yeniden bağlanılabiliyor. İlk çalıştırmadan sonra secrets dosyasındaki anahtarlar şunlar (değerleri gizledik):
Parameters:sql-password
Parameters:redis-password
Parameters:rabbitmq-password
Parameters:keycloak-password
Resources:sql:tcp:port
Resources:rabbitmq:tcp:port
Resources:rabbitmq:management:port
Resources:keycloak:management:port
AppHost:OtlpApiKey
AppHost:DashboardApiKey
Bu dosya repo'da değil, kullanıcı profilinde (Windows'ta %APPDATA%\Microsoft\UserSecrets\bookstore-apphost). Ekibin her üyesinin parolası farklı oluyor ve hiçbiri commit'lenmiyor. Parolayı değiştirmek istersen persistent volume'u da silmen gerekiyor; SQL Server ve Keycloak ilk açılışta yazdıkları admin parolasını volume'da tutuyor.
Service'leri Bağlamak: WithReference, WaitFor ve Health Check
İkinci yarı service'leri tarif ediyor:
// Migrator, --migrate ile başlatılan Catalog binary'sinin kendisi; şema güncel olunca çıkıyor
IResourceBuilder<ProjectResource> catalogMigrator = builder
.AddProject<Projects.Bookstore_Catalog_Api>("catalog-migrator", options => options.ExcludeLaunchProfile = true)
.WithArgs("--migrate")
.WithEnvironment("Seed__SampleData", "true")
.WithReference(catalogDb)
.WaitFor(catalogDb);
builder.AddProject<Projects.Bookstore_Catalog_Api>("catalog")
.WithReference(catalogDb)
.WaitForCompletion(catalogMigrator)
.WithHttpHealthCheck("/health");
builder.AddProject<Projects.Bookstore_Ordering_Api>("ordering")
.WithReference(orderingDb)
.WaitFor(orderingDb)
.WithHttpHealthCheck("/health");
builder.AddProject<Projects.Bookstore_Inventory_Grpc>("inventory")
.WithReference(inventoryDb)
.WaitFor(inventoryDb)
.WithHttpHealthCheck("/health");
builder.AddProject<Projects.Bookstore_Payments_Api>("payments")
.WithReference(paymentsDb)
.WaitFor(paymentsDb)
.WithHttpHealthCheck("/health");
builder.AddProject<Projects.Bookstore_Notifications_Api>("notifications")
.WithReference(redis)
.WithReference(rabbitmq)
.WaitFor(redis)
.WaitFor(rabbitmq)
.WithHttpHealthCheck("/health");
builder.Build().Run();
WithReference(catalogDb) Catalog'a catalogdb'nin connection string'ini environment variable olarak veriyor. Çalışan Catalog process'inde gördüğümüz değer (parolayı gizledik):
ConnectionStrings__catalogdb = Server=127.0.0.1,28595;User ID=sa;Password=<hidden>;TrustServerCertificate=true;Initial Catalog=catalogdb
OTEL_SERVICE_NAME = catalog
01'de "connection string'i yalnız sahibi alır" dedik; AppHost bu kuralın uygulandığı yer. Ordering catalogdb'ye referans almadığı için onun connection string'ini hiç görmüyor. Code review'da AppHost.cs'teki her WithReference satırı, iki service arasında bir bağımlılık kararı olarak okunmalı. WaitFor ise başlama sırasını belirliyor: Ordering, orderingdb sağlıklı olana (container ayakta, database oluşturulmuş) kadar başlamıyor. WithHttpHealthCheck("/health") service'in kendi health endpoint'ini dashboard'a bağlıyor; "Running" ile "Healthy" arasındaki farkı bu satır yaratıyor.
Service'ler henüz neredeyse boş, ama hepsine şu üç satır eklendi:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
WebApplication app = builder.Build();
app.MapDefaultEndpoints();
app.MapGet("/", () => "Bookstore.Ordering.Api");
app.Run();
ServiceDefaults: Her Service'te Tekrar Etmediğimiz Şeyler
AddServiceDefaults() template'in ürettiği Extensions.cs'ten geliyor. Bu dosyaya yalnız iki küçük değişiklik yaptık (var yerine açık tip, kullanmadığımız Azure Monitor satırlarını silmek). Önemli kısmı şu:
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
// Her HttpClient'a standard resilience pipeline'ı
http.AddStandardResilienceHandler();
// Her HttpClient'ta service discovery: http://catalog gibi adresler çözülür
http.AddServiceDiscovery();
});
return builder;
}
Dört iş var. OpenTelemetry: log, metric ve trace'ler OTLP (OpenTelemetry Protocol; telemetri verisini taşıyan standart protokol) ile OTEL_EXPORTER_OTLP_ENDPOINT'e gidiyor; Aspire bu değişkeni dashboard'un adresiyle dolduruyor. Health check: /health bütün check'leri, /alive yalnız live etiketli olanları çalıştırıyor; template ikisini de yalnız Development ortamında map'liyor, production'da bunu bilinçli olarak açmak 14. yazının konusu. Service discovery: 05'te Ordering, Inventory'yi https+http://inventory adresiyle bulacak. Resilience: her HttpClient'a retry, timeout ve circuit breaker içeren standard pipeline ekleniyor; ayarları External Service Integration ve Resilience serisinin Standard Pipeline yazısında anlattık.
ServiceDefaults'a neyin girmediği de bir karar. Veritabanı, broker ve authentication ayarları buraya girmiyor; bunları her service kendisi ve kendi ihtiyacına göre ekliyor. ServiceDefaults "her service'te aynı olması gereken altyapı" ile sınırlı kaldığı sürece 01'deki paylaşılan kod politikasını delmiyor. Bir business kuralı ya da bir DTO (Data Transfer Object; service'ler arasında veri taşıyan sınıf) buraya girdiği gün, adı ne olursa olsun bir Bookstore.Common projesine dönüşüyor.
Migration'ları Ayrı Bir Process'te Çalıştırmak
Catalog'un veri modelini monolith'ten olduğu gibi taşıdık: Book, Author, Category, artık catalogdb'de ve kendi CatalogDbContext'inde. Modelin service'e göre şekillenmesi 03'ün konusu; burada soru migration'ın ne zaman ve nerede çalışacağı. Uygulama açılışında Database.Migrate() çağırmak tek instance'lı bir monolith'te kabul edilebilirdi. Birden fazla instance aynı anda açıldığında, migration uzun sürdüğünde ya da başarısız olduğunda bu yaklaşım sorun çıkarıyor: uygulama açılmadan health check'ler kırmızıya dönüyor, Kubernetes pod'u yeniden başlatıyor ve migration yarıda kesilebiliyor.
Aspire'da üç yol var. Dokümanın önerdiği ilk yol, her veritabanı için ayrı bir Worker Service projesi: migration'ı uygular ve StopApplication ile çıkar. İkincisi, Aspire.Hosting.EntityFrameworkCore paketindeki AddEFMigrations: migration'ları AppHost'tan yönetiyor, ama paket yazım günü preview. Üçüncüsü bizim seçtiğimiz: service'in kendi binary'sini --migrate argümanıyla, ayrı bir resource olarak başlatmak. Migration kodu ve DbContext service'in içinde kalıyor, ekstra proje açmıyoruz ve 14. yazıda Kubernetes'te aynı image'ı farklı bir argümanla bir Job olarak çalıştırabiliyoruz. Bedeli, service'in Program.cs'inde küçük bir dal:
using Bookstore.Catalog.Data;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddSqlServerDbContext<CatalogDbContext>("catalogdb");
WebApplication app = builder.Build();
if (DatabaseMigration.IsMigrationRun(args))
{
await app.MigrateDatabaseAsync();
return;
}
app.MapDefaultEndpoints();
app.MapGet("/", () => "Bookstore.Catalog.Api");
app.Run();
public static class DatabaseMigration
{
// Aynı binary kısa ömürlü bir migrator olarak da çalışıyor: dotnet run -- --migrate
public static bool IsMigrationRun(string[] args) => args.Contains("--migrate");
public static async Task MigrateDatabaseAsync(this WebApplication app)
{
CancellationToken ct = app.Lifetime.ApplicationStopping;
Stopwatch elapsed = Stopwatch.StartNew();
await using AsyncServiceScope scope = app.Services.CreateAsyncScope();
CatalogDbContext db = scope.ServiceProvider.GetRequiredService<CatalogDbContext>();
string[] pending = [.. await db.Database.GetPendingMigrationsAsync(ct)];
await db.Database.MigrateAsync(ct);
app.Logger.LogInformation("Applied {Count} migration(s): {Migrations}", pending.Length, string.Join(", ", pending));
if (app.Configuration.GetValue<bool>("Seed:SampleData"))
{
await CatalogSeed.SeedAsync(db, ct);
app.Logger.LogInformation("Sample data ready: {Books} books", await db.Books.CountAsync(ct));
}
app.Logger.LogInformation("catalogdb is up to date in {Elapsed} ms", elapsed.ElapsedMilliseconds);
}
}
app.Build() çağrısı Kestrel'i başlatmıyor; app.Run()'a hiç gelmediğimiz için migrator port dinlemiyor. AppHost tarafında ExcludeLaunchProfile = true bu yüzden var: launch profile'daki 5101 port'u gerçek Catalog'a ait, migrator onu almamalı. AddSqlServerDbContext Aspire'ın Entity Framework Core (EF Core) client integration'ı; DbContext'i kaydediyor, adı context tipinden gelen bir health check, tracing, metric ve varsayılanı 6 olan retry ekliyor. Retry, SQL Server container'ı henüz bağlantı kabul etmezken migrator'ın düşmesini engelliyor.
Örnek veri (01'deki 20 klasik kitap) migration'ın içinde HasData ile değil, ayrı bir CatalogSeed.SeedAsync ile geliyor ve yalnız Seed:SampleData açıkken çalışıyor. AppHost bunu WithEnvironment("Seed__SampleData", "true") ile açıyor; production ortamında bu değişken yok, dolayısıyla demo kitaplar production veritabanına girmiyor. Migration'ı her zamanki gibi ürettik; dotnet ef repo'daki tool manifest'inden (dotnet tool restore) geliyor:
dotnet ef migrations add InitialCreate --project src/Services/Catalog/Bookstore.Catalog.Api --output-dir Data/Migrations
İlk çalıştırmada migrator'ın log'undan seçtiğimiz satırlar:
[catalog-migrator] info: Microsoft.EntityFrameworkCore.Migrations[20411]
[catalog-migrator] Acquiring an exclusive lock for migration application. See https://aka.ms/efcore-docs-migrations-lock for more information if this
[catalog-migrator] EXEC @result = sp_getapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session', @LockMode = 'Exclusive';
[catalog-migrator] info: Microsoft.EntityFrameworkCore.Migrations[20402]
[catalog-migrator] Applying migration '20261114122715_InitialCreate'.
[catalog-migrator] EXEC @result = sp_releaseapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session';
[catalog-migrator] Applied 1 migration(s): 20261114122715_InitialCreate
[catalog-migrator] Sample data ready: 20 books
[catalog-migrator] catalogdb is up to date in 915 ms
sp_getapplock satırı EF Core 9'la gelen migration lock'u. İki migrator aynı anda çalışırsa ikincisi birincinin bitmesini bekliyor; Entity Framework Core serisinin EF Core 9 Yenilikleri yazısında anlattığımız özellik. İkinci açılışta aynı migrator şunu yazıyor:
[catalog-migrator] No migrations were applied. The database is already up to date.
[catalog-migrator] Applied 0 migration(s):
[catalog-migrator] Sample data ready: 20 books
[catalog-migrator] catalogdb is up to date in 844 ms
WaitFor mı, WaitForCompletion mı? Gerçek Başlama Saatleri
Önce Tahmin Et'teki soruyu denedik. Catalog'u bir kez WaitFor(catalogMigrator) ile, bir kez WaitForCompletion(catalogMigrator) ile başlatıp resource'ların başlama ve bitiş saatlerini aspire describe --format Json çıktısından okuduk:
WaitFor(catalogMigrator)
catalog-migrator start 12:51:39.995 stop 12:51:43.881
catalog start 12:51:40.058
WaitForCompletion(catalogMigrator)
catalog-migrator start 12:52:28.048 stop 12:52:29.412 exit 0
catalog start 12:52:29.921
WaitFor ile Catalog, migrator başladıktan 63 ms sonra başladı; migration 3,8 saniye daha sürdü. Migrator'ın health check'i olmadığı için "Running" durumuna geçmesi WaitFor'a yetiyor. Yeni bir kolon ekleyen bir migration'da Catalog'un ilk istekleri eski şemaya gider ve Invalid column name hatası alırdı. WaitForCompletion ile Catalog, migrator 0 kodla çıktıktan yarım saniye sonra başladı. Aspire dokümanı da migration için WaitForCompletion'ı gösteriyor.
İkinci soru için migrator'ı bilerek bozduk: Seed__SampleData değerini yes yaptık, GetValue<bool> bunu çeviremiyor. Sonuç:
[catalog-migrator] Unhandled exception. System.InvalidOperationException: Failed to convert configuration value 'yes' at 'Seed:SampleData' to type
[catalog-migrator] ---> System.FormatException: yes is not a valid value for Boolean.
migrator Finished exit 134 | catalog FailedToStart
Migrator sıfırdan farklı bir kodla çıkınca Catalog hiç başlatılmıyor ve dashboard'da FailedToStart olarak görünüyor. Yarım kalmış bir şemaya karşı çalışan bir service yerine hiç çalışmayan bir service: istediğimiz davranış bu.
aspire run: Dashboard'da Her Şey Yeşil
Bütün sistem tek komutla açılıyor. Komutları http launch profile'ıyla çalıştırdık; bu profil dashboard'u HTTPS olmadan açtığı için launch profile'da ASPIRE_ALLOW_UNSECURED_TRANSPORT=true gerekiyor, yoksa AppHost açılışta "The 'applicationUrl' setting must be an https address" hatasıyla duruyor. Varsayılan https profilinde Aspire CLI açılışta geliştirme sertifikasını oluşturup güvenilir hâle getirmeye çalışıyor; bu adım başarısız olursa aspire certs trust komutunu öneriyor.
aspire run -lp http
AppHost: src/Bookstore.AppHost/Bookstore.AppHost.csproj
Dashboard: http://localhost:15001/login?t=<token>
Press CTRL+C to stop the AppHost and exit.
Dashboard'u tarayıcıda açmak yerine Aspire CLI'dan da sorgulayabilirsin. aspire describe, çalışan AppHost'u bulup resource'ları listeliyor (dashboard bağlantı sütununu kısalttık):
aspire describe
Name Type State Health URLs
catalog Project Running Healthy http://localhost:5101
catalog-migrator Project Finished - -
catalogdb SqlServerDatabaseResource Running Healthy -
inventory Project Running Healthy http://localhost:5103
inventorydb SqlServerDatabaseResource Running Healthy -
keycloak Container Running Healthy http://localhost:8180
notifications Project Running Healthy http://localhost:5105
ordering Project Running Healthy http://localhost:5102
orderingdb SqlServerDatabaseResource Running Healthy -
payments Project Running Healthy http://localhost:5104
paymentsdb SqlServerDatabaseResource Running Healthy -
rabbitmq Container Running Healthy http://localhost:28597, tcp://localhost:28596
redis Container Running Healthy redis://localhost:61117
sql Container Running Healthy tcp://localhost:28595
On dört resource: beş service, bir migrator, dört database ve dört container. Migrator'ın durumu "Finished" ve Health sütunu boş; kısa ömürlü bir iş için doğru görüntü bu. Catalog'un health endpoint'i doğrudan da cevap veriyor; CatalogDbContext'in health check'i de bu cevabın içinde:
curl -i http://localhost:5101/health
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Sat, 14 Nov 2026 12:37:16 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
Süreleri de ölçtük. Volume'lar boşken ilk açılış, SQL Server'ın ve Keycloak'un ilk kurulumu yüzünden birkaç dakika sürdü. Persistent container'lar çalışırken ikinci aspire run, build dahil 15 saniyede bütün resource'ları "Healthy" gösterdi. AppHost'u durdurduğumuzda geriye kalanlar:
docker ps --filter name=msapp- --format "table {{.Names}}\t{{.Status}}"
NAMES STATUS
msapp-keycloak Up 7 minutes
msapp-rabbitmq Up 7 minutes
msapp-sql Up 7 minutes
Redis gitti, çünkü session lifetime'daydı; diğer üçü bir sonraki açılışı bekliyor. Bilgisayarı kapatana kadar bellek kullanmaya devam ettikleri için gün sonunda docker stop msapp-sql msapp-rabbitmq msapp-keycloak ile durdurmak iyi bir alışkanlık.
Aspire Kullanmayan Ekipler İçin: Docker Compose
Ekibinde Aspire yoksa aynı ortamı Docker Compose ile kurabilirsin; Compose'un ayrıntıları Docker Compose serisinde. İkisi arasında köprü de var: Aspire.Hosting.Docker paketini ekleyip AppHost'a builder.AddDockerComposeEnvironment("compose") yazınca aspire publish bir docker-compose.yaml ve bir .env dosyası üretiyor. Bir kopyada denedik; üretilen dosyada WaitForCompletion, Compose'un depends_on altında condition: "service_completed_successfully" satırına, migrator'ın argümanı command: ["--migrate"]'e, parolalar da .env'deki SQL_PASSWORD gibi değişkenlere dönüşmüş. WaitFor(catalogDb) ise yalnız condition: "service_started" olmuş: Compose tarafında SQL Server'ın gerçekten hazır olmasını EF Core'un retry'ı karşılıyor. Üretilen dosyayı çalıştırmadık, service image'larının önce build edilmesi gerekiyor; image üretimi 14. yazının konusu.
Üretimde Dikkat
AppHost bir geliştirme aracı. Production'da container'ları, parolaları ve bağlantıları Kubernetes manifest'leri, secret store ve deploy pipeline'ı kuruyor; AppHost'ta yazdığın model bunlara kaynak olabilir ama onların yerini tutmuyor. Özellikle Keycloak'un lokal kurulumu (preview integration, admin parolası volume'da) production için bir örnek değil.
Migration'ı deploy'un ayrı bir adımı yap. Lokalde WaitForCompletion ile kurduğumuz sıra, production'da "önce migration Job'u, sonra yeni sürüm" demek. Bunu yapabilmek için migration'lar geriye uyumlu olmalı: yeni kolon eklenir, eski kod çalışmaya devam eder; kolon silme bir sonraki sürüme kalır. Deployment Stratejileri serisinin Migration'ların Deploy'daki Yeri yazısı bu sıranın ayrıntısı.
Lokal ortamın kaynak tüketimini izle. Ölçtüğümüzde dört container toplam 1,6 GB bellek kullanıyordu (SQL Server tek başına 910 MB, Keycloak 540 MB); service'ler, AppHost ve dashboard dahil .NET process'leri de 2 GB civarındaydı. Docker Desktop'a ayırdığın bellek 8 GB'ın altındaysa Keycloak'u ihtiyaç duyduğun güne kadar AppHost'tan çıkarmayı ya da WithExplicitStart() ile elle başlatılan bir resource yapmayı düşün.
Sık Düşülen Tuzaklar
Migrator için WaitFor kullanmak. Yukarıda gördüğümüz gibi migrator çalışırken service de başlıyor. Kısa ömürlü işler için doğru method WaitForCompletion.
Migrator'ı launch profile'ıyla başlatmak. ExcludeLaunchProfile olmadan iki resource aynı launch profile'ı, dolayısıyla aynı port'u kullanmaya çalışır. Migrator'ın port'a ihtiyacı yok.
Persistent container'ın parolasını değiştirip volume'u unutmak. Aspire yeni parolayla bağlanmaya çalışır, SQL Server ise volume'daki eski parolayı bekler; sonuç bir login hatası. Parola değişikliği, volume'u silmek demek.
Her şeyi her service'e referans vermek. WithReference bir bağımlılık bildirimi. Notifications'a catalogdb referansı vermek, yarın birinin oradan "küçük bir sorgu" atmasının yolunu açar.
ServiceDefaults'u şişirmek. Logging enricher'ı, ortak exception handler'ı, sonra ortak DTO'lar... ServiceDefaults her service'te aynı olması gereken altyapı olarak kalmalı.
Küçük Bir Deneme
★☆☆ AppHost'u çalıştır, sonra docker stop msapp-rabbitmq ile RabbitMQ'yu durdur. aspire describe'da Notifications'ın ve RabbitMQ'nun durumu ne oluyor? Container'ı tekrar başlattığında ne değişiyor?
★★☆ Redis'i de ContainerLifetime.Persistent yap ve AppHost'u iki kez başlat. User secrets dosyasına hangi yeni anahtar eklendi? Redis'in persistent olmasının bu seride ne zaman işe yarayacağını (11. yazıdaki Notifications'ı düşün) bir cümleyle yaz.
★★★ Ordering için de aynı migrator yapısını kur: OrderingDbContext'e tek bir Order tablosu, --migrate dalı, ordering-migrator resource'u ve WaitForCompletion. İki migrator aynı anda çalışırken __EFMigrationsLock kilidi birbirini etkiliyor mu? Neden?
Lokal ortam hazır: tek komut, on dört resource, hepsi yeşil. Bir sonraki yazıda Catalog'u gerçek bir service'e dönüştürüyoruz: kendi veri modeli, sayfalama ve arama endpoint'leri, output cache, API versioning, ProblemDetails ve OpenAPI ile yayınlanan bir contract, bir de 06'da Ordering'in dinleyeceği BookPriceChanged ve BookRenamed event'leri.