Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Modular Monolith — #06

Module Başına Veri İzolasyonu: EF Core ile Schema per Module ve Cross-Module Sorgular

Mert Özen 7 Kas 2026 10 dk 4 görüntülenme
Module Başına Veri İzolasyonu: EF Core ile Schema per Module ve Cross-Module Sorgular

Her module kendi DbContext'iyle tablolarını HasDefaultSchema ile kendi schema'sına koyar, migration geçmişini MigrationsHistoryTable ile kendi schema'sında tutar ve veritabanında yalnız kendi schema'sına yetkili bir kullanıcıyla çalışır. Ordering kullanıcısının Catalog'a join'inin 229 hatasıyla reddedildiğini gösteriyor, aynı raporu contract çağrısı, event ile tutulan read model ve raporlama view'leriyle izolasyonu delmeden üretiyoruz.

Sürüm notu: Bu yazıdaki kodlar C# 14, .NET 10, Entity Framework Core (EF Core) 10 ve SQL (Structured Query Language) Server 2025 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un module'leri artık kendi DbContext'leriyle çalışıyor ve birbirlerinin entity'lerini derleyici izin vermediği için göremiyorlar. Ama hepsi aynı SQL Server veritabanında. Finans ekibinden biri "kitap başına satış adedi ve ciro" raporu istiyor ve Ordering'i yazan developer şu sorguyu yazıyor:

SELECT b.Title, SUM(i.Quantity) AS Copies
FROM ordering.OrderItems i
JOIN catalog.Books b ON b.Id = i.BookId
GROUP BY b.Title;

C# tarafındaki bütün sınırlar bu tek satırla delindi mi? Catalog ekibi gelecek ay Books.Title kolonunu bölüp Title ve Subtitle yaparsa bu rapora ne olur ve bunu kim fark eder? Ve veritabanının kendisi bu sorguyu çalıştırmayı reddedebilir mi?

İzolasyon Seviyeleri

Module'lerin verisini ayırmanın tek bir yolu yok; artan izolasyon ve artan maliyetle sıralanmış birkaç seviye var:

SeviyeNasılKazançBedel
Ortak tablolar, sahiplik listesiAynı schema, "bu tabloya kim yazar" dokümanıHiçbir altyapı değişikliği yokSınır yalnız disiplinle korunur
Schema per moduleModule başına schema, DbContext ve migration geçmişiSahiplik görünür, yetki schema seviyesinde verilebilir, tek veritabanı ve tek yedekAynı sunucunun kaynakları paylaşılır, cross-schema join teknik olarak hâlâ mümkün
Database per moduleModule başına veritabanı (aynı ya da ayrı sunucu)Bağımsız yedek, ölçek ve teknoloji seçimi; service'e çıkarmaya en yakın hâlModule'ler arası transaction ve join yok, işletim maliyeti artar

Bookstore için seçim schema per module. Ekip küçük, tek bir SQL Server işletiliyor ve independent deployability "şimdilik değil". Schema per module, sahipliği veritabanında görünür kılıyor ve bir module'ü günü geldiğinde ayrı bir veritabanına taşımayı mekanik bir iş hâline getiriyor.

Module Başına DbContext ve HasDefaultSchema

Her module'ün kendi DbContext'i var ve HasDefaultSchema ile bütün tablolarını kendi schema'sına koyuyor. Catalog'un bu yazıdaki hâli:

internal sealed class CatalogDbContext(DbContextOptions<CatalogDbContext> options) : DbContext(options)
{
    public const string Schema = "catalog";

    public DbSet<Book> Books => Set<Book>();
    public DbSet<BookSales> Sales => Set<BookSales>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema(Schema);
        modelBuilder.Entity<Book>().Property(b => b.ListPrice).HasPrecision(10, 2);
        modelBuilder.Entity<BookSales>().HasKey(s => s.BookId);
        modelBuilder.AddInbox();
    }
}

DbContext internal; başka bir module onu inject edemez, Schema sabiti de dışarıdan görünmez. Fluent API (Application Programming Interface) ile yapılan konfigürasyonun tamamı burada module'ün içinde kalıyor; bu konfigürasyonu EF Core serisinin "EF Core'da Data Annotation mı Fluent API mı? OnModelCreating ve IEntityTypeConfiguration" yazısında ayrıntılı konuşmuştuk. Outbox ve inbox tabloları da AddOutbox()/AddInbox() ile aynı schema'ya düşüyor, çünkü onlar da module'ün verisi.

Module Başına Migrations History Tablosu

EF Core uygulanan migration'ları varsayılan olarak __EFMigrationsHistory adlı tek bir tabloda tutar. Beş DbContext aynı veritabanına migration uyguladığında, hepsi aynı tabloya, varsayılan schema'daki (dbo) tabloya yazar. Microsoft Learn'deki "Custom Migrations History Table" sayfası bu tablonun adını ve schema'sını MigrationsHistoryTable() ile değiştirmeyi gösteriyor. Her module kendi history tablosunu kendi schema'sında tutuyor:

public static IServiceCollection AddOrderingModule(this IServiceCollection services, string connectionString, bool useOutbox)
{
    services.AddDbContext<OrderingDbContext>(o => o.UseSqlServer(connectionString,
        sql => sql.MigrationsHistoryTable("__EFMigrationsHistory", OrderingDbContext.Schema)));
    services.AddSingleton(new OrderingOptions(useOutbox));
    services.AddScoped<PlaceOrderHandler>();
    services.AddIntegrationEvent<OrderPlaced>();
    if (useOutbox)
        services.AddOutboxProcessor<OrderingDbContext>(TimeSpan.FromSeconds(5));
    return services;
}

public static Task InitializeOrderingModuleAsync(this IServiceProvider services) =>
    services.MigrateModuleAsync<OrderingDbContext>();

Migration'ları da her module kendi project'inde üretiyor. EF Core serisinin "EF Core Migrations: migrations add, database update, Script ve Bundle" yazısındaki komutlar, --context ve --project ile module başına çalıştırılıyor; dotnet-ef aracını global değil, solution klasöründeki tool manifest'iyle local olarak kurduk:

# Her module'ün migration'ları kendi project'inde, kendi context'iyle
for m in Catalog Inventory Ordering Shipping Payments; do
  dotnet ef migrations add Initial \
    --project src/Modules/$m/Bookstore.$m.Core \
    --startup-project src/Bookstore.Host \
    --context ${m}DbContext \
    --output-dir Persistence/Migrations
done

Üretilen migration schema'yı da kendisi oluşturuyor:

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.EnsureSchema(
        name: "catalog");

    migrationBuilder.CreateTable(
        name: "Books",
        schema: "catalog",
        columns: table => new
        {
            Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
            Isbn = table.Column<string>(type: "nvarchar(max)", nullable: false),
            Title = table.Column<string>(type: "nvarchar(max)", nullable: false),
            ListPrice = table.Column<decimal>(type: "decimal(10,2)", precision: 10, scale: 2, nullable: false),
            Currency = table.Column<string>(type: "nvarchar(max)", nullable: false)
        },
        constraints: table =>
        {
            table.PrimaryKey("PK_Books", x => x.Id);
        });

Host başlarken her module MigrateAsync ile yalnız kendi migration'larını uyguluyor. Bir sipariş verdikten sonra veritabanındaki tablolar:

schema table
------ -----
catalog __EFMigrationsHistory
catalog Books
catalog ProcessedMessages
catalog Sales
inventory __EFMigrationsHistory
inventory ProcessedMessages
inventory Reservations
inventory StockItems
ordering __EFMigrationsHistory
ordering OrderItems
ordering Orders
ordering OutboxMessages
payments __EFMigrationsHistory
payments Payments
payments ProcessedMessages
shipping __EFMigrationsHistory
shipping ProcessedMessages
shipping Shipments

Her schema'nın kendi __EFMigrationsHistory tablosu var ve Ordering'inki yalnız Ordering'in migration'larını biliyor:

MigrationId
-----------
20261108002704_Initial
20261108002759_ReportingView

Tek bir ortak history tablosu çalışır, ama iki soruna yol açar. Birincisi, dbo schema'sı hiçbir module'ün değil; birazdan module'lere yalnız kendi schema'larında yetki vereceğiz ve migration'ları uygulayan kullanıcının ortak bir tabloya yazma ihtiyacı bu ayrımı deler. İkincisi, bir module'ü ileride ayrı bir veritabanına taşırken onun migration geçmişini ortak tablodan ayıklaman gerekir. Aynı sayfanın uyarısını da not et: history tablosunu migration'lar uygulandıktan sonra değiştirirsen, var olan tabloyu güncellemek sana kalıyor. Var olan bir sistemde bu ayarı açmadan önce eski kayıtları yeni tabloya taşıyan bir script hazırla.

Sınırı Veritabanında Zorlamak: Schema Yetkileri

Schema'ların asıl gücü, SQL Server'ın yetki modeline doğrudan oturmaları. Her module'e kendi veritabanı kullanıcısını verip yetkiyi schema seviyesinde tanımladık:

/* Her module'ün uygulama kullanıcısı yalnız kendi schema'sına yazar */
CREATE USER catalog_app WITHOUT LOGIN;
CREATE USER ordering_app WITHOUT LOGIN;
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::catalog TO catalog_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::ordering TO ordering_app;
GO

Açılıştaki sorguyu Ordering'in kullanıcısıyla çalıştırdık:

/* Ordering'in kullanıcısı kendi tablosunu okuyabilir */
EXECUTE AS USER = 'ordering_app';
SELECT COUNT(*) AS MyOrders FROM ordering.Orders;
/* ... ama Catalog'un tablosuna join atamaz */
SELECT i.Quantity, b.Title
FROM ordering.OrderItems i JOIN catalog.Books b ON b.Id = i.BookId;
REVERT;
MyOrders
--------
1

(1 rows affected)
Msg 229, Level 14, State 5, Line 5
The SELECT permission was denied on the object 'Books', database 'mm06', schema 'catalog'.

Ordering kendi tablosunu okuyor ama Catalog'un tablosuna join atamıyor; sınır artık yalnız derleyicide değil, veritabanında da var. Bunun çalışması için modular monolith'in her module'e kendi connection string'ini verebilmesi gerekiyor. AddOrderingModule(connectionString, ...) imzası bu yüzden module başına bir parametre alıyor: bugün beşi aynı kullanıcıyı gösterse de, yarın her biri kendi kullanıcısını gösterebilir. Migration'ları uygulayan kullanıcı ayrı tutulmalı; ona da yalnız kendi schema'sında ALTER ve veritabanında CREATE TABLE yetkisi yeter.

Cross-Module Join Yerine Ne Yapmalı?

Raporun ihtiyacı gerçek; yasak olan yalnız yöntemi. Üç alternatif var ve hangisinin seçileceği sorunun türüne bağlı.

Birincisi, 04. yazıdaki contract çağrısı. Bir ekranın tek bir siparişin kitap başlıklarına ihtiyacı varsa Ordering, Catalog'un GetBooksAsync'ini çağırır. Etkileşimli, küçük hacimli okumalar için doğru seçim.

İkincisi, event ile tutulan yerel bir read model. Catalog "çok satanlar" listesini göstermek istiyorsa Ordering'in tablosuna gitmek yerine, bir önceki yazıdaki OrderPlaced event'ini dinleyip satış adetlerini kendi schema'sında tutabilir:

using Bookstore.BuildingBlocks.Infrastructure.Messaging;
using Bookstore.BuildingBlocks.Messaging;
using Bookstore.Ordering.Contracts;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Catalog.Core;

// "Çok satanlar" için Ordering'in tablosuna join yok: event'ten yerel read model
internal sealed class UpdateSalesWhenOrderPlaced(CatalogDbContext db, TimeProvider clock) : IIntegrationEventHandler<OrderPlaced>
{
    public async Task HandleAsync(OrderPlaced @event, CancellationToken ct)
    {
        if (await db.AlreadyProcessedAsync(@event.EventId, nameof(UpdateSalesWhenOrderPlaced), ct))
            return;

        foreach (OrderPlacedLine line in @event.Lines)
        {
            BookSales? sales = await db.Sales.FindAsync([line.BookId], ct);
            if (sales is null)
                db.Sales.Add(new BookSales { BookId = line.BookId, CopiesSold = line.Quantity });
            else
                sales.CopiesSold += line.Quantity;
        }
        db.MarkProcessed(@event.EventId, nameof(UpdateSalesWhenOrderPlaced), clock.GetUtcNow().UtcDateTime);
        await db.SaveChangesAsync(ct);
    }
}
Title CopiesSold
----- ----------
Classic #1 2
Classic #2 1

Bu tablo Catalog'un verisi; Ordering'in kolon adları değişse de etkilenmez, çünkü Catalog yalnız event'in contract'ını tanıyor. Bedeli eventual consistency: sipariş verildikten birkaç saniye sonra güncelleniyor. "Çok satanlar" listesi için bu fazlasıyla yeterli. CQRS (Command Query Responsibility Segregation) ve Event Sourcing serisi read model'leri bu açıdan ayrıntılı ele alıyor.

Üçüncüsü, raporlama için module'lerin yayınladığı view'ler. Finans raporu birden fazla module'ün verisini büyük hacimde birleştiriyor ve gerçekten SQL'e ihtiyaç duyuyor. Burada çözüm, her module'ün raporlamaya kendi migration'ıyla bir view yayınlaması ve bu view'in kolonlarının o module'ün raporlama contract'ı olması:

using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace Bookstore.Ordering.Core.Persistence.Migrations
{
    /// <inheritdoc />
    public partial class ReportingView : Migration
    {
        // Ordering'in raporlama contract'ı: sipariş satırları, iç kolon adlarından bağımsız
        protected override void Up(MigrationBuilder migrationBuilder) =>
            migrationBuilder.Sql("""
                CREATE VIEW ordering.OrderLinesForReporting AS
                SELECT o.Id AS OrderId, o.PlacedAt, i.BookId, i.Quantity, i.UnitPrice
                FROM ordering.Orders o JOIN ordering.OrderItems i ON i.OrderId = o.Id;
                """);

        protected override void Down(MigrationBuilder migrationBuilder) =>
            migrationBuilder.Sql("DROP VIEW ordering.OrderLinesForReporting;");
    }
}

Catalog da aynı şekilde catalog.BooksForReporting view'ini yayınlıyor. Raporlama kendi schema'sında bu iki view'i birleştiriyor ve raporlama kullanıcısı yalnız o schema'yı okuyabiliyor:

/* Raporlama: module'lerin yayınladığı view'leri birleştiren ayrı, salt okunur bir schema */
CREATE SCHEMA reporting;
GO
CREATE VIEW reporting.SalesByTitle AS
SELECT b.Title, SUM(l.Quantity) AS Copies, SUM(l.Quantity * l.UnitPrice) AS Revenue
FROM ordering.OrderLinesForReporting l
JOIN catalog.BooksForReporting b ON b.BookId = l.BookId
GROUP BY b.Title;
GO
CREATE USER reporting_reader WITHOUT LOGIN;
GRANT SELECT ON SCHEMA::reporting TO reporting_reader;
EXECUTE AS USER = 'reporting_reader';
SELECT Title, Copies, Revenue FROM reporting.SalesByTitle ORDER BY Title;
SELECT TOP 1 * FROM ordering.Orders;
REVERT;
Title Copies Revenue
----- ------ -------
Classic #1 2 20.00
Classic #2 1 10.00

(2 rows affected)
Msg 229, Level 14, State 5, Line 3
The SELECT permission was denied on the object 'Orders', database 'mm06', schema 'ordering'.

Rapor çalışıyor, raporlama kullanıcısı module tablolarını doğrudan okuyamıyor. View'ler aynı sahibe (dbo) ait schema'larda durduğu için SQL Server'ın ownership chaining'i alttaki tablolar için ayrıca yetki istemiyor. Catalog ekibi Title kolonunu bölerse kendi BooksForReporting view'ini de aynı migration'da güncellemek zorunda; raporlama sorgusu değişmiyor. Kırılmayı fark edecek kişi artık rapor sahibi değil, değişikliği yapan module'ün kendisi.

Module'ler Arası Tek Transaction İstemek

Aynı veritabanında olduğumuz sürece iki module'ün DbContext'ini aynı DbConnection ve aynı transaction üzerinde çalıştırmak teknik olarak mümkün; EF Core serisinin "EF Core'da Transaction'lar: SaveChanges, BeginTransaction ve Savepoint'ler" yazısındaki UseTransaction bunu sağlıyor. Ama bu ihtiyaç çoğu zaman bir tasarım sinyali. İki module'ün verisinin aynı anda, atomik olarak tutarlı olması gerekiyorsa, ya bu iki veri aynı invariant'ın parçası ve sınır yanlış çizilmiş, ya da tutarlılık aslında birkaç saniye gecikebilir ve bir event yeter. Sipariş ile stok rezervasyonu ikinci türden: 05. yazıda gördüğümüz gibi outbox ve idempotent handler'larla güvenle ayrılabiliyorlar. Module'ler arası tek transaction, bugün çalışan ama module'ü ayrı bir veritabanına taşıdığın gün imkânsız hâle gelen bir bağımlılık.

Database per Module'e Geçmek

Schema per module'den database per module'e geçiş, bu yapıda mekanik bir adım: module'ün connection string'i başka bir veritabanını gösterir, migration'ları orada uygulanır, verisi taşınır. Kod tarafında değişmesi gereken tek şey, o module'ün tablolarına başka bir module'den hâlâ erişen bir şey varsa onu bulmak. Schema yetkileri bunu zaten engelliyorsa, cevap "hiçbir şey". Raporlama view'leri istisna: farklı veritabanlarındaki view'leri birleştirmek için ya bir veri ambarı ya da event'lerle beslenen ayrı bir raporlama veritabanı gerekir. Bu geçişin ne zaman anlamlı olduğu 08. yazının konusu.

Sık Düşülen Tuzaklar

Tek DbContext'te bütün entity'ler

Module'ler kodda ayrı, ama BookstoreDbContext hepsinin entity'lerini biliyor. Bu context'i kullanan her kod her tabloya erişebilir ve EF Core'un navigation property'leri module'ler arasında sessizce join üretir. Module başına context, sınırın veritabanı tarafındaki ilk adımı.

Raporlama yüzünden izolasyonu delmek

"Raporlama kullanıcısına bütün schema'larda SELECT verelim." Bu karar verildiği gün, her module'ün tablosu raporlamanın contract'ı olur ve hiçbir module kolonunu rahatça değiştiremez. Raporlamaya module'lerin yayınladığı view'ler ya da event'lerle beslenen ayrı bir model sun.

Foreign key ile module'leri bağlamak

ordering.OrderItems.BookId'ye catalog.Books.Id'yi gösteren bir foreign key eklemek veritabanı seviyesinde güzel görünür ama Catalog'un kitap silme ya da taşıma işlemini Ordering'e bağlar ve bir module'ü ayrı bir veritabanına taşımayı imkânsız kılar. Module'ler arası referanslar id ile, foreign key'siz tutulur; doğrulama contract çağrısıyla yapılır.

Ortak history tablosunu sonradan ayırmak

MigrationsHistoryTable'ı uygulama production'dayken açarsan, EF Core yeni tabloyu boş görür ve bütün migration'ları baştan uygulamaya çalışır. Ayarı açmadan önce eski kayıtları yeni tablolara kopyala.

Küçük Bir Deneme

★☆☆ catalog_app kullanıcısıyla ordering.Orders tablosuna bir satır eklemeyi dene. Hangi hata kodunu alıyorsun? Sonra catalog.Sales tablosuna yazmayı dene.

★★☆ Catalog'un Books tablosundaki Title kolonunu Title ve Subtitle olarak bölen bir migration yaz. Aynı migration'da BooksForReporting view'ini, raporlama sorgusu değişmeyecek şekilde güncelle ve reporting.SalesByTitle'ın aynı sonucu verdiğini doğrula.

★★★ Inventory module'ünü ayrı bir veritabanına (mm06_inventory) taşı: yalnız connection string'ini değiştir, migration'ını orada uygula ve veriyi taşıyan bir script yaz. Uygulamanın başka hangi parçası değişmek zorunda kaldı? Cevap "hiçbiri" değilse, sınır nerede delinmişti?

Bu yazıda module'lerin verisini kendilerine ait kıldık ve sınırı veritabanında da zorladık. Bir sonraki yazıda bu sınırların zamanla aşınmasını önleyen otomatik bekçileri kuruyoruz: ArchUnitNET ile module kuralları, bir MSBuild kontrolü, CODEOWNERS ve bir module'ü tek başına ayağa kaldıran integration test'ler.