BaseForge — Mimari Kararlar (ARCH)
Bu doküman, BaseForge'un mimari kararlarını ve gerekçelerini ayrıntılı olarak tutar. Yeni bir özellik eklenmeden önce bu dosya güncellenir.
1. Genel Yaklaşım: Opinionated Library
BaseForge bir framework değil, opinionated library'dir. Hedef; library'nin esnekliğini korurken framework kolaylığını sunmaktır. Pratikte bu, tek satırlık DI entegrasyonu anlamına gelir:
builder.Services.AddBaseForge(options =>
{
options.UsePostgreSQL(connectionString);
options.EnableCQRS();
options.EnableAuditLog();
});Gerekçe: Her mikroservis aynı altyapı kararlarını (CQRS, repository, audit, exception handling) tekrar kurmak zorunda kalmamalı; ancak istediğinde davranışı override edebilmeli.
2. Katmanlama (Clean Architecture)
Üç paket, bağımlılık yönü dışarıdan içeriye olacak şekilde ayrılır:
BaseForge.API ──► BaseForge.Infrastructure ──► BaseForge.Core| Katman | Bağımlılık | İçerik |
|---|---|---|
Core | Yok (yalnızca MediatR sözleşmeleri) | Entity base'leri, interface'ler, CQRS sözleşmeleri, exception'lar |
Infrastructure | Core | GenericRepository, ADO.NET query builder, DbContext base, DI extension'ları |
API | Core + Infrastructure | BaseController, middleware, AddBaseForge() |
Kurallar:
Corehiçbir somut altyapıya (DB, HTTP, MediatR implementasyonu) bağlı olamaz. Yalnızca MediatR'ın marker interface'lerini (IRequestvb.) referans alır — tam MediatR paketi Infrastructure/API'de register edilir.InfrastructureaslaAPI'ye bağlı olamaz.APIher iki katmana da bağlı olabilir.
Gerekçe: Test edilebilirlik ve paket bağımsızlığı. Core bağımlılıksız olduğu için sadece sözleşmeleri tüketmek isteyen servisler yalnızca onu çekebilir.
3. CQRS — MediatR Üzerine
- CQRS sıfırdan yazılmaz; MediatR üzerine inşa edilir.
CoreiçindeICommand,IQuery,IHandlerbase sözleşmeleri tanımlanır; bunlar MediatR'ınIRequest/IRequestHandlertiplerini sarmalar.- Her servis bu sözleşmeleri extend eder.
- Karar: MediatR dışında başka bir CQRS/mediator kütüphanesi eklenmez.
4. Veri Erişimi — EF Core 10 (ORM) + Dapper (ham SQL)
Karar değişikliği (2026-06-24): PDF spesifikasyonundaki "ORM kullanılmaz, ADO.NET tercih edilir" maddesi proje sahibi tarafından revize edildi. Gerekçe: EF Core'un LINQ + change tracking üretkenliği ile ham SQL esnekliği aynı anda elde edilebiliyor; saf ADO.NET'in boilerplate maliyeti üretkenliği düşürüyor.
Hibrit yaklaşım benimsenir:
- EF Core 10 birincil ORM'dir. Sorumlulukları: yazma işlemleri (insert/update/delete), change tracking (identity map / first-level cache), migration'lar ve CRUD'un büyük kısmı LINQ ile.
- Dapper (micro-ORM) ağır okuma ve karmaşık join sorgularında ham SQL için kullanılır; sonuçları DTO'lara hızlıca map eder. Dapper bir sorgu üreticisi/ORM değildir — SQL elle yazılır, yalnızca mapping sağlar.
- Dapper, EF Core
DbContext'ininDbConnection'ı üzerinden çalıştırılır (Database.GetDbConnection()), böylece aynı bağlantı ve transaction paylaşılır. GenericRepository,IRepository<TEntity, TKey>sözleşmesini EF Core ile implemente eder. Karmaşık okuma senaryoları için Dapper tabanlı bir sorgu yardımcısı (ISqlQuerybenzeri) sunulur.
Rol dağılımı:
| İhtiyaç | Araç |
|---|---|
| CRUD, ilişki yükleme, LINQ | EF Core |
| Change tracking, migration | EF Core |
| Karmaşık join / projeksiyon / rapor sorgusu | Dapper (ham SQL) veya EF FromSql |
| Toplu set-based update/delete | EF ExecuteUpdate / ExecuteDelete |
| Tam kontrol / saf bağlantı | DbContext.Database.GetDbConnection() |
PostgreSQL sağlayıcısı: Npgsql.EntityFrameworkCore.PostgreSQL.
Audit & Soft Delete
BaseEntityüzerindeCreatedAt,UpdatedAt,CreatedBy(audit) veIsDeleted/DeletedAt(soft delete) alanları tanımlıdır.- Audit alanları EF Core
SaveChangesoverride'ında otomatik doldurulur. - Soft delete EF Core global query filter ile uygulanır; silinmiş kayıtlar varsayılan sorgularda görünmez.
- Not: Dapper EF'in query filter'ını bilmez; Dapper ile yazılan ham SQL'de soft delete koşulu (
WHERE is_deleted = false) elle eklenmelidir.
5. Mikroservis İletişimi
- Database per Service: Her mikroservis kendi PostgreSQL veritabanına sahiptir; servisler birbirinin DB'sine doğrudan erişmez.
- Senkron: gRPC.
- Asenkron: RabbitMQ (fire-and-forget, event-driven) — bkz. §5.2.
5.1. gRPC — Otomatik Proto Üretimi
Her via: grpc dış referans (ExternalRefSpec), baseforge new-service sırasında gerçek gRPC client+server kodu üretir (önceden yalnızca boş bir Id-only interface iskeleti üretiliyordu).
- Server-side (otomatik, opt-out yok): Üretilen her servis, kendi TÜM entity'lerini bir gRPC servisi olarak expose eder (
Protos/{entity}.proto+Grpc/{Entity}GrpcService.cs). Server implementasyonu mevcut CQRSGet{Entity}ByIdQuery'yi MediatR üzerinden çağırır — veri erişimi tekrar yazılmaz. Sıralama bağımlılığından kaçınmak için bu davranış koşulsuzdur (sağlayıcı servis, tüketicisi üretilmeden önce de tüm entity'lerini expose eder). - Client-side çözümleme:
ExternalRefSpec.Target("servis/Entity") dışında CodeGen hedefin alan şeklini bilmez. Çözüm: hedefin servis segmentiyle, spec dosyasının bulunduğu klasörde kardeş{servis}.yamlaranır (SpecLoaderile). Bulunursa hedef entity'nin gerçekProps'u okunup zengin (gerçek alanlı) bir proto+client üretilir; bulunamazsaConsole.Error'a uyarı yazılıp minimal (yalnızca Id) bir fallback stub'a sessizce düşülür — asla hata fırlatılmaz. identity/Userözel durumu: Identity kendiServiceSpec'ini kullanmadığından (ayrıAuthSpec) kardeş-spec okuma çalışmaz.services/BaseForge.Identity/Protos/user.protogerçek, statik bir dosyadır; hemIdentityGenerator'ın kopyalama döngüsü hemCodeGenerator'ıntarget: identity/Userözel durumu aynı embedded kaynağı okur (tek fiziksel kaynak, drift riski yok). Sabit alanlar (ApplicationUser): Id, UserName, Email, FullName.- Kestrel — iki port zorunlu: ASP.NET Core Kestrel, TLS olmadan (h2c) aynı portta HTTP/1.1 ve HTTP/2'yi otomatik ayırt edemez (canlı testte doğrulandı:
EndpointDefaults: Http1AndHttp2tek başına REST'i çalıştırır ama gRPC'yi sessizce HTTP/1.1'e düşürür). Bu yüzden her üretilen servis iki ayrı endpoint tanımlar:Http(8080, REST/Scalar) veGrpc(8081, h2c). Client tarafındaAppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true)ile TLS'siz HTTP/2 istekleri etkinleştirilir. - Kısıtlar: İki farklı kaynak servisten aynı adlı entity'ye dış referans çakışır (ikincisi atlanır). gRPC çağrılarında JWT propagasyonu yok (güven, docker ağı sınırına dayanıyor). decimal/datetime/date/guid proto'da
stringtaşınır (native karşılık yok). - Cross-service host adresi: Her üretilen servis kendi izole
docker-compose.yml'unda (kendi Docker network'ünde) çalışır; sağlayıcı servisin bare adı (örn."identity") bu yüzden network DNS ile çözülemez. Rich gRPC client'larınProviderHost'u (ve RabbitMq broker adresi, bkz. §5.2) bu yüzden sabit olarakhost.docker.internalüretilir — Docker Desktop'ın container'dan host'a yönlendiren adresi. - Referans senaryo:
samples/products.yaml+samples/warehouse.yaml+samples/orders.yaml→services/BaseForge.Products,services/BaseForge.Warehouse,services/BaseForge.Orders(committed, gerçek/derlenebilir). Orders, Products'a (kardeş spec) ve Identity'ye (identity/User) gRPC ile bağlanır; Warehouse da Products'a bağlanır.
5.2. RabbitMQ — Otomatik Event Pub/Sub
ExternalRefSpec.Via = "event" implemente edilmedi ve gelecekte planlanan farklı bir özellik (read-model senkronizasyonu — üretici servis CRUD olaylarını yayınlar, tüketici yerel bir gölge tabloyla günceller) için rezerve edilmiştir; bugün no-op'tur. Asenkron pub/sub, ayrı ve daha basit iki YAML alanıyla çalışır: entity bazlı publishes ve servis bazlı subscribes.
Kütüphane tarafı (BaseForge.Core/Infrastructure/API):
IIntegrationEvent : INotification(Core) — MediatR'ın bildirim sözleşmesini genişletir. YayıncıIEventBus.PublishAsync<TEvent>()çağırır; tüketici tarafındaRabbitMqConsumerHostedService(Infrastructure,BackgroundService) kuyruktan gelen mesajı ilgili CLR tipine deserialize edip yerel olarakIPublisher.Publish()(MediatR) ile dağıtır. Geliştirici sıradan birINotificationHandler<TEvent>yazar, RabbitMQ'yu hiç görmez — yeni bir CQRS kütüphanesi eklenmemiş olur (§3 kararıyla tutarlı).builder.Services.AddBaseForge(options => options.EnableRabbitMq(mq => { ... }))—EnableJwtile birebir aynı fluent desen. Abonelik varsa (mq.Subscribe<TEvent>(eventType, queueName)) tüketici hosted service'i otomatik eklenir; publish varsa (her zaman) outbox relay'i (aşağıda) otomatik eklenir.- Broker: tek bir topic exchange (
baseforge.events). Routing key, olayınEventType'ından türetilir (servis/EntityKind→servis.EntityKind). - Paket:
RabbitMQ.Client(tam async API) +Microsoft.Extensions.Hosting.Abstractions—BaseForge.Infrastructure'a eklendi (ASP.NET Core'a bağımlılık getirmez, host-framework-agnostic kuralı korunur).
Transactional Outbox (2026-07-16 — dual-write riskini çözer):
IEventBus.PublishAsync<TEvent>() artık RabbitMQ'ya doğrudan yazmıyor. Önceki tasarımda handler önce _unitOfWork.SaveChangesAsync() ile DB'ye commit ediyor, sonra _eventBus.PublishAsync() ile broker'a yazıyordu — bu ikisi arasında atomiklik yoktu: DB commit başarılı olur ama publish (network/broker hatası) başarısız olursa, değişiklik kalıcı olur ama event hiç yayınlanmazdı.
OutboxEventBus : IEventBus(Infrastructure, Scoped) —PublishAsync, olayı birOutboxMessage(Core, plain POCO —IAuditEntity/ISoftDelete/ITenantEntityimplemente etmez, global query filter'lara girmez) satırı olarak çağıranın o ankiBaseForgeDbContext'inin change tracker'ına ekler. DB'ye yazmaz, I/O yapmaz.- CodeGen şablonlarında (
Templates.cs)_eventBus.PublishAsync(...)çağrısı artık_unitOfWork.SaveChangesAsync(...)'den önce yapılır — böylece outbox satırı, tetikleyen business entity değişikliğiyle tek birSaveChangesAsyncçağrısında, aynı transaction'da atomik yazılır. OutboxPublisherHostedService(Infrastructure,BackgroundService) —BaseForgeDbContext.OutboxMessages'ı periyodik tarar (RabbitMqOptions.OutboxPollingInterval, varsayılan 2s), işlenmemiş (ProcessedAt IS NULL) satırlarıIRabbitMqPublisher.PublishRawAsync()(eskiRabbitMqEventBus'ın yeniden adlandırılmış hâli — artık jenerik değil, zaten hazır zarf JSON'unu olduğu gibi yazar) ile gerçek broker'a gönderir, başarılıysaProcessedAtişaretler.- Çoklu-instance güvenliği: satır seçimi
SELECT ... FOR UPDATE SKIP LOCKEDile yapılır — iki instance aynı satırı asla aynı anda işlemez, ekstra lease/claim kolonu gerekmez, process çökerse Postgres kilidi otomatik serbest kalır. IEventBuskaydı Singleton'dan Scoped'a değişti (çağıranla aynı scope'unBaseForgeDbContext'ine yazması gerektiği için) —OutboxPublisherHostedServiceher zaman kayıtlıdır (abonelik sayısından bağımsız, publish varsa aktif).- Max-retry + dead marking (2026-07-16):
RabbitMqOptions.OutboxMaxRetries(varsayılan 10) aşılınca satırOutboxMessage.IsDead = trueolarak işaretlenir, relay'inWHEREkoşulundan çıkar (AND "IsDead" = false) — sonsuza kadar denenmez, ama silinmez (manuel inceleme için tabloda kalır). - Cleanup/retention job (2026-07-16): Her tarama tick'inin sonunda (yeni mesaj olsun olmasın), işlenmiş (
ProcessedAtdolu) veRabbitMqOptions.OutboxRetention(varsayılan 7 gün) süresinden eski satırlarExecuteDeleteAsyncile toplu silinir.IsDeadsatırlar bu temizlikten muaftır.
CodeGen tarafı:
EntitySpec.Publishes: List<string>(created/updated/deleted) — ilgili Create/Update/Delete komutu (artıkSaveChangesAsync'den önce, outbox'a){Entity}{Kind}Event'i yayınlar (Features/{Entity}s/{Entity}Events.cs).ServiceSpec.Subscribes: List<SubscribeSpec>(event: "servis/EntityKind",handler: ClassName) — hedef entity kardeş spec'te (veya kendi spec'inde, kendi kendine abonelik için özel durum gerekmez) bulunuppublisheslistesinde ilgili Kind varsa gerçek alanlarla ("rich"), bulunamazsa yalnızcaIdile ("minimal") bir gölge event/data class'ı +INotificationHandler<T>stub'ı üretilir (Integration/{Handler}.cs). Kardeş-spec bulma mantığı, gRPC dış referans çözümlemesiyle (§5.1) aynıLoadSiblingSpechelper'ını paylaşır.- CLI/YAML-only v1:
publishes/subscribes'ın kendisi (hangi entity hangi olayı yayınlar/dinler) hâlâ Designer web UI'da form karşılığı yok (auth:bloğunun bugünkü durumuyla aynı — CLI soru sormuyor, YAML elle yazılır).via: event'in aksine bu iki alan/metaendpoint'inde veyaEntityEditor.tsx'te temsil edilmez; fast-follow olarak planlanabilir. - RabbitMQ ince ayarları için Designer formu eklendi (2026-07-16):
ServiceSpec.RabbitMqTuning(OutboxMaxRetries/OutboxRetentionDays, opsiyonel) —DockerPortsSpecile birebir aynı desen (nullable nested object, doğrudan controlled input). Designer'da servis formunun altında (DockerPorts'un hemen altında),publishes/subscribesdurumundan bağımsız olarak her zaman görünür —DockerPortsda aynı şekilde koşulsuz gösteriliyor, ve Designer'ınServiceSpec/EntitySpecTS modelindepublishes/subscribeshiç temsil edilmediği için istemci tarafında "bu servis RabbitMQ kullanıyor mu" güvenilir şekilde hesaplanamıyordu (plan bunu koşullu göstermeyi öngörmüştü, uygulama sırasında bu kısıt fark edilip düzeltildi). Doldurulursa üretilenProgram.cs'tekioptions.EnableRabbitMq(mq => ...)bloğunamq.OutboxMaxRetries/mq.OutboxRetentionoverride satırları eklenir. - Docker topolojisi: kökteki
docker-compose.yml'daki (kullanılmadan duran,.env.example'daRABBITMQ_*değişkenleriyle zaten scaffold edilmiş)rabbitmqservisi tek paylaşılan broker olur. Üretilen servisler kendi izole compose'larında RabbitMQ container'ı açmaz;appsettings.json'dakiRabbitMq:Hostvarsayılanıhost.docker.internal'dır (bkz. §5.1 cross-service host notu).
v1 sınırlamaları (bilinçli, dokümante edilen basitlik):
Tüketici tarafı: DLQ/retry politikası yokDLQ çözüldü (2026-07-16): Daha öncenack(requeue: false)ile reddedilen bir mesaj, kuyrukta hiçbir dead-letter-exchange tanımlı olmadığı için RabbitMQ tarafından sessizce ve kalıcı olarak siliniyordu — gerçek bir veri kaybıydı. Artık her abonelik için paylaşılan bir{ExchangeName}.dlx(fanout) exchange'e bağlı bir{queue}.deadkuyruğu declare ediliyor (asıl kuyrukx-dead-letter-exchangeargümanıyla açılıyor); reddedilen mesaj artık kaybolmuyor,{queue}.dead'de (RabbitMQ management UI'dan) görülüp incelenebiliyor/elle replay edilebiliyor. Bilinçli kapsam dışı: Otomatik N-kere-yeniden-dene-sonra-DLQ (retry-with-delay) eklenmedi — TTL+DLX zincirleme (delay queue pattern) gerektirir, canlı bir broker'a karşı doğrulanmadan doğru kurulduğuna güvenmek riskli; ayrı bir gelecek iş.- Outbox relay tarafı: at-least-once teslim, exactly-once değil — publish RabbitMQ'ya gittikten sonra
ProcessedAtcommit edilmeden process çökerse mesaj bir sonraki taramada tekrar gönderilebilir. Asıl çözülen sorun (commit sonrası publish başarısızlığında event'in tamamen kaybolması) tamamen giderildi.Tüketici tarafıÇözüldü (2026-07-16) — Inbox pattern:EventIdbazlı idempotency yokInboxMessage(Core,OutboxMessageile aynı gerekçeyle marker interface implemente etmez) +BaseForgeDbContext.InboxMessages.RabbitMqConsumerHostedService.HandleDeliveryAsync, MediatR'a dağıtmadan önceInboxMessages'ta aynıEventId'yi arar — bulursa handler'ı tekrar çalıştırmadanack'ler. İşaretleme handler'dan SONRA yapılır (mark-after, mark-before değil): handler çökerse Inbox satırı henüz commit edilmediği için yeniden teslimat hâlâ "işlenmemiş" görünüp tekrar denenir — mark-before olsaydı event yanlışlıkla "zaten işlendi" sayılıp kaybolurdu. Kalan kısıt: bu iki adım (handler'ın kendi DB etkileri + Inbox satırı) TEK bir transaction'da değil — handler başarılı ama Inbox commit/ack arasında çökme olursa nadir bir gerçek duplicate işlem olabilir (bugünkünden çok daha iyi, mükemmel değil). Outbox'ta cleanup/retention job yokÇözüldü (2026-07-16) — yukarıya bkz.Outbox'ta sınırsız retry varÇözüldü (2026-07-16) — max-retry sonrası dead marking, yukarıya bkz. (Outbox'ın kendi "dead" satırları için ayrı bir DLQ/broker yolu yoktur, tabloda kalır — tüketici tarafındaki broker DLQ'su [aşağıdaki madde] farklı bir mekanizma.)OutboxMessagestablosu da diğer tüm entity tabloları gibi migration'sız, yalnızcaDatabase.EnsureCreated()(Development ortamı) ile oluşur.Kanal havuzu yokÇözüldü (2026-07-16):RabbitMqConnectionManager'a sınırlı (kapasite 10) birRentChannelAsync/ReturnChannelAsynchavuzu eklendi;RabbitMqPublisher.PublishRawAsyncartık her publish'te aç/kapat yerine bunu kullanır. Tüketici hosted service'i zaten uygulama ömrü boyunca tek bir kanal tuttuğu için değişmedi (havuza ihtiyacı yok).- gRPC çağrılarında olduğu gibi, mesajlarda JWT/kimlik propagasyonu yok.
5.3. JSON/JSONB Alan Tipi
Spec tip sistemine json eklendi: C# tarafında string (serileştirilmiş JSON metni), veritabanı tarafında Postgres jsonb ([Column(TypeName = "jsonb")], EF Core native fluent API yerine — bu üretici mevcut MaxLength deseninin aynısı, DataAnnotation attribute olarak entity sınıfına gömülür).
- Karar:
TypeMap.cs'e["json"] = ("string", "jsonb")eklendi;[Column(TypeName = "jsonb")]yalnızca entity sınıfında üretilir, Create/Update komut DTO'larında değil (Column attribute'u yalnızca EF-mapped tiplerde anlamlıdır; DTO'lar mapped değildir —MaxLength'in DTO'larda da anlamlı olmasının [ASP.NET model validation] aksine). - Gerekçe: Esnek/şemasız payload alanları (örn. audit/trace event'lerinin olay-spesifik verisi) için ayrı bir tablo/JOIN yerine tek bir sütun yeterli; Postgres'in native
jsonbdesteği sorgu/index imkânı da sağlıyor (ilerideEF.Functions.JsonContainsvb. ile). - Kısıtlar: Yalnızca Postgres
jsonb'e eşlenir (SQL Server gibi başka bir provider hedeflenirse bu tip yeniden değerlendirilmeli).maxLengthjson'da anlamsız olduğu içinSpecValidator'ın string/text-only kontrolü sayesinde otomatik reddedilir (ek kod gerekmedi). gRPC proto tarafındastringolarak taşınır (decimal/datetime/guid ile aynı "native karşılığı yok" kısıtı).
5.4. Append-Only Entity'ler
EntitySpec.AppendOnly: bool — true ise Update/Delete komutu, handler'ı ve controller action'ı hiç üretilmez; yalnızca Create/GetById/List kalır.
- Karar: Servis-geneli değil, entity-bazlı bir bayrak (her serviste hem mutable hem append-only entity'ler bir arada olabilir — örn.
Productmutable,TraceEventappend-only, aynı serviste). - Gerekçe: GMP/Annex 11 ve 21 CFR Part 11 gibi regülatif uyum gerektiren audit/trace kayıtlarının API üzerinden asla değiştirilememesi/silinememesi gerekiyor. Bunu yalnızca "istemci Update/Delete çağırmasın" (sözleşme/dokümantasyon) yerine, üretici seviyede fiziksel olarak var olmayan bir endpoint ile garanti altına almak daha güvenli.
- Kısıtlar:
AppendOnly=trueikenpublisheslistesindecreateddışında bir değer (updated/deleted) veyaanonymousActionsiçindeupdate/deleteolamaz —SpecValidatorbunu derleme/üretim öncesi hata olarak yakalar (sessizce yok saymak yerine "fail loud").
5.5. Multi-Tenancy
ServiceSpec.MultiTenant: bool — true ise servisin tüm entity'leri ITenantEntity (Guid TenantId, BaseForge.Core.Entities) implemente eder; options.EnableMultiTenancy() çağrılır.
- Karar: Servis-geneli, entity-bazlı değil — gerçek izolasyon her tabloyu kapsamalı, entity-bazlı seçim ayak tuzağı olurdu (bir tabloyu unutmak = tenant sızıntısı).
BaseEntity<TKey>değiştirilmedi (mevcut tüm servisleri etkileyen breaking change olurdu) — yeniITenantEntitymarker interface'i,ISoftDeleteile aynı desende, yalnızca CodeGen tarafındanMultiTenant: trueolan servislerin entity'lerine eklenir;TenantIdkullanıcı tarafından YAML'da tanımlanmaz, otomatik enjekte edilir. - Mekanizma:
ICurrentTenant(Core,ICurrentUserile aynı şekil) +CurrentTenant(API, JWTtenant_idclaim'i okur)EnableMultiTenancy()ile DI'a kaydedilir.BaseForgeDbContext:ApplyAuditAndSoftDelete,AddeddurumundakiITenantEntity'lereTenantId'yi damgalar;ICurrentTenant.TenantIdnull iseInvalidOperationExceptionfırlatır (sessiz NULL satır yerine "fail loud").OnModelCreating, EF Core'un her entity tipi için yalnızca tek query filter'a izin vermesi nedeniyle,ISoftDeleteveITenantEntityfiltreleriniExpression.AndAlsoile tek bir birleşik filtrede kurar (4 durum: ne biri ne diğeri / yalnız soft-delete / yalnız tenant / ikisi birden).- Üretilen DbContext'in constructor'ı
ICurrentUser?/ICurrentTenant?'ıBaseForgeDbContext'e forward eder ((options, currentUser = null, currentTenant = null) : base(...)) — bu forward olmadan tenant damgalama hiç çalışmaz.
- Bilinen bir reflection tuzağı (üretim sırasında yakalandı, birim testle doğrulandı): Query filter ifadesinde o anki context'e (
this) referans verirkenExpression.Constant(this, typeof(BaseForgeDbContext))ile açıkça temel sınıf olarak tiplemek gerekir —Expression.Constant(this)runtime tipini (her zaman türetilmiş, CodeGen'in ürettiği DbContext sınıfı) kullanırsa,privateCurrentTenantIdproperty'si (yalnızcaBaseForgeDbContext'te tanımlı, private üyelerFlattenHierarchyile türetilmiş tipe miras alınmaz) reflection'da bulunamaz ve her sorgudaArgumentExceptionfırlar. - Kısıtlar: Tenant claim adı sabit:
tenant_id. Multi-tenant bir entity'ye tenant context'siz (örn. arka plan servisindenICurrentTenantolmadan) kayıt eklemek exception fırlatır — bu bilinçli bir tasarım (sessiz cross-tenant sızıntısı yerine).
5.6. Merkezi Loglama ve Correlation ID
Her mikroservis kendi konsol çıktısına hapsolmuş durumdaydı — bir isteği HTTP → gRPC → RabbitMQ event zinciri boyunca servisler arasında takip etmenin yolu yoktu. Serilog + Grafana Loki ile merkezi, yapılandırılmış (structured) loglama ve üç sınırı (HTTP/gRPC/RabbitMQ) aşan bir CorrelationId eklendi.
Tasarım kararı — her zaman açık: ExceptionHandlingMiddleware/RequestLoggingMiddleware gibi bu da spec.yaml'da opt-in bir toggle değildir — ServiceSpec/CodeModel/Designer UI'a dokunulmadı, yalnızca sabit CodeGen template'lerine eklendi. Loki URL'i boşsa/erişilemezse servis konsola loglamaya devam eder (RabbitMq'nun host.docker.internal ile paylaşılan broker'a bağlanma deseniyle tutarlı — Loki'nin ayakta olması bir ön koşul değildir); Loki sink'i içeride bir yazma hatasıyla karşılaşırsa (2026-07-16) artık Serilog.Debugging.SelfLog ile stderr'e bir tanılama satırı düşer — tamamen sessiz değildir.
ICorrelationIdAccessor(Core,BaseForge.Core.Logging) — o anki akışın correlation id'sine ambient erişim sözleşmesi.CorrelationIdAccessor(Infrastructure)AsyncLocal<string?>ile implemente eder, Singleton kaydedilir; async çağrı zinciri boyunca (HTTP → handler → outbox yazımı, gRPC çağrısı, consumer'ın MediatR dispatch'i) doğru akar, eşzamanlı farklı akışlar arasında sızmaz.- HTTP sınırı:
CorrelationIdMiddleware(API) — pipeline'ın en başına eklenir (ExceptionHandlingMiddleware'den bile önce). GelenX-Correlation-Idheader'ını kullanır (yoksa üretir), accessor'a yazar, SerilogLogContext'e ekler, response'a da aynı header'ı geri yazar. - gRPC sınırı:
CorrelationIdClientInterceptor/CorrelationIdServerInterceptor(API,BaseForge.API.Grpc) — giden çağrının metadata'sınacorrelation-ideklenir, sunucu tarafında okunup accessor/LogContext'e yazılır. CodeGen Program.cs şablonu herAddGrpcClient<...>()çağrısına.AddInterceptor<CorrelationIdClientInterceptor>(),AddGrpc()çağrısına da server interceptor'ı otomatik ekler. - RabbitMQ sınırı:
EventEnvelope'a (Infrastructure)CorrelationIdalanı eklendi — DB şema/migration değişikliği yok (OutboxMessage.Payloadzaten tam JSON blob'u, yeni alan sadece o JSON'un bir üyesi).OutboxEventBus.PublishAsync, envelope'u oluştururken accessor'ın o anki değerini gömer;RabbitMqConsumerHostedService.HandleDeliveryAsync, mesajı MediatR'a dağıtmadan önce bu id'yi yeni scope'un accessor'ına veLogContext'e geri yükler — event'i işleyen handler'ın logları, olayı tetikleyen orijinal istekle aynı id'yi taşır. - Serilog wiring: yeni
WebApplicationBuilder.AddBaseForgeLogging(serviceName)(API) —AddBaseForge(services, DI) çağrısından ayrı,builder.Host.UseSerilog(...)seviyesinde çağrılır (Serilog logging provider'ı Host üzerinden değiştirir). Her log satırıServicealanıyla etiketlenir;Serilog:LokiUrlappsettings anahtarı doluysaSerilog.Sinks.Grafana.Lokiile push edilir, boşsa yalnızca konsola yazılır. - Kök
docker-compose.yml'a paylaşılanloki+grafanacontainer'ları eklendi (Postgres/RabbitMQ ile aynı desen); Grafana,grafana/provisioning/datasources/loki.yamlile Loki datasource'unu otomatik provision eder (elle "Add datasource" gerekmez).
v1 sınırlamaları (bilinçli, dokümante edilen basitlik):
Grafana'da hazır bir dashboard yokÇözüldü (2026-07-16):grafana/dashboards/baseforge-logs.json(provisioning:grafana/provisioning/dashboards/dashboards.yaml) —Service/CorrelationIdtemplate değişkenleriyle filtrelenebilen bir log paneli + servise göre log hacmi zaman serisi paneli. Daha ileri düzey sorgular hâlâ LogQL ile Explore üzerinden yapılır.- Log retention/rotation:
grafana/loki-config.yaml(2026-07-16) ile 7 gün (retention_period: 168h,compactor.retention_enabled: true) olarak yapılandırıldı — değiştirmek için bu dosyayı düzenleyipdocker compose up -d lokiile yeniden başlatmak yeterli. 2026-09-26'da canlı Loki 3.2.0 container'ında doğrulandı (servis loglarıserviceetiketiyle geliyor, Grafana veri kaynağı ve dashboard provisioning çalışıyor). Loki + Grafana yalnızca bu deponun kök compose'unda tanımlıydı; kullanıcının workspace'inde çalışan bir Loki olmadığı için loglar sessizce yalnızca konsola düşüyorduÇözüldü (2026-09-26): ilk servis/identity üretiminde workspace köküneobservability/(compose + yukarıdakigrafana/dosyaları, gömülü kaynak olarak tek kaynaktan; rastgele Grafana admin parolası.env'de) yazılır; klasör varsa dokunulmaz. ÜretilenlaunchSettings.json, yereldotnet runiçinSerilog__LokiUrl=http://localhost:3100ve Authority'ninlocalhostkarşılığını verir —host.docker.internalyalnızca container'ların içinden çözülür.Serilog:LokiUrlboşsa/erişilemezse konsola düşülür; erişilemezse artıkSelfLogile stderr'e tanılama mesajı yazılır (2026-07-16) — ama bu tam bir healthcheck/retry değil, yalnızca görünürlük.- gRPC client interceptor'ı yalnızca unary çağrıları destekler (CodeGen bugün yalnızca unary
GetByIdüretiyor — streaming RPC yok).
5.7. Health Check ve Servis Durumu İzleme
Docker-compose'daki healthcheck: blokları önceden yalnızca altyapı container'ları (Postgres pg_isready, RabbitMQ rabbitmq-diagnostics ping) içindi — üretilen servisin kendi uygulama container'ının canlı olup olmadığını gösteren bir app-level probe yoktu, dolayısıyla Identity dashboard'unun "Servisler" bölümü de sadece codegen anında donmuş bir services.json anlık görüntüsü gösteriyordu (isim/port/entity sayısı, canlılık bilgisi yok).
Tasarım kararı — her zaman açık: §5.6'daki loglama gibi, /health de spec.yaml'da opt-in bir toggle değildir — amacı tam olarak Identity'nin her servisi güvenilir şekilde yoklayabilmesi; opt-in olsaydı bazı servisler dashboard'da görünmezdi.
/healthendpoint'i (BaseForge.API):AddBaseForgeher zamanAddHealthChecks()çağırır;UsePostgreSQLile bir bağlantı dizesi verildiyse (her zaman verilir)PostgresHealthCheck(Infrastructure, hamNpgsqlConnection+SELECT 1— ayrı birAspNetCore.HealthChecks.NpgSqlbağımlılığı eklemeden) bir"postgresql"check'i olarak eklenir.UseBaseForge(artıkWebApplicationalıyor — endpoint eşleme gerektiği içinIApplicationBuilder'dan genişletildi)/health'i JWT/[Authorize]'dan bağımsız (Protectper-controller uygulanıyor, global filtre yok) küçük özel bir JSON response writer'la eşler:{"status":"Healthy","checks":[{"name":"postgresql","status":"Healthy","durationMs":12}]}.- Docker healthcheck: CodeGen'in
docker-compose.yml/Dockerfileşablonlarına (Templates.cs, identity içinIdentityGenerator.BuildCompose/BuildDockerfile)curl -f http://localhost:8080/healthtabanlı birhealthcheck:bloğu eklendi;mcr.microsoft.com/dotnet/aspnet:10.0curl içermediği için final Docker stage'eapt-get install curleklendi. - Identity'nin canlı yoklaması (
ServicesApiController.Status,GET /api/services/status): Identity kendisi hariç kayıtlı her servisihost.docker.internal:{restPort}/healthüzerinden yoklar — her üretilen servis kendi bağımsız docker-compose ağında çalıştığı için (container DNS'i paylaşılmıyor), mevcut cross-service gRPC deseniyle (§7.1,CrossServiceHost = "host.docker.internal") aynı host-mapped port yaklaşımı kullanılır. Bu, kod tabanındaki ilk server-to-serverHttpClientkullanımı ("ServiceHealthClient", 2 saniye timeout,AddHttpClientile adlandırılmış) — bugüne kadar servisler arası iletişim yalnızca gRPC/RabbitMQ idi. - Dashboard (React):
Home.tsxmevcut statikservices.jsonlistesini (isim/port/entity sayısı)/api/services/status'un döndürdüğü canlı{name, healthy, checkedAt}listesiyle isim eşleştirerek birleştirir; 10 saniyede birsetIntervalile yeniler, her kartta yeşil/kırmızı/gri nokta + "Ayakta"/ "Kapalı"/"Kontrol ediliyor…" rozeti gösterir.
v1 sınırlamaları (bilinçli, dokümante edilen basitlik):
- Geçmişe dönük uptime/downtime kaydı veya grafik yok — sadece anlık durum (pull/polling, push değil).
/healthve/api/services/statuskimlik doğrulamasız — statikservices.json'ın zaten paylaştığı trust seviyesiyle tutarlı (internal/ops amaçlı, dashboard zaten aynı trust boundary'de).- Otomatik alarm/bildirim (servis düşünce e-posta/Slack) yok — ayrı bir gelecek özellik.
- Yerel (
dotnet run, container dışı) çalıştırmadahost.docker.internalçözümlemesi garanti değil — mevcut gRPC cross-service deseninin de paylaştığı bilinen bir kısıt, yeni bir risk değil.
5.8. Gateway / BFF — YARP Tabanlı Reverse Proxy
Bugüne kadar servisler arası iletişim (§5.1) yalnızca "ID'den tekil kayıt çözümleme" (gRPC, Integration/{Entity}Client.cs) sağlıyordu — bir frontend'in birden fazla servisin TÜM CRUD/liste uçlarına tek bir origin üzerinden erişebilmesi için hiçbir mekanizma yoktu (her servis izole bir docker-compose ağında, kendi host portunda). Bu, bir servisin diğerlerinin REST yüzeyini frontend'e tek bir kapı üzerinden sunabilmesini sağlayan, kalıcı bir gateway/BFF özelliği ekler.
Tasarım kararı — entity-agnostik, config-only proxy. Gateway, kardeş servisin entity'lerini TEK TEK bilmez/proxy'lemez — ServiceSpec.Gateway.ProxiedServices'teki her kardeş servisin tüm /api/* yüzeyi, /api/gateway/{servis}/{**catch-all} altında YARP ile şeffafça iletilir. Bunun avantajı: kardeş serviste yeni bir entity eklendiğinde gateway'in yeniden üretilmesi GEREKMEZ — sadece port/route bilgisi baştan üretilir, entity bilgisi hiç gerekmez.
- Spec:
gateway: { proxiedServices: [core, its, netsis] }(GatewaySpec,ServiceSpec.cs). - Port çözümleme (
CodeGenerator.ResolveGatewayTargets): her proxied servisin host REST portu, workspace kökündeki paylaşılanservices.jsonkaydından (ServiceRegistry.LoadForWorkspace, §7.1'deki identity gRPC port çözümlemesiyle AYNI desen) okunur — kardeş servisin hamspec.yaml'ininDockerPorts'u DEĞİL, çünkü henüz üretilmemiş/port ayarlanmamış bir hedefte bu sabit8080'e düşer ve birden fazla proxied servis aynı (yanlış) adrese çakışabilirdi. Kayıtta bulunamayan bir hedef sessizce atlanır (uyarı yazılır) — önce o servisin üretilmiş/güncellenmiş olması gerekir. - Üretim: appsettings.json'a bir
ReverseProxybölümü (her proxied servis için birRoutes+Clustersçifti,PathRemovePrefixile/api/gateway/{servis}öneki soyulupPathPrefixile/apieklenir — önceden kullanılanPathPattern: /api/{catch-all}, çok parçalı yakalamadaki/'ı kodlayıpListings/{id}gibi iki+ parçalı yolları hedefte 404'e düşürüyordu, HekimBurada'da canlıda bulundu) veProgram.cs'ebuilder.Services.AddReverseProxy().LoadFromConfig(...)+app.MapReverseProxy()eklenir (Templates.cs). YalnızcaGatewayset edilmiş projedeYarp.ReverseProxypaket referansı koşullu eklenir (Templates.Project,ProjectFileModel.HasGateway). - CORS: proxied servislerin CORS'u tarayıcı açısından önemsizdir (tarayıcı onlara asla doğrudan gitmez) — yalnızca gateway servisinin KENDİ
corsOrigins'i (mevcut §-genel CORS mekanizması,Cors:AllowedOrigins) frontend'in origin'ini içermelidir.
v1 sınırlaması (bilinçli, dokümante edilen basitlik): MapReverseProxy() uçları MVC pipeline'ının dışında çalışır — [Authorize] bunlara UYGULANMAZ, gateway hop'u kendisi kimlik doğrulaması yapmaz. YARP Authorization header'ını varsayılan olarak olduğu gibi iletir, gerçek yetkilendirme sınırı hâlâ hedef serviste (protect: true + [Authorize]) duruyor — bu bir güvenlik açığı değildir (gateway ekstra bir doğrulama katmanı EKLEMİYOR, ama mevcut sınırı da BOZMUYOR), sadece gateway'de bir ön-kontrol yok. Ayrıca: proxied servisin portu üretim anında appsettings.json'a "gömülür" — bir kardeş servisin portu sonradan değişirse gateway'in de yeniden üretilmesi gerekir (mevcut gRPC-client port coupling'iyle aynı bilinen kısıt).
6. Kimlik Doğrulama
- Merkezi tek bir Identity Service vardır (JWT / OAuth2).
- Her servis JWT token'ı kendi middleware'inde lokal olarak validate eder; her istekte merkezi DB'ye çağrı yapılmaz.
6.1. Yetkilendirme Modeli (roller + sahiplik)
Kimlik doğrulama ("giriş yapmış mı?") ile yetkilendirme ("bunu yapmaya hakkı var mı?") ayrıdır. auth.protect + anonymousActions yalnızca ilkini ifade ediyordu; Identity Admin/User rollerini token'a koyuyor ama servisler bunu okumuyordu — kayıt olan her kullanıcı her yazma ucunu çağırabiliyordu. HekimBurada'da bu boşluk ~60 elle yazılmış "sahip/admin şartı" ve her serviste kopyalanan AdminAuth.cs ile kapatılmıştı.
Spec (servis):
auth:
protect: true
defaultAccess: authenticated # access'te belirtilmeyen action'lar için (varsayılan: authenticated)
superRoles: [SuperAdmin] # opsiyonel — bu roller her kuralı (roller + owner) otomatik geçer
entities:
Post:
ownerField: AuthorId # opsiyonel, guid prop
access:
list: anonymous
getById: anonymous
create: [Admin, Editor]
update: [Admin, owner]
delete: [Admin]Action başına değer: anonymous → [AllowAnonymous]; authenticated → [Authorize]; rol listesi → [Authorize(Roles = "...")]; listede owner varsa → [Authorize] + sahiplik kontrolü (listelenen roller ve superRoles kontrolü geçer). anonymousActions geriye dönük uyumluluk için korunur (access: { x: anonymous } ile aynı); aynı entity'de ikisi birlikte kullanılamaz.
Sahiplik (ownerField):
create: sahip alanı istekten okunmaz, token'dakisubile damgalanır (başkası adına kayıt açılamaz).update: sahip alanı hiçbir zaman değiştirilmez (sahiplik devri API'den yapılamaz).update/deletekuralındaownervarsa: çağıran sahibi değilse ve rollerinden hiçbiri kuralda/superRoles'ta yoksaForbiddenException→ 403.list/getByIdkuralındaownervarsa: aynı durumda yalnızca kendi kayıtları döner (getById'de başkasınınki 404 — varlığı sızdırılmaz).
Kararı controller verir, handler uygular. Controller rol/sahiplik durumunu hesaplayıp komut/sorguya [BindNever]/[JsonIgnore] bir alan olarak geçirir (RestrictToOwnerId); handler yalnızca bu alan doluysa filtre/kontrol uygular. Gerekçe: gRPC sunucu servisleri aynı handler'ları kullanıcı bağlamı olmadan (servisler arası, güvenilir çağrı) çağırır — kontrol doğrudan handler'da ICurrentUser ile yapılsaydı servisler arası okumalar boş dönerdi. Alan istemciden bağlanamadığı için (model binding'e kapalı) istek ile atlatılamaz.
Rol claim'i (EnableJwt): MapInboundClaims = false, RoleClaimType = "role", NameClaimType = "sub" — OpenIddict'in kısa claim adları olduğu gibi kalır, [Authorize(Roles = ...)] ve User.IsInRole ek kod olmadan çalışır. Kırıcı değişiklik: servis kodunda ClaimTypes.Role/ClaimTypes.NameIdentifier ile claim arayan yerler artık kısa adları (role/sub) aramalı (CurrentUser.UserId ikisine de bakar).
Identity (auth.yaml):
roles: [Admin, User, Editor] # seed edilir; Admin ve User her zaman eklenir
registration:
enabled: false # varsayılan: kapalı
defaultRole: UserKayıt kapalıyken: /api/account/register 404 döner, SPA kayıt bağlantısını gizler ve dış sağlayıcı (Google vb.) ile ilk kez gelen kullanıcı için hesap oluşturulmaz — dış giriş de bir kayıt yoludur; yalnızca önceden var olan (admin panelinden eklenmiş) kullanıcılar dış sağlayıcıyla girebilir. Varsayılanın kapalı olmasının gerekçesi: halka açık bir generator'da güvenli varsayılan; kayıt gerektiren projeler (örn. HekimBurada) spec'te açıkça enabled: true yazar.
Bilinen kısıtlar: superRoles multi-tenant servislerde (§5.5) kiracı filtresini atlamaz — SuperAdmin de yalnızca kendi tenant_id'sinin verisini görür; platform genelinde (cross-tenant) okuma ayrı bir özellik. Sayaç (counters) uçları access'ten bağımsız olarak herkese açık kalır. Servis spec'indeki rol adları, kardeş identity/auth.yaml bulunursa onun roles listesiyle karşılaştırılır (bulunamazsa uyarı), bulunamazsa kontrol atlanır.
6.2. Liste Filtreleri ve Okuma Görünürlüğü
Post:
filterable: [Status, AuthorId] # ?status=Live&authorId=... (eşitlik; yalnızca sayfalı listeler)
readFilter:
where: { IsPublished: true } # herkes yalnızca bunları görür (VE)
bypassRoles: [Admin] # + servisin superRoles'u otomatik
bypassOwner: true # sahibi kendi kayıtlarını (taslaklarını) da görür- filterable: prop'lar, ilişki FK'leri (
{İlişki}Id) ve dış referans alanları; tipler string/sayı/bool/guid/date/enum. Liste sorgusuna nullable özellik olarak eklenir, verilmeyen filtre uygulanmaz. Sayfalama/arama adlarıyla (Page,Search…) çakışamaz. - readFilter: tfbSoft'ta elle yazılan "taslakları anonimden gizle" deseninin genellemesi. list ve getById'ye uygulanır; koşula uymayan kayıt getById'de 404 (varlığı sızdırılmaz).
wheredeğerleri tipine göre derleme zamanında C# literal'ine çevrilir (bool/enum/string/int). Sahiplik (§6.1) ile aynı ilke: kararı controller verir (ApplyReadFilter,ReadFilterOwnerId—[BindNever], istemciden bağlanamaz), handler uygular; gRPC servisler arası okumalar etkilenmez. Sayfalamasız listelerde bellekte uygulanır. - Neden erişim kuralından ayrı?
access"bu uca kim girebilir"i,readFilter"girenin hangi satırları göreceği"ni belirler; blogda liste herkese açıkken (list: anonymous) taslakların yalnızca yazara/admin'e görünmesi ikisinin birlikte kullanılmasını gerektirir.
6.3. Kullanıcı Profil Alanları (userProfile)
Identity spec'ten üretilmediği için (gömülü referans servis kopyalanır) domain'e özgü kullanıcı alanları (HekimBurada: Specialty, DiplomaNo, VerificationStatus) elle bir yan entity + elle düzenlenmiş user.proto gerektiriyordu. auth.yaml'a deklaratif alanlar eklendi:
userProfile:
props:
Specialty: string
DiplomaNo: { type: string, nullable: true, maxLength: 32 }
VerificationStatus: { type: enum, values: [Pending, Approved, Rejected], default: Pending, editableBy: admin, inToken: true }- Alan tanımı servis
props'uyla aynıdır (PropSpec: tip, nullable, maxLength, default, enumvalues) + iki ek anahtar:editableBy: self | admin(varsayılanself— kullanıcı profil sayfasından düzenler;adminyalnızca admin panelinden) veinToken(varsayılanfalse;trueise alan camelCase adıyla JWT claim'i olur — değer token yenilenene kadar eskidir).jsontipi veeditableBy/inToken'ın servis spec'lerinde kullanımı reddedilir; Identity'nin kendi alanlarıyla (Email,FullName, …) ve standart claim adlarıyla çakışan adlar geçersizdir. - Ayrı tablo yok: alanlar doğrudan
ApplicationUser'a eklenir (partialsınıf; üretilenEntities/ApplicationUser.Profile.cs). Enum'larUser{Alan}C# enum'u olur, DB'de string saklanır (servislerdeki enum ile aynı ilke). - Şema senkronu: Identity
EnsureCreatedkullandığından mevcut bir veritabanına sonradan eklenen alanların kolonu oluşmazdı. ÜretilenUserProfile.EnsureColumnsAsyncaçılışta her alan içinALTER TABLE "AspNetUsers" ADD COLUMN IF NOT EXISTSçalıştırır (NOT NULL kolonlar default'la —defaultveya tipin sıfır değeri — eklenir, böylece mevcut satırlar geçerli kalır). Kolon silme/tip değiştirme yapılmaz (veri kaybı riski; elle). - API:
GET /api/account/meve admin kullanıcı satırlarıprofilesözlüğünü döner;PUT /api/account/profileprofileiçinde yalnızcaselfalanlarını kabul eder (adminalanı gönderilirse 400);PUT /api/admin/users/{id}/profiletüm alanları düzenler. Kısmi güncelleme: gönderilmeyen alan değişmez. Değerler tipine göre doğrulanır (enum değeri, maxLength, nullable).GET /api/account/profile-schemaalan metadata'sını verir — Ortak Giriş SPA'sı profil ve admin formlarını buradan dinamik çizer (SPA önceden derlenip gömüldüğü için alanlar derleme zamanında bilinmez). - gRPC:
user.protoartık sabit dosya değil, auth.yaml'dan üretilir (UserMessage1–4 sabit, profil alanları 5'ten itibaren sırayla).identity/Userdış referansı çözümlenirken CodeGen workspace'tekiidentity/auth.yaml'ı okuyup aynı proto'yu ve zenginUserReferencealanlarını üretir; auth.yaml bulunamazsa profilsiz gömülü proto'ya düşer. Alan sırası değişirse proto numaraları değişir — Identity ve tüketen servisler birlikte yeniden üretilmelidir. - Bilinen kısıtlar: kayıt formunda profil alanları sorulmaz (kayıt sonrası profil sayfasında doldurulur;
adminalanları zaten kullanıcıya kapalıdır). Profil değişikliği olay (publishes) yayınlamaz — Identity'nin olay senkronu ayrı bir açık madde.
7. Containerization
- Her servis için ayrı
Dockerfile. - Tüm servisler tek bir
docker-compose.ymlile ayağa kalkar. Not: CodeGen bugün her servis için ayrı, izole birdocker-compose.ymlüretir (kendi Postgres'i ile); kökteki paylaşılandocker-compose.ymlyalnızca tek-örnek altyapı için kullanılır (Postgres + RabbitMQ, bkz. §5.2) — üretilen servisler bu paylaşılan broker'ahost.docker.internalüzerinden bağlanır. - Konfigürasyon
.envdosyasından okunur; production'da.env.productionkullanılır.
7.1. Servis Kaydı (ServiceRegistry) — Port/Authority Doğruluğu
ServiceRegistry.cs her üretimde workspace kökünde (üretilen servis klasörünün bir üstünde) paylaşılan bir services.json tutar: Name, RestPort, GrpcPort, PostgresPort, IsIdentity, Authority, Audience, Protected. Bunun iki tüketicisi var:
- Identity dashboard'u — üretim sırasında bu kaydın güncel hali identity'nin kendi
wwwroot'una kopyalanıp imaja gömülür (SnapshotForIdentity), "Servisler" bölümü bunu okur. - CodeGen'in kendisi — bir servis
identity/User'a (via: grpc) referans verdiğinde, Identity'nin gerçek gRPC portu bu kayıttan okunur (ServiceRegistry.LoadForWorkspace); kardeş bir servise referans veriliyorsa (identity dışı) port doğrudan kardeşin kendispec.yaml'inden (DockerPorts.Grpc) okunur. Karar değişikliği (2026-07-13): Önceden appsettings.json'dakiGrpc:{Provider}adresi portu hardcoded8081yazıyordu — sağlayıcının gerçekte hangi portu kullandığından bağımsız. Bu, herkes varsayılan portları kullandığı sürece fark edilmiyordu; portlar artık rutin olarak farklılaşacağı (bkz. §7.2) için gerçek bir bağlantı hatasına dönüşürdü. Kayıt/kardeş-spec'te port bulunamazsa eski varsayılana (8081, identity için8082) sessizce düşülür — hata fırlatılmaz.
7.2. Designer — Otomatik Artan Port/Authority Önerisi
Designer, /api/workspace ile bu kaydı okuyup yeni bir servis/identity açıldığında (spec.yaml/ auth.yaml diskte henüz yoksa) REST/gRPC/Postgres portlarını, workspace'teki en yüksek kullanılan değerin bir fazlası olacak şekilde gerçek, düzenlenebilir varsayılan değer olarak önceden doldurur (salt placeholder metni değil) — kullanıcı hâlâ elle değiştirebilir. Authority alanı da aynı şekilde, workspace'te bir Identity kaydı varsa http://host.docker.internal:{identity'nin gerçek REST portu} olarak önerilir (önceden hardcoded http://localhost:5090 idi — bu, Docker container içinden asla erişilemeyen bir adres, çünkü 5090 yalnızca Identity'nin yerel dotnet run portu). Bir servis/identity üretildikten sonra (aynı Designer oturumunda ikisi art arda üretilebildiği için) workspace yeniden okunur; kullanıcının elle değiştirmediği (hâlâ önceki önerilen değere eşit) alanlar canlı güncellenir, elle girilmiş bir değer asla ezilmez.
8. Dağıtım
- Üç paket public NuGet (nuget.org) olarak yayınlanır:
BaseForge.Core,BaseForge.Infrastructure,BaseForge.API.
Karar Günlüğü
| Tarih | Karar | Durum |
|---|---|---|
| 2026-06-24 | Proje iskeleti (.NET 10, 3 src + 2 test projesi, .slnx) kuruldu | ✅ |
| 2026-06-24 | Opinionated Library + Clean Architecture + CQRS(MediatR) kararları PDF spesifikasyonundan alındı | ✅ |
| 2026-06-24 | Veri erişimi PDF'teki "ADO.NET, ORM yok" yerine EF Core 10 (ORM) + Dapper (ham SQL) olarak revize edildi | ✅ |
| 2026-06-24 | CQRS için MediatR 12.5.0 (son ücretsiz/Apache-2.0 sürüm; v13+ ticari) sabitlendi | ✅ |
| 2026-06-24 | nuget.org Trusted Publishing (OIDC, .github/workflows/publish.yml) kuruldu; klasik API key yerine | ✅ |
| 2026-06-24 | Backlog "ER Diagram": BaseForge.Tools paketi + DbmlGenerator (EF Core model → DBML) eklendi; kaynak=EF Core model, çıktı=DBML | ✅ |
| 2026-07-07 | gRPC senkron iletişim gerçek hale getirildi: otomatik proto üretimi (server+client), kardeş-spec zengin çözümleme, identity/User özel durumu, Kestrel iki-port (h2c) düzeltmesi. RabbitMQ hâlâ backlog'da. | ✅ |
| 2026-07-10 | RabbitMQ asenkron event pub/sub eklendi: IIntegrationEvent/IEventBus (Core/Infrastructure, MediatR'ı yerel dağıtım için yeniden kullanır), EnableRabbitMq (API, EnableJwt deseniyle), CodeGen publishes/subscribes (bkz. §5.2). via: event kalıcı olarak no-op — ayrı bir gelecek özellik için rezerve. Aynı geçişte rich gRPC client'ların ProviderHost'u host.docker.internal'a düzeltildi (izole compose ağları arasında hiç çalışmıyordu). | ✅ |
| 2026-07-13 | json/jsonb prop tipi eklendi (bkz. §5.3): TypeMap + [Column(TypeName = "jsonb")] yalnızca entity sınıfında (DTO'larda değil). | ✅ |
| 2026-07-13 | Append-only entity desteği eklendi (bkz. §5.4): EntitySpec.AppendOnly — Update/Delete komut/handler/controller action'ı hiç üretilmez; GMP/21 CFR Part 11 audit/trace senaryosu için. | ✅ |
| 2026-07-13 | Multi-tenancy eklendi (bkz. §5.5): ServiceSpec.MultiTenant, ITenantEntity/ICurrentTenant/EnableMultiTenancy(), BaseForgeDbContext'te Expression.AndAlso ile birleşik soft-delete+tenant query filter. Üretim sırasında iki gerçek hata bulunup düzeltildi: (1) üretilen DbContext constructor'ı ICurrentUser/ICurrentTenant'ı hiç forward etmiyordu, (2) query filter'daki Expression.Constant(this) runtime tipini kullandığından private CurrentTenantId property'si türetilmiş tipte reflection ile bulunamıyordu (Expression.Constant(this, typeof(BaseForgeDbContext)) ile düzeltildi, birim testle doğrulandı). | ✅ |
| 2026-07-13 | Docker port/Authority doğruluğu eklendi (bkz. §7.1/7.2): ServiceRegistry'ye Postgres portu + public LoadForWorkspace; gRPC cross-service appsettings adresi artık gerçek (sağlayıcının kendi spec'inden veya identity kaydından okunan) portu kullanıyor — önceden hardcoded 8081'di, gerçek bir bağlantı hatasıydı. Designer artık yeni bir servis/identity açıldığında portları/Authority'yi workspace kaydından otomatik, çakışmayacak şekilde önceden dolduruyor (elle değiştirilebilir); Identity ve normal bir servis aynı oturumdan art arda üretildiğinde öneriler canlı güncelleniyor. | ✅ |
| 2026-07-16 | Transactional Outbox Pattern eklendi (bkz. §5.2): IEventBus artık RabbitMQ'ya doğrudan yazmıyor, OutboxEventBus olayı aynı SaveChangesAsync transaction'ında bir OutboxMessage satırı olarak yazıyor; OutboxPublisherHostedService (FOR UPDATE SKIP LOCKED ile çoklu-instance güvenli) bunu ayrı, güvenilir bir relay ile gerçek broker'a gönderiyor. DB commit ile RabbitMQ publish arasındaki dual-write/event-kaybı riski çözüldü (at-least-once garantisiyle). RabbitMqEventBus → RabbitMqPublisher/IRabbitMqPublisher olarak refactor edildi (wire format değişmedi). | ✅ |
| 2026-07-16 | Merkezi loglama (Serilog + Grafana Loki) + Correlation ID eklendi (bkz. §5.6): ICorrelationIdAccessor (AsyncLocal) HTTP middleware, gRPC client/server interceptor'ları ve RabbitMQ outbox/consumer arasında paylaşılıyor — bir isteğin üç sınır boyunca (HTTP/gRPC/RabbitMQ) aynı id ile loglanmasını sağlıyor. AddBaseForgeLogging (Host seviyesi, AddBaseForge'dan ayrı) her zaman konsola, Serilog:LokiUrl doluysa Loki'ye de yazıyor. Kök docker-compose.yml'a paylaşılan loki/grafana container'ları eklendi. Her zaman açık (RabbitMQ/JWT gibi opt-in bir spec toggle değil) — ServiceSpec/Designer UI'a dokunulmadı. | ✅ |
| 2026-09-26 | Rol + sahiplik tabanlı yetkilendirme modeli eklendi (bkz. §6.1): entity.access (anonymous / authenticated / rol listesi + owner), entity.ownerField, auth.defaultAccess, auth.superRoles; Identity'de roles ve varsayılan olarak kapalı registration (dış sağlayıcıyla ilk giriş de kayıt sayılır). Kararı controller verir, handler RestrictToOwnerId ile uygular — gRPC'nin kullanıcı bağlamsız servisler arası okumaları etkilenmez. EnableJwt artık MapInboundClaims=false + RoleClaimType="role". Geriye dönük uyumlu: access kullanmayan spec'ler birebir aynı controller'ları üretir. Aynı geçişte iki önceden var olan hata düzeltildi: yeni serviste wwwroot olmadığı için görsel yükleme 500 veriyordu; Identity admin panelindeki rol listesi Admin/User olarak sabitti. Yayın notu: üretilen kod yeni BaseController yardımcılarını kullandığı için generator ile birlikte BaseForge.API'nin yeni sürümü yayınlanmalı (CodeGenerator.BaseForgeVersion). | ✅ |
| 2026-09-26 | Kullanıcı profil alanları eklendi (bkz. §6.3): auth.yaml userProfile.props (editableBy: self|admin, inToken), alanlar ayrı tablo yerine doğrudan ApplicationUser (partial) üzerinde; user.proto auth.yaml'dan üretiliyor ve identity/User tüketicileri aynı proto'yu alıyor; mevcut DB'ye ADD COLUMN IF NOT EXISTS ile şema senkronu; Ortak Giriş SPA'sında metadata ucundan dinamik profil/admin formları. | ✅ |