Data Ownership ve Database per Service: Shared Database'den Neden Kaçınılır?
Shared database, hiçbir contract'ta görünmeyen bir coupling yaratır; her service'in verisi ona private olmalı ve başkaları bu veriye API composition, event'le replike edilen local kopya ya da CQRS read model ile ulaşmalı. SQL Server'da schema seviyesinde yetkiyle sınırı zorlamak, BookRenamed ile version kontrollü kopya, snapshot farkı, reference data ve raporlama için CDC.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'da Catalog ve Ordering ayrı service'lere ayrıldı, ama ikisi hâlâ aynı SQL (Structured Query Language) Server veritabanını kullanıyor. Ordering sipariş satırını oluştururken kitabın adını catalog.Books tablosundan okuyor; "sadece okuyorum, yazmıyorum, ne zararı olabilir" diye düşünülmüş. Aylık satış raporu da catalog.Books ile ordering.Orders'ı join'liyor.
Catalog ekibi, kitap adlarının alt başlığı da kapsaması için Title kolonunu DisplayTitle olarak yeniden adlandırıyor ve yalnız kendi service'ini deploy ediyor. Kim kırılır? Ne zaman fark edilir: deploy anında mı, gece raporu çalıştığında mı, bir müşteri boş isimli bir fatura aldığında mı? Ve Catalog ekibi, tablosunu kimin okuduğunu nereden bilebilirdi?
Shared Database: Gizli Coupling
Bir önceki yazıda Sam Newman'ın coupling türlerinden common coupling'i görmüştük: birden fazla service'in aynı veriyi paylaşması. Shared database bunun en yaygın hâli ve en sinsisi, çünkü hiçbir contract'ta görünmüyor. Catalog'un HTTP (Hypertext Transfer Protocol) API'sini (Application Programming Interface) kimin çağırdığını log'lardan, contract test'lerden, gateway'den görebilirsin; tablosunu kimin okuduğunu ise genelde ilk kırılmada öğrenirsin. Tablo yapısı, sürümü olmayan, dokümantasyonu olmayan ve hiç kimsenin "public" ilan etmediği bir API'ye dönüşüyor.
Chris Richardson'ın microservices.io'daki Database per Service pattern'i bu yüzden kuralı net koyuyor: bir service'in kalıcı verisi ona private'tır ve yalnız onun API'si üzerinden erişilir. Shared Database ise ayrı bir anti-pattern olarak, microservice'lerin bir veritabanını paylaşmasının yarattığı sorunları anlatan bir girdi olarak duruyor. Faydası açık: bir service'in veritabanındaki değişiklik diğerlerini etkilemiyor ve her service işine en uygun veritabanını (arama için Elasticsearch, ilişkisel veri için SQL Server) seçebiliyor. Bedeli de açık: birden fazla service'e yayılan iş işlemleri ve service'ler arası join'ler zorlaşıyor.

Shared database'in bir de zamanlama tarafı var. İki service aynı tablolara yazıyorsa, birinin uzun süren bir transaction'ı diğerinin lock beklemesine dönüşüyor; birinin kötü bir sorgusu, aynı veritabanı sunucusundaki herkesin latency'sini artırıyor. Ayrı process'ler ve ayrı deploy pipeline'ları, aynı veritabanı sunucusunda buluşunca yeniden birbirine bağlanıyor.
Private Table, Schema ya da Veritabanı
"Her service'in verisi private" kuralı, her service için ayrı bir veritabanı sunucusu kurmak anlamına gelmiyor. Richardson üç seviye sayıyor. Private-tables-per-service: her service'in kendi tabloları var ve yalnız o service onlara erişebiliyor; en düşük maliyetli seçenek. Schema-per-service: her service'in kendine ait bir schema'sı var; sahiplik daha net görünüyor. Database-server-per-service: her service'in ayrı bir veritabanı sunucusu var; yüksek trafikli service'ler için.
Seviyeyi ne seçersen seç, kuralı konvansiyona bırakmamak gerekiyor. "Kimse başka schema'yı okumasın" diye wiki'ye yazmak, kampanya haftasında bir kısayolu durdurmaz. SQL Server'da her service'e ayrı bir database user'ı verip yetkiyi schema seviyesinde tanımlamak, kuralı veritabanının kendisine emanet etmek demek:
CREATE SCHEMA catalog;
GO
CREATE SCHEMA ordering;
GO
CREATE TABLE catalog.Books (Id uniqueidentifier PRIMARY KEY, Isbn char(14) NOT NULL, Title nvarchar(200) NOT NULL, ListPrice decimal(10,2) NOT NULL);
CREATE TABLE ordering.Orders (Id uniqueidentifier PRIMARY KEY, CustomerId uniqueidentifier NOT NULL, Status varchar(20) NOT NULL);
CREATE TABLE ordering.BookReplicas (BookId uniqueidentifier PRIMARY KEY, Title nvarchar(200) NOT NULL, Version bigint NOT NULL);
GO
/* Her service'in kendi database user'ı var ve yalnız kendi schema'sına yetkisi var */
CREATE USER catalog_svc WITHOUT LOGIN;
CREATE USER ordering_svc WITHOUT LOGIN;
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::catalog TO catalog_svc;
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::ordering TO ordering_svc;
GO
Şimdi Ordering'in kimliğiyle iki sorgu çalıştıralım: kendi tablosuna ve açılıştaki "sadece okuyorum" kısayoluna:
USE ms_04;
EXECUTE AS USER = 'ordering_svc';
SELECT COUNT(*) AS Orders FROM ordering.Orders;
/* "Sadece okuyorum" kısayolu */
SELECT Title, ListPrice FROM catalog.Books WHERE Isbn = '978-0441013593';
REVERT;
Orders
-----------
0
(1 rows affected)
Msg 229, Level 14, State 5, Server b8be943222fa, Line 5
The SELECT permission was denied on the object 'Books', database 'ms_04', schema 'catalog'.
Ordering kendi tablosunu okuyabiliyor, Catalog'un tablosunu okuyamıyor. Açılıştaki kısayol artık bir code review yorumuyla değil, ilk denemede Msg 229 hatasıyla duruyor. Gerçek bir ortamda bu user'lar WITHOUT LOGIN değil, her service'in kendi login'ine ya da bulutta managed identity'sine bağlanır; ama ilke aynı: connection string'deki kimlik, service'in sahip olduğu veriden fazlasını göremez. Bu, ilerideki Modular Monolith serisinde module'ler için uygulanacak tekniğin de aynısı; fark, microservice'lerde bu sınırın bir de deployment sınırıyla örtüşmesi.
.NET'in referans uygulaması dotnet/eShop'ta bu kararın pragmatik bir hâli görülüyor. Aspire AppHost'u tek bir PostgreSQL sunucusu tanımlıyor ve üzerinde catalogdb, orderingdb, identitydb ve webhooksdb adlı ayrı veritabanları açıyor; Catalog API'si yalnız catalogdb'ye, Ordering API'si yalnız orderingdb'ye referans veriyor. Dikkat çekici bir ayrıntı da var: arka planda çalışan OrderProcessor da orderingdb'ye bağlı. Bu, kuralın ihlali değil; ikisi aynı bounded context'in, aynı ekibin iki ayrı deployable'ı. Sahiplik service başına değil, bounded context ve ekip başına düşünülmeli.
Başka Service'in Verisine İhtiyaç Duyduğunda
Veriyi private yapınca hemen bir soru geliyor: Ordering kitabın adına ihtiyaç duyuyorsa ne yapacak? Üç yol var ve her birinin bedeli farklı.
API composition: ihtiyaç anında sahibine sor. Ordering sepet sayfasını hazırlarken Catalog'un API'sinden kitap bilgilerini alır ve kendi verisiyle birleştirir. Richardson'ın tarifinde birleştirmeyi uygulama ya da bir API gateway yapıyor. Basit ve her zaman güncel, ama Catalog ayakta değilse sepet sayfası da yok; bir sayfada 40 kitap varsa ya 40 çağrı ya da toplu bir endpoint gerekiyor. Okuma sıklığı düşük, tazelik ihtiyacı yüksek veride doğru seçim.
Event'le replike edilen local kopya: sahibi değişikliği event olarak yayınlar, ihtiyaç duyan kendi veritabanında kendi kopyasını tutar. Catalog BookRenamed ve BookPriceChanged yayınlar, Ordering bunları dinleyip BookReplicas tablosunu günceller. Ordering artık sipariş verirken Catalog'a hiç gitmiyor; Catalog'un deploy edilmesi ya da çökmesi checkout'u etkilemiyor. Bedeli eventual consistency: kopya bir süre eski kalabilir. Messaging ve Event-Driven Architecture serisinin "Message Türleri: Command, Event ve Query; Event Notification ve Event-Carried State Transfer" yazısında bu yaklaşımın adı event-carried state transfer'dı.
CQRS (Command Query Responsibility Segregation) read model: birden fazla service'in verisini birleştiren, yalnız okuma için tasarlanmış ayrı bir görünüm. Richardson'ın ikinci seçeneği bu: service'lerin event'lerine subscribe olan bir projector materialized view'ı günceller. "Müşterinin sipariş geçmişi, kitap adları ve kargo durumlarıyla" gibi birçok service'i kesen ekranlar için uygun. Nasıl kurulacağı CQRS ve Event Sourcing serisinin "Ayrı Read Model: Materialized View, Senkronizasyon ve Eventual Consistency ile Yaşamak" yazısında.
Ordering'in Kitap Kopyası: BookRenamed
Ortadaki seçeneği kodla görelim. Catalog'un yayınladığı event bir Version taşıyor; kitabın Catalog'daki her değişikliği bu sayıyı artırıyor:
namespace Bookstore.Catalog.Contracts;
// Catalog'un yayınladığı event: Version, kitabın Catalog'daki sürümü
public sealed record BookRenamed(Guid BookId, string Title, long Version);
using Bookstore.Catalog.Contracts;
namespace Bookstore.Ordering;
// Ordering'in kendi veritabanındaki kopya: yalnız Ordering'in ihtiyaç duyduğu property'ler
public sealed record BookReplica(Guid BookId, string Title, long Version);
// Sipariş satırı, sipariş anındaki adı snapshot olarak saklar
public sealed record OrderItem(Guid BookId, string Title, decimal UnitPrice, int Quantity);
public sealed class BookReplicaStore
{
private readonly Dictionary<Guid, BookReplica> _books = [];
public void Seed(BookReplica book) => _books[book.BookId] = book;
public BookReplica Get(Guid bookId) => _books[bookId];
// Aynı event iki kez ya da eski bir event geç gelirse kopya bozulmasın
public string Apply(BookRenamed e)
{
if (_books.TryGetValue(e.BookId, out BookReplica? current) && current.Version >= e.Version)
return $"v{e.Version} ignored (replica already at v{current.Version})";
_books[e.BookId] = new BookReplica(e.BookId, e.Title, e.Version);
return $"v{e.Version} applied -> \"{e.Title}\"";
}
}
Broker'lar message'ları genelde at-least-once teslim eder: aynı event iki kez gelebilir, iki event'in sırası karışabilir. Kopyayı kuran handler bu yüzden yalnız kendisininkinden daha yeni bir sürümü uygular:
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Guid duneId = Guid.Parse("0199f0c4-5c1e-7000-8000-000000000001");
BookReplicaStore replicas = new();
replicas.Seed(new BookReplica(duneId, "Dune", Version: 1));
// Sipariş verilirken Catalog'a gidilmiyor: ad local kopyadan okunuyor
OrderItem item = new(duneId, replicas.Get(duneId).Title, 12.50m, 2);
// Broker at-least-once teslim eder: duplicate ve sırası karışmış event'ler normal
BookRenamed[] delivered =
[
new(duneId, "Dune (Deluxe Edition)", 2),
new(duneId, "Dune (Deluxe Edition)", 2),
new(duneId, "Dune: Deluxe Hardcover", 4),
new(duneId, "Dune (Anniversary Edition)", 3)
];
foreach (BookRenamed e in delivered)
Console.WriteLine($"BookRenamed {replicas.Apply(e)}");
Console.WriteLine($"Replica title : {replicas.Get(duneId).Title}");
Console.WriteLine($"Placed order item: {item.Title} x{item.Quantity} @ {item.UnitPrice}");
BookRenamed v2 applied -> "Dune (Deluxe Edition)"
BookRenamed v2 ignored (replica already at v2)
BookRenamed v4 applied -> "Dune: Deluxe Hardcover"
BookRenamed v3 ignored (replica already at v4)
Replica title : Dune: Deluxe Hardcover
Placed order item: Dune x2 @ 12.50
Çıktıda iki şey var. Birincisi kopyanın kendisi: duplicate v2 ve geç gelen v3 yok sayıldı, kopya en yeni ad olan v4'te kaldı. Version kontrolü olmasaydı, kopya son gelen event'in adını, yani daha eski olan "Anniversary Edition"ı gösterecekti. Bu, Messaging serisinin "Idempotent Consumer: Aynı Message İki Kez Gelirse Ne Olur?" yazısındaki tekniğin replikasyondaki karşılığı; gerçek bir veritabanında aynı kontrol UPDATE ... WHERE Version < @version şeklinde tek bir sorguyla yapılır.
İkincisi, sipariş satırı. Ada'nın siparişi kitap "Dune" iken verildi ve satırdaki ad, kopya değiştikten sonra da "Dune" olarak kaldı. Bu bir hata değil, bilinçli bir tasarım: DDD (Domain-Driven Design) serisindeki OrderItem, sipariş anındaki adı ve fiyatı snapshot olarak tutuyor. Faturada, siparişin verildiği gün müşterinin gördüğü şey yazmalı. Kopya ile snapshot iki farklı soruya cevap veriyor: kopya "bu kitabın şu anki adı ne?", snapshot "müşteri ne satın aldı?". İkisini karıştırmak, eski faturaların bir gece sessizce değişmesine yol açar.
Kopya tutmanın da sınırları var. Ordering yalnız ihtiyaç duyduğu property'leri tutmalı (ad ve fiyat, açıklama ya da yazar biyografisi değil). Kopyanın kaynağı her zaman tektir: Ordering kendi kopyasındaki adı değiştirmez, değişiklik ancak Catalog'dan gelir. Ve kopyanın baştan kurulabilmesi gerekir: yeni bir service eklendiğinde ya da kopya bozulduğunda Catalog'dan toplu bir okuma ile yeniden doldurulabilmeli.
Reference Data
Ülke listesi, para birimleri, kitap kategorileri, KDV (Katma Değer Vergisi) oranları: birçok service'in ihtiyaç duyduğu ama nadiren değişen veriye reference data deniyor. "Herkes kullanıyor" diye bunu ortak bir tabloya koymak, shared database'i en masum görünen kapıdan geri getirir. Pratikte üç yol var. Gerçekten statik olan veri (ISO (International Organization for Standardization) 4217 para birimi kodları gibi) her service'te bir enum ya da configuration olarak durabilir; değişikliği yılda bir kez ve bir standarda bağlı. Bir iş sahibi olan ve değişen veri (kategoriler Catalog'un, KDV oranları muhasebe kurallarının) o veriyi sahiplenen service'te durur ve diğerlerine yukarıdaki kopya yöntemiyle dağıtılır. Sık sorgulanan ama sahibi belirsiz veri için ise önce bir sahip bulmak gerekir; sahipsiz reference data, ortak "Common" library'sinin veritabanındaki karşılığıdır.
Raporlama ve Analitik İçin Ayrı Yol
Açılıştaki aylık satış raporu, database per service'e geçen ekiplerin en sık takıldığı yer. Rapor Catalog ile Ordering'i join'lemek istiyor ve iki veritabanı artık ayrı. Cevap, raporlamaya service veritabanlarına bağlanma izni vermek değil, ona kendi veri yolunu açmak. İki yaygın yöntem var. CDC (Change Data Capture): veritabanının transaction log'undaki değişiklikleri okuyup bir analitik depoya aktarmak. SQL Server'ın kendi CDC özelliği ve Debezium gibi araçlar bunu yapar; service'in koduna dokunmadan çalışır, ama tablo yapısını raporlamaya açtığı için şema değişikliklerinde dikkat ister. Event stream: service'lerin zaten yayınladığı integration event'leri bir data platformunda (data warehouse, data lake) toplamak. Contract'lı ve sürümlü olduğu için daha kararlı, ama yalnız yayınlanan bilgiyi içerir.
İki durumda da raporlama sorguları operasyonel veritabanlarına yük bindirmez ve Catalog ekibi bir kolonu yeniden adlandırdığında raporu kırmaz; en kötü ihtimalle CDC hattındaki bir mapping güncellenir. Bookstore ölçeğinde bu bir data warehouse projesi olmak zorunda değil: gece çalışan ve event'lerden beslenen tek bir raporlama veritabanı başlangıç için yeterli.
Sık Düşülen Tuzaklar
"Sadece okuyorum" demek. Okumak da bir bağımlılık. Tablosunu okuduğun service onu değiştirdiğinde sen kırılırsın ve bunu kimse önceden bilemez. Okuma ihtiyacı varsa API'den, kopyadan ya da read model'den karşılanmalı.
Ayrı veritabanı, ortak tablo. Service'leri ayırıp bir "shared" schema'da ortak tablolar bırakmak, common coupling'i tek bir yere toplamaktan başka bir şey değil. Her tablonun tek bir sahibi olmalı.
Kopyayı sahibinden bağımsız değiştirmek. Ordering kopyadaki adı "düzeltirse", iki gerçeklik oluşur. Kopya salt okunur bir projeksiyondur; düzeltme kaynakta yapılır.
Version'suz replikasyon. Event'leri geldiği sırayla uygulamak, at-least-once teslim ve paralel consumer'larla kopyanın eski bir değerde takılmasına yol açar. Her event bir sürüm ya da zaman damgası taşımalı.
Snapshot ile kopyayı karıştırmak. Siparişte görünmesi gereken sipariş anındaki değerdir, kitabın bugünkü adı değil.
Ne Zaman Kullanma
Database per service her durumda zorunlu değil. Aynı bounded context'in aynı ekibe ait iki deployable'ı (eShop'taki Ordering API ve OrderProcessor gibi) aynı veritabanını paylaşabilir; bu bir service sınırı değil, bir deployment kararı. Monolith'ten geçiş sırasında bir süre ortak veritabanı kaçınılmaz olabilir; bu durumda hangi tablonun hangi service'e ait olduğunu yazılı hâle getirip yetkileri yukarıdaki gibi schema seviyesinde daraltmak, kalıcı bir shared database'e dönüşmesini engeller. Monolith'ten veri ayırmanın sırası serinin son yazısının konusu. Ve sistem tek bir ekibin elindeyse, belki de henüz ayrı service'lere değil, schema'ları ayrılmış bir modular monolith'e ihtiyacın var.
Küçük Bir Deneme
★☆☆ Yazıdaki SQL'i kendi SQL Server'ında çalıştır. Sonra catalog_svc kimliğiyle ordering.Orders'a bir INSERT dene. Hangi hata kodunu alıyorsun? GRANT satırlarından birini DENY ile değiştirmek neyi farklılaştırır?
★★☆ BookReplicaStore'a BookPriceChanged(BookId, ListPrice, Version) desteği ekle. Ad ve fiyat aynı Version sayacını paylaşıyorsa, sırası karışmış bir BookRenamed ve BookPriceChanged çiftinde ne olur? Sayacı property başına mı, kitap başına mı tutmak gerekir?
★★★ Çalıştığın sistemde bir veritabanını seç ve her tablo için "kim yazıyor, kim okuyor" listesi çıkar (SQL Server'da bir Extended Events oturumu ve uygulama log'ları yardımcı olur). Birden fazla service'in yazdığı tablolar için bir sahip belirle; diğer okuyucular için API, kopya ve read model seçeneklerinden hangisinin uygun olduğunu gerekçesiyle yaz.
Veriyi ayırınca bir soru daha çıkıyor: sipariş, stok ve ödeme artık üç ayrı veritabanında; üçünü birlikte tutarlı tutan bir transaction yok. Bir sonraki yazıda neden distributed transaction kullanmadığımızı, tutarlılık gerektiren kuralın nerede yaşaması gerektiğini, saga'ya karar seviyesinde bakışı ve eventual consistency'nin kullanıcı arayüzünde nasıl gösterileceğini ele alıyoruz.