Uçtan Uca: Bookstore Şemasını Sıfırdan Tasarlamak ve Şema Review Checklist'i
İyi bir şema, requirement cümlelerindeki kuralların constraint olarak yazıldığı şemadır; kuralların çoğu cümlelerin arasında saklıdır. Ön sipariş, kupon ve stok rezervasyonu requirement'larından entity listesi, Mermaid ER diyagramı, closure ile BCNF kontrolü ve SQL Server DDL'ine gidiyor, constraint'leri iş kurallarına karşı test ediyor, açıklamaları şemada tutuyor, EF Core migration'ını review ediyor ve bir şema review checklist'i çıkarıyoruz.
Sürüm notu: Bu yazıdaki SQL (Structured Query Language) örnekleri SQL Server 2025 (17.0, Cumulative Update 10) üzerinde, C# örneği .NET 10 ve Entity Framework Core (EF Core) 10.0.15 ile, PostgreSQL notundaki örnek PostgreSQL 18.6 üzerinde çalıştırılmış; Mermaid diyagramı Mermaid 12.3.0 ile çizdirilerek doğrulanmıştır.
Önce Tahmin Et: Üç Requirement Kaç Tablo Eder?
Kitapçının ürün ekibi üç yeni özellik istiyor: henüz çıkmamış kitaplara ön sipariş, indirim kuponları ve sepete eklenen kitabın kısa süreliğine rezerve edilmesi. Toplantıdan çıkan not üç satır. Tahmin et: Bu üç satır Bookstore şemasına kaç yeni tablo, kaç yeni kolon ve kaç constraint ekler? Ve bunlardan kaçı ilk okumada görünür?
Bu yazıda serinin bütün adımlarını bu üç requirement üzerinde sırayla uygulayacağız: cümlelerden entity ve kural çıkarmak, ER (Entity-Relationship) diyagramı, dependency'ler ve normal form kontrolü, fiziksel DDL (Data Definition Language), açıklamaların şemada tutulması ve EF Core'un ürettiği migration'ın review'ı. Sonunda elimizde dört yeni tablo, iki mevcut tabloda birer değişiklik, bir view, yirmi sekiz constraint ve iki index olacak; constraint'lerin çoğu requirement cümlelerinde açıkça yazmıyor, cümlelerin arasından çıkıyor. Örnekler, SQL Temelleri serisinin Bookstore şemasının yüklü olduğu dm_12 veritabanında.

Requirement'lar ve Entity Listesi
Serinin 1. yazısındaki yöntemle başlıyoruz: her cümleyi isimler (entity adayı), fiiller (ilişki) ve kurallar (constraint adayı) olarak okumak. Toplantı notunu ürün ekibiyle birlikte açınca cümleler şunlar oldu:
Ön sipariş: "Çıkış tarihi gelecekte olan bir kitap için ön sipariş alınır. Fiyat ön sipariş anında sabitlenir. Kitap çıkınca ön sipariş gerçek bir siparişe dönüşür; müşteri isterse iptal eder. Bir müşterinin aynı kitap için bekleyen tek bir ön siparişi olur."
Kupon: "Her kuponun benzersiz bir kodu vardır. İndirim yüzde ya da sabit tutardır. Kupon bir tarih aralığında geçerlidir ve toplam kullanım sınırı olabilir. Bir müşteri bir kuponu bir kez kullanır. Bir siparişte en fazla bir kupon kullanılır."
Rezervasyon: "Sepete eklenen kitap 15 dakika rezerve edilir. Rezerve edilen adet stoktan düşülmez, ama satılabilir stoku azaltır. Süresi dolan rezervasyon kendiliğinden geçersizdir."
Entity'ler: pre_orders (kendi yaşam döngüsü olan bir şey; bekler, dönüşür ya da iptal olur), coupons, coupon_redemptions (kuponun bir siparişte kullanılması; tarih, tutar ve müşterisi olan bir olgu, yani associative entity) ve stock_reservations. Mevcut entity'lere de dokunuluyor: kitabın bir "satışa çıkış tarihi" olmalı. Access pattern'ler de not edildi: sepet ekranı her kitap için satılabilir stoku okur, ödeme adımı kuponu doğrular ve kullanır, gece job'ı kitabı çıkan ön siparişleri siparişe çevirir.
ER Diyagramı: Mermaid ile Kodda
Serinin 2. yazısında ER diyagramını Mermaid ile kodda tutmayı ve diff'lenebilir yapmayı görmüştük. Yeni entity'ler ve mevcut tablolarla ilişkileri:
erDiagram
customers ||--o{ orders : places
customers ||--o{ pre_orders : places
customers ||--o{ stock_reservations : holds
books ||--o{ pre_orders : "is pre-ordered in"
books ||--|| stock_items : "is stocked as"
stock_items ||--o{ stock_reservations : "is reserved by"
orders |o--o{ pre_orders : "is converted from"
coupons ||--o{ coupon_redemptions : "is redeemed in"
orders ||--o| coupon_redemptions : "uses at most one"
coupons {
int coupon_id PK
varchar code UK
varchar discount_type
decimal discount_value
datetime2 valid_from
datetime2 valid_to
int max_redemptions "NULL = unlimited"
int redemption_count
}
coupon_redemptions {
int order_id PK, FK
int customer_id FK
int coupon_id FK
decimal discount_amount
}
pre_orders {
int pre_order_id PK
int customer_id FK
int book_id FK
decimal unit_price "snapshot"
varchar status
int order_id FK "NULL until converted"
}
stock_reservations {
bigint reservation_id PK
int book_id FK
int customer_id FK
int quantity
datetime2 expires_at
}
Diyagramdaki iki karar dikkat istiyor. orders ||--o| coupon_redemptions: bir siparişin sıfır ya da bir kupon kullanımı var, bu yüzden coupon_redemptions'ın key'i doğrudan order_id olabilir; 3. yazıdaki 1-1 kalıbı, opsiyonel tarafta foreign key'in primary key olması. orders |o--o{ pre_orders: bir ön sipariş en fazla bir siparişe dönüşür, bir sipariş birden çok ön siparişten oluşabilir ve dönüşmemiş bir ön siparişin siparişi yoktur.
Dependency'ler ve Normal Form Kontrolü
coupon_redemptions'ın iki kuralı iki ayrı dependency: "siparişte en fazla bir kupon" order_id → coupon_id demek, "müşteri başına bir kez" {coupon_id, customer_id} → order_id demek. 5. yazıdaki closure procedure'üyle kontrol edelim:
CREATE TABLE dbo.fds (
fd_id INT NOT NULL,
side CHAR(3) NOT NULL CHECK (side IN ('lhs', 'rhs')),
attr VARCHAR(30) NOT NULL
);
INSERT INTO dbo.fds (fd_id, side, attr) VALUES
(1, 'lhs', 'order_id'), (1, 'rhs', 'customer_id'), (1, 'rhs', 'coupon_id'),
(1, 'rhs', 'discount_amount'), (1, 'rhs', 'redeemed_at'),
(2, 'lhs', 'coupon_id'), (2, 'lhs', 'customer_id'), (2, 'rhs', 'order_id');
EXEC dbo.attribute_closure 'order_id';
EXEC dbo.attribute_closure 'coupon_id, customer_id';
step attributes
---- ----------------------------------------------------
0 order_id
1 coupon_id, customer_id, discount_amount, redeemed_at
step attributes
---- ----------------------------
0 coupon_id, customer_id
1 order_id
2 discount_amount, redeemed_at
İki candidate key var: {order_id} ve {coupon_id, customer_id}. İki dependency'nin determinant'ı da bir candidate key olduğu için tablo BCNF'te (Boyce-Codd Normal Form). Ama bir şey dikkat çekiyor: customer_id, orders tablosunda da var ve order_id → customer_id orada da geçerli. Yani kolon tablolar arasında bir kopya. Neden tutuyoruz? Çünkü "müşteri başına bir kez" kuralını bir UNIQUE constraint ile yazmanın yolu bu: kural iki tabloya dağılırsa, 7. yazıdaki dependency preservation kaybını yaşarız. Kopyanın sapmaması için de 4. yazıdaki composite foreign key'i kullanacağız: (order_id, customer_id) çifti orders'taki aynı çifte bağlanacak. Kopya var, ama tutarlılığını veritabanı koruyor.
Diğer kararlar da birer dependency sorusu. pre_orders.unit_price, books.list_price'ın kopyası değil; ön sipariş anının fiyatı, 6. yazıdaki order_items.unit_price gibi bir snapshot. Satılabilir stok ise stoktan ve aktif rezervasyonlardan türüyor; saklanırsa 8. yazıdaki bir senkronizasyon problemine dönüşür, bu yüzden bir view olacak. Kuponun kaç kez kullanıldığı da türetilebilir bir değer (COUNT(*)), ama onu bilinçli olarak saklayacağız, çünkü toplam kullanım sınırını bir constraint ile korumanın en sağlam yolu bu.
Fiziksel DDL: İsimlendirme, Tipler ve Constraint'ler
İsimlendirme SQL Temelleri serisinin Bookstore şemasını izliyor: snake_case, çoğul tablo adları, <tablo>_id biçiminde key'ler ve PK_, FK_, UQ_, CK_, DF_ önekli constraint adları. Tipler serinin kararlarından: para DECIMAL(10,2), anlık zamanlar UTC (Coordinated Universal Time) olarak DATETIME2(0), takvim günü DATE, durumlar CHECK'li VARCHAR.
ALTER TABLE dbo.books ADD available_from DATE NULL;
GO
UPDATE dbo.books SET available_from = listed_on;
ALTER TABLE dbo.books ALTER COLUMN available_from DATE NOT NULL;
ALTER TABLE dbo.orders ADD CONSTRAINT UQ_orders_order_customer UNIQUE (order_id, customer_id);
GO
CREATE TABLE dbo.pre_orders (
pre_order_id INT IDENTITY(1,1) NOT NULL,
customer_id INT NOT NULL,
book_id INT NOT NULL,
quantity INT NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL CONSTRAINT DF_pre_orders_status DEFAULT ('Waiting'),
order_id INT NULL,
created_at DATETIME2(0) NOT NULL CONSTRAINT DF_pre_orders_created_at DEFAULT (SYSUTCDATETIME()),
CONSTRAINT PK_pre_orders PRIMARY KEY (pre_order_id),
CONSTRAINT FK_pre_orders_customers FOREIGN KEY (customer_id) REFERENCES dbo.customers (customer_id),
CONSTRAINT FK_pre_orders_books FOREIGN KEY (book_id) REFERENCES dbo.books (book_id),
CONSTRAINT FK_pre_orders_orders FOREIGN KEY (order_id) REFERENCES dbo.orders (order_id),
CONSTRAINT CK_pre_orders_quantity CHECK (quantity > 0),
CONSTRAINT CK_pre_orders_status CHECK (status IN ('Waiting', 'Converted', 'Cancelled')),
CONSTRAINT CK_pre_orders_converted CHECK (
(status = 'Converted' AND order_id IS NOT NULL) OR (status <> 'Converted' AND order_id IS NULL))
);
CREATE UNIQUE INDEX UX_pre_orders_waiting
ON dbo.pre_orders (customer_id, book_id) WHERE status = 'Waiting';
CREATE TABLE dbo.coupons (
coupon_id INT IDENTITY(1,1) NOT NULL,
code VARCHAR(30) NOT NULL,
discount_type VARCHAR(10) NOT NULL,
discount_value DECIMAL(10,2) NOT NULL,
valid_from DATETIME2(0) NOT NULL,
valid_to DATETIME2(0) NOT NULL,
max_redemptions INT NULL,
redemption_count INT NOT NULL CONSTRAINT DF_coupons_redemption_count DEFAULT (0),
CONSTRAINT PK_coupons PRIMARY KEY (coupon_id),
CONSTRAINT UQ_coupons_code UNIQUE (code),
CONSTRAINT CK_coupons_discount CHECK (discount_value > 0
AND (discount_type = 'amount' OR (discount_type = 'percent' AND discount_value <= 100))),
CONSTRAINT CK_coupons_valid_range CHECK (valid_to > valid_from),
CONSTRAINT CK_coupons_redemptions CHECK (redemption_count <= max_redemptions)
);
CREATE TABLE dbo.coupon_redemptions (
order_id INT NOT NULL,
customer_id INT NOT NULL,
coupon_id INT NOT NULL,
discount_amount DECIMAL(10,2) NOT NULL,
redeemed_at DATETIME2(0) NOT NULL CONSTRAINT DF_coupon_redemptions_redeemed_at DEFAULT (SYSUTCDATETIME()),
CONSTRAINT PK_coupon_redemptions PRIMARY KEY (order_id),
CONSTRAINT UQ_coupon_redemptions_coupon_customer UNIQUE (coupon_id, customer_id),
CONSTRAINT FK_coupon_redemptions_coupons FOREIGN KEY (coupon_id) REFERENCES dbo.coupons (coupon_id),
CONSTRAINT FK_coupon_redemptions_orders FOREIGN KEY (order_id, customer_id)
REFERENCES dbo.orders (order_id, customer_id),
CONSTRAINT CK_coupon_redemptions_amount CHECK (discount_amount > 0)
);
CREATE TABLE dbo.stock_reservations (
reservation_id BIGINT IDENTITY(1,1) NOT NULL,
book_id INT NOT NULL,
customer_id INT NOT NULL,
quantity INT NOT NULL,
reserved_at DATETIME2(0) NOT NULL CONSTRAINT DF_stock_reservations_reserved_at DEFAULT (SYSUTCDATETIME()),
expires_at DATETIME2(0) NOT NULL,
CONSTRAINT PK_stock_reservations PRIMARY KEY (reservation_id),
CONSTRAINT FK_stock_reservations_stock_items FOREIGN KEY (book_id) REFERENCES dbo.stock_items (book_id),
CONSTRAINT FK_stock_reservations_customers FOREIGN KEY (customer_id) REFERENCES dbo.customers (customer_id),
CONSTRAINT CK_stock_reservations_quantity CHECK (quantity > 0),
CONSTRAINT CK_stock_reservations_expiry CHECK (expires_at > reserved_at)
);
CREATE INDEX IX_stock_reservations_book_expiry
ON dbo.stock_reservations (book_id, expires_at) INCLUDE (quantity);
GO
CREATE VIEW dbo.available_stock
AS
SELECT s.book_id,
s.quantity_on_hand,
s.quantity_on_hand - ISNULL(r.reserved, 0) AS available_quantity
FROM dbo.stock_items AS s
OUTER APPLY (SELECT SUM(quantity) AS reserved
FROM dbo.stock_reservations
WHERE book_id = s.book_id AND expires_at > SYSUTCDATETIME()) AS r;
(77 rows affected)
Birkaç karar requirement'ta yazmıyordu. available_from nullable eklenip doldurulduktan sonra NOT NULL yapıldı: "çıkış tarihi bilinmiyor" diye bir durum istemiyoruz, mevcut kitaplar için listelenme tarihi yeterli. CK_pre_orders_converted, "dönüşmüş ön siparişin siparişi vardır, dönüşmemişin yoktur" kuralını yazıyor; 3. yazıdaki "nullable foreign key ne anlama geliyor?" sorusunun cevabı burada bir constraint. CK_coupons_redemptions, max_redemptions NULL olduğunda UNKNOWN döner ve geçer; 9. yazıda tuzak olarak gördüğümüz davranış, burada "sınırsız" anlamında bilinçli kullanılıyor. Kupon kullanımı, sayaç ile kullanım satırını aynı transaction'da yazan bir procedure:
CREATE OR ALTER PROCEDURE dbo.redeem_coupon @order_id INT, @code VARCHAR(30)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
UPDATE dbo.coupons
SET redemption_count += 1
WHERE code = @code
AND SYSUTCDATETIME() >= valid_from AND SYSUTCDATETIME() < valid_to;
IF @@ROWCOUNT = 0
THROW 50001, 'Coupon not found or not valid now.', 1;
INSERT INTO dbo.coupon_redemptions (order_id, customer_id, coupon_id, discount_amount)
SELECT o.order_id, o.customer_id, c.coupon_id,
CASE c.discount_type
WHEN 'percent' THEN ROUND(t.total * c.discount_value / 100, 2)
ELSE c.discount_value
END
FROM dbo.orders AS o
CROSS JOIN dbo.coupons AS c
CROSS APPLY (SELECT SUM(quantity * unit_price) AS total
FROM dbo.order_items WHERE order_id = o.order_id) AS t
WHERE o.order_id = @order_id AND c.code = @code;
COMMIT;
END;
Şimdi constraint'leri requirement cümlelerine karşı test edelim. Her başarısız satır bir cümleye karşılık geliyor:
INSERT INTO dbo.coupons (code, discount_type, discount_value, valid_from, valid_to, max_redemptions)
VALUES ('SPRING10', 'percent', 10, '2026-01-01', '2027-01-01', NULL),
('ONEUSE50', 'amount', 50, '2026-01-01', '2027-01-01', 1);
EXEC dbo.redeem_coupon @order_id = 1, @code = 'SPRING10';
EXEC dbo.redeem_coupon @order_id = 3, @code = 'SPRING10';
EXEC dbo.redeem_coupon @order_id = 10, @code = 'SPRING10';
GO
EXEC dbo.redeem_coupon @order_id = 2, @code = 'ONEUSE50';
EXEC dbo.redeem_coupon @order_id = 5, @code = 'ONEUSE50';
GO
INSERT INTO dbo.coupon_redemptions (order_id, customer_id, coupon_id, discount_amount)
VALUES (4, 17, 2, 50.00);
GO
SELECT r.order_id, r.customer_id, c.code, r.discount_amount
FROM dbo.coupon_redemptions AS r
JOIN dbo.coupons AS c ON c.coupon_id = r.coupon_id
ORDER BY r.order_id;
SELECT code, max_redemptions, redemption_count FROM dbo.coupons;
(2 rows affected)
Msg 2627, Level 14, State 1, Server b8be943222fa, Procedure dbo.redeem_coupon, Line 14
Violation of UNIQUE KEY constraint 'UQ_coupon_redemptions_coupon_customer'. Cannot insert duplicate key in object 'dbo.coupon_redemptions'. The duplicate key value is (1, 3).
Msg 547, Level 16, State 1, Server b8be943222fa, Procedure dbo.redeem_coupon, Line 7
The UPDATE statement conflicted with the CHECK constraint "CK_coupons_redemptions". The conflict occurred in database "dm_12", table "dbo.coupons".
Msg 547, Level 16, State 1, Server b8be943222fa, Line 1
The INSERT statement conflicted with the FOREIGN KEY constraint "FK_coupon_redemptions_orders". The conflict occurred in database "dm_12", table "dbo.orders".
The statement has been terminated.
order_id customer_id code discount_amount
-------- ----------- -------- ---------------
1 17 SPRING10 104.27
2 28 ONEUSE50 50.00
3 3 SPRING10 154.00
(3 rows affected)
code max_redemptions redemption_count
-------- --------------- ----------------
SPRING10 NULL 2
ONEUSE50 1 1
(2 rows affected)
Müşteri 3'ün ikinci SPRING10 kullanımı unique constraint'e, tek kullanımlık kuponun ikinci kullanımı sayaç CHECK'ine, siparişin müşterisiyle uyuşmayan bir kullanım satırı composite foreign key'e takıldı. Başarısız procedure çağrılarında sayaç da geri alındı, çünkü XACT_ABORT açık. Ön sipariş ve rezervasyon kuralları:
INSERT INTO dbo.pre_orders (customer_id, book_id, quantity, unit_price)
VALUES (3, 41, 1, 200.50);
INSERT INTO dbo.pre_orders (customer_id, book_id, quantity, unit_price)
VALUES (3, 41, 2, 200.50);
GO
UPDATE dbo.pre_orders SET status = 'Converted' WHERE customer_id = 3 AND book_id = 41;
(1 rows affected)
Msg 2601, Level 14, State 1, Server b8be943222fa, Line 3
Cannot insert duplicate key row in object 'dbo.pre_orders' with unique index 'UX_pre_orders_waiting'. The duplicate key value is (3, 41).
The statement has been terminated.
Msg 547, Level 16, State 1, Server b8be943222fa, Line 1
The UPDATE statement conflicted with the CHECK constraint "CK_pre_orders_converted". The conflict occurred in database "dm_12", table "dbo.pre_orders".
The statement has been terminated.
INSERT INTO dbo.stock_reservations (book_id, customer_id, quantity, reserved_at, expires_at)
VALUES (26, 3, 2, SYSUTCDATETIME(), DATEADD(MINUTE, 15, SYSUTCDATETIME())),
(26, 17, 1, SYSUTCDATETIME(), DATEADD(MINUTE, 15, SYSUTCDATETIME())),
(26, 28, 5, DATEADD(MINUTE, -20, SYSUTCDATETIME()), DATEADD(MINUTE, -5, SYSUTCDATETIME()));
SELECT book_id, quantity_on_hand, available_quantity
FROM dbo.available_stock
WHERE book_id = 26;
(3 rows affected)
book_id quantity_on_hand available_quantity
------- ---------------- ------------------
26 51 48
(1 rows affected)
Aynı kitap için ikinci bekleyen ön sipariş filtered unique index'e, siparişi olmadan "dönüştü" denen ön sipariş CHECK'e takıldı. Stokta 51 Dune var; iki aktif rezervasyon 3 adet tutuyor, süresi dolmuş 5 adetlik rezervasyon sayılmıyor ve satılabilir stok 48. Süresi dolan satırları silmek bir temizlik job'ının işi; doğruluk için gerekli değil, çünkü view yalnızca aktif rezervasyonlara bakıyor.
Açıklamaları Şemada Tutmak
Yukarıdaki kararların çoğu kolon adından anlaşılmıyor: max_redemptions'ın NULL'ı ne demek, redemption_count'ı kim güncelliyor? Bu bilgiyi bir wiki sayfasına değil, şemanın kendisine yazmak gerekiyor; şema değişince açıklama da aynı migration'da değişir. SQL Server'da bunun yolu extended property:
EXEC sys.sp_addextendedproperty
@name = N'MS_Description', @value = N'Maximum number of redemptions. NULL means unlimited.',
@level0type = N'SCHEMA', @level0name = N'dbo',
@level1type = N'TABLE', @level1name = N'coupons',
@level2type = N'COLUMN', @level2name = N'max_redemptions';
EXEC sys.sp_addextendedproperty
@name = N'MS_Description', @value = N'Denormalized counter kept by dbo.redeem_coupon in the same transaction.',
@level0type = N'SCHEMA', @level0name = N'dbo',
@level1type = N'TABLE', @level1name = N'coupons',
@level2type = N'COLUMN', @level2name = N'redemption_count';
EXEC sys.sp_addextendedproperty
@name = N'MS_Description', @value = N'NULL until the pre-order is converted into an order.',
@level0type = N'SCHEMA', @level0name = N'dbo',
@level1type = N'TABLE', @level1name = N'pre_orders',
@level2type = N'COLUMN', @level2name = N'order_id';
SELECT t.name AS table_name, c.name AS column_name, CAST(ep.value AS NVARCHAR(200)) AS description
FROM sys.extended_properties AS ep
JOIN sys.tables AS t ON t.object_id = ep.major_id
JOIN sys.columns AS c ON c.object_id = ep.major_id AND c.column_id = ep.minor_id
WHERE ep.name = N'MS_Description'
ORDER BY t.name, c.column_id;
table_name column_name description
---------- ---------------- -----------------------------------------------------------------------
coupons max_redemptions Maximum number of redemptions. NULL means unlimited.
coupons redemption_count Denormalized counter kept by dbo.redeem_coupon in the same transaction.
pre_orders order_id NULL until the pre-order is converted into an order.
(3 rows affected)
MS_Description adı bir gelenek: SSMS (SQL Server Management Studio) ve birçok dokümantasyon aracı bu adı okuyor. PostgreSQL'de karşılığı COMMENT ON:
CREATE TABLE coupons (
coupon_id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code varchar(30) NOT NULL UNIQUE,
max_redemptions integer NULL CHECK (max_redemptions > 0)
);
COMMENT ON TABLE coupons IS 'Discount codes customers can redeem on orders.';
COMMENT ON COLUMN coupons.max_redemptions IS 'Maximum number of redemptions. NULL means unlimited.';
SELECT a.attname AS column_name, col_description(a.attrelid, a.attnum) AS description
FROM pg_attribute AS a
WHERE a.attrelid = 'coupons'::regclass AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum;
column_name | description
-----------------+------------------------------------------------------
coupon_id |
code |
max_redemptions | Maximum number of redemptions. NULL means unlimited.
(3 rows)
Hangi kolonlara açıklama yazmalı? Her kolona değil. Nullable her kolonun NULL anlamı, türetilmiş ya da kopyalanmış her kolonun kaynağı ve güncelleme yolu, snapshot olan her kolonun "bu bir snapshot" notu yeterli. 7. yazıdaki bilinçli normal form sapmaları da tablo açıklamasına gider.
EF Core'da Code-First Model ve Migration Review'ı
Çoğu .NET projesinde şema elle yazılmaz; EF Core modelinden migration olarak üretilir. Bu durumda review'ın konusu C# class'ları değil, migration'ın ürettiği SQL'dir. Convention'lara güvenen ilk model:
public class Coupon
{
public int CouponId { get; set; }
public string Code { get; set; } = "";
public string DiscountType { get; set; } = "";
public decimal DiscountValue { get; set; }
public DateTime ValidFrom { get; set; }
public DateTime ValidTo { get; set; }
public int? MaxRedemptions { get; set; }
public List<CouponRedemption> Redemptions { get; set; } = [];
}
public class CouponRedemption
{
public int CouponRedemptionId { get; set; }
public int OrderId { get; set; }
public int CustomerId { get; set; }
public int CouponId { get; set; }
public Coupon Coupon { get; set; } = null!;
public decimal DiscountAmount { get; set; }
public DateTime RedeemedAt { get; set; }
}
dotnet ef migrations add Initial ve dotnet ef migrations script (yerel tool manifest ile kurulan dotnet-ef 10.0.15) şu SQL'i üretti:
CREATE TABLE [Coupons] (
[CouponId] int NOT NULL IDENTITY,
[Code] nvarchar(max) NOT NULL,
[DiscountType] nvarchar(max) NOT NULL,
[DiscountValue] decimal(18,2) NOT NULL,
[ValidFrom] datetime2 NOT NULL,
[ValidTo] datetime2 NOT NULL,
[MaxRedemptions] int NULL,
CONSTRAINT [PK_Coupons] PRIMARY KEY ([CouponId])
);
CREATE TABLE [CouponRedemptions] (
[CouponRedemptionId] int NOT NULL IDENTITY,
[OrderId] int NOT NULL,
[CustomerId] int NOT NULL,
[CouponId] int NOT NULL,
[DiscountAmount] decimal(18,2) NOT NULL,
[RedeemedAt] datetime2 NOT NULL,
CONSTRAINT [PK_CouponRedemptions] PRIMARY KEY ([CouponRedemptionId]),
CONSTRAINT [FK_CouponRedemptions_Coupons_CouponId] FOREIGN KEY ([CouponId]) REFERENCES [Coupons] ([CouponId]) ON DELETE CASCADE
);
CREATE INDEX [IX_CouponRedemptions_CouponId] ON [CouponRedemptions] ([CouponId]);
Derleme hatasız, migration temiz; ama review'da en az altı bulgu var. Code nvarchar(max) ve benzersiz değil; max uzunluklu bir kolon zaten index key'i olamaz. Para kolonları convention'ın decimal(18,2)'si, zamanlar hassasiyetsiz datetime2. DiscountType serbest metin. Kullanım tablosu bir surrogate key almış ve "siparişte bir kupon" ile "müşteri başına bir kez" kurallarının ikisi de kaybolmuş. Kupon silinince bütün kullanım geçmişini silen bir ON DELETE CASCADE var. İsimler de Bookstore'un snake_case'iyle uyuşmuyor. Kuralları modelde yazınca:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Coupon>(e =>
{
e.ToTable("coupons", t =>
{
t.HasCheckConstraint("CK_coupons_discount_type", "discount_type IN ('percent', 'amount')");
t.HasCheckConstraint("CK_coupons_valid_range", "valid_to > valid_from");
});
e.Property(c => c.Code).HasMaxLength(30).IsUnicode(false);
e.HasIndex(c => c.Code).IsUnique();
e.Property(c => c.DiscountType).HasMaxLength(10).IsUnicode(false);
e.Property(c => c.DiscountValue).HasPrecision(10, 2);
e.Property(c => c.ValidFrom).HasPrecision(0);
e.Property(c => c.ValidTo).HasPrecision(0);
});
modelBuilder.Entity<CouponRedemption>(e =>
{
e.ToTable("coupon_redemptions");
e.HasKey(r => r.OrderId);
e.Property(r => r.OrderId).ValueGeneratedNever();
e.HasIndex(r => new { r.CouponId, r.CustomerId }).IsUnique();
e.Property(r => r.DiscountAmount).HasPrecision(10, 2);
e.Property(r => r.RedeemedAt).HasPrecision(0);
e.HasOne(r => r.Coupon).WithMany(c => c.Redemptions)
.HasForeignKey(r => r.CouponId)
.OnDelete(DeleteBehavior.Restrict);
});
foreach (var entity in modelBuilder.Model.GetEntityTypes())
{
foreach (var property in entity.GetProperties())
{
property.SetColumnName(ToSnakeCase(property.Name));
}
}
}
private static string ToSnakeCase(string name) =>
string.Concat(name.Select((ch, i) => i > 0 && char.IsUpper(ch) ? "_" + char.ToLowerInvariant(ch) : char.ToLowerInvariant(ch).ToString()));
CREATE TABLE [coupons] (
[coupon_id] int NOT NULL IDENTITY,
[code] varchar(30) NOT NULL,
[discount_type] varchar(10) NOT NULL,
[discount_value] decimal(10,2) NOT NULL,
[valid_from] datetime2(0) NOT NULL,
[valid_to] datetime2(0) NOT NULL,
[max_redemptions] int NULL,
CONSTRAINT [PK_coupons] PRIMARY KEY ([coupon_id]),
CONSTRAINT [CK_coupons_discount_type] CHECK (discount_type IN ('percent', 'amount')),
CONSTRAINT [CK_coupons_valid_range] CHECK (valid_to > valid_from)
);
CREATE TABLE [coupon_redemptions] (
[order_id] int NOT NULL,
[customer_id] int NOT NULL,
[coupon_id] int NOT NULL,
[discount_amount] decimal(10,2) NOT NULL,
[redeemed_at] datetime2(0) NOT NULL,
CONSTRAINT [PK_coupon_redemptions] PRIMARY KEY ([order_id]),
CONSTRAINT [FK_coupon_redemptions_coupons_coupon_id] FOREIGN KEY ([coupon_id]) REFERENCES [coupons] ([coupon_id]) ON DELETE NO ACTION
);
CREATE UNIQUE INDEX [IX_coupon_redemptions_coupon_id_customer_id] ON [coupon_redemptions] ([coupon_id], [customer_id]);
CREATE UNIQUE INDEX [IX_coupons_code] ON [coupons] ([code]);
Uzunluklar, hassasiyetler, iki benzersizlik kuralı, CHECK'ler ve ON DELETE NO ACTION artık migration'da. İsimler tablo ve kolonlarda snake_case; index adları ise EF Core'un IX_ convention'ında kaldı ve kararı review'da vermek gerekiyor: ya HasDatabaseName ile Bookstore'un UQ_ önekine çevirmek ya da bu farkı kabul edip yazmak. Composite foreign key için orders entity'sinin modelde olması ve (order_id, customer_id) çiftinin bir alternate key olarak tanımlanması gerekir; bu örnekte orders modelin dışında olduğu için o adım yok. Migration'ların komutları, script ve bundle üretimi EF Core serisinin "EF Core Migrations" yazısında, owned type ve value converter gibi mapping seçenekleri "EF Core'da Owned Type ve Value Converter" yazısında.
Şema Review Checklist'i
Bir şema değişikliğini (elle yazılmış DDL ya da üretilmiş migration) review ederken sorulacak sorular, serinin her yazısından birer cümle:
- Her tablonun anlamlı bir natural uniqueness'ı var mı ve
UNIQUEile korunuyor mu? Surrogate key'in yanında iş kuralı kayboldu mu? - Her nullable kolonun
NULLanlamı (bilinmiyor, yok, henüz değil) yazılı mı? VarsayılanNOT NULLmı? - Silme davranışı (
CASCADE,NO ACTION, soft delete) modelden mi geliyor? Weak entity dışında birCASCADEvar mı? - Her kolon tablonun key'ine, key'in tamamına ve yalnızca key'e mi bağlı? Bağlı değilse bilinçli bir karar mı ve belgelenmiş mi?
- Bölünen tablolarda kaybolan bir dependency var mı ve onu ne koruyor?
- Kopyalanan ya da türetilen her kolonun kaynağı ve güncelleme yolu belli mi? Snapshot'lar kopyadan ayrılmış mı?
- Para
DECIMAL, anlık zaman UTC ve sabit hassasiyetli, takvim günüDATEmi? Metin kolonlarının uzunluğu ve Unicode ihtiyacı düşünülmüş mü? - Virgüllü liste, numaralı kolon, EAV (Entity-Attribute-Value), tek büyük lookup tablosu ya da kısıtsız bir JSON (JavaScript Object Notation) kolonu var mı?
- Raporlama ihtiyacı OLTP (Online Transaction Processing) şemasını zorluyor mu, yoksa ayrı bir dimensional model mi gerekiyor?
- İsimler mevcut şemanın convention'ına uyuyor mu? Değişiklik geriye uyumlu deploy edilebilir mi?
Üretimde Dikkat
Constraint'leri requirement testleri gibi yaz. Yukarıdaki t1.sql ve t2a.sql, her iş kuralı için bir başarısız satır deniyor. Bu script'ler bir test veritabanında CI (Continuous Integration) içinde çalışırsa, birinin "geçici olarak" bir constraint'i kaldırması fark edilir.
Zaman kurallarını constraint'e koymaya çalışma. "Ön sipariş yalnızca çıkış tarihi gelecekteki kitaplar için alınır" kuralı SYSUTCDATETIME() ile bir CHECK'e yazılamaz, çünkü kural bugün doğru olan bir satırı yarın yanlış yapar. Bu kural uygulama kodunda, yazma anında kontrol edilir; veritabanı yalnızca zamandan bağımsız kuralları korur.
Yeni kolonları geriye uyumlu ekle. available_from önce nullable eklendi, dolduruldu, sonra NOT NULL yapıldı; büyük bir tabloda bu adımlar ayrı deploy'larda ve parçalı backfill ile yapılır. Expand-contract akışı Deployment Stratejileri serisinin "Migration'ların Deploy'daki Yeri" yazısında.
Sayaç ve rezervasyon gibi sıcak satırları izle. Popüler bir kuponun redemption_count satırı, kampanya anında aynı satıra yoğun yazma demek. Bu ölçekte sorun olursa 8. yazıdaki seçenekler (batch, event tabanlı güncelleme) ya da kuponu parçalara bölen tasarımlar masaya gelir.
Sık Düşülen Tuzaklar
Requirement cümlesini tek bir tabloya çevirip kuralları atlamak. "Müşteri başına bir kez" cümlesi bir tablo değil, bir UNIQUE constraint; "siparişte en fazla bir kupon" da primary key seçimi. Kurallar cümlelerin içinde saklıdır.
ORM'in (Object-Relational Mapper) ürettiği migration'ı okumadan uygulamak. Convention'lar çalışan bir şema üretir, doğru bir şema değil; yukarıdaki ilk migration'daki ON DELETE CASCADE, kupon silindiğinde finansal geçmişi siler.
Türetilebilir değeri her yerde saklamak ya da hiçbir yerde saklamamak. Satılabilir stok türetildi, kupon sayacı saklandı; ikisi de bilinçli kararlar ve nedenleri şemada yazılı. Tek bir kural yok, her değer için ayrı bir karar var.
Açıklamaları şemanın dışında tutmak. Wiki sayfası şemayla birlikte güncellenmez; extended property ya da COMMENT ON aynı migration'da güncellenir.
Küçük Bir Deneme
★☆☆ coupons tablosuna "kupon yalnızca belirli bir kategorideki kitaplara uygulanır" kuralını ekle. Bu bir kolon mu, yeni bir tablo mu? Kupon birden çok kategoriye uygulanabiliyorsa ne değişir?
★★☆ dbo.convert_pre_orders adında, çıkış tarihi gelmiş kitapların bekleyen ön siparişlerini müşteri başına tek bir siparişe çeviren bir procedure yaz. CK_pre_orders_converted sana hangi sırayla yazman gerektiğini söylüyor?
★★★ EF Core modeline Order entity'sini ekle ve coupon_redemptions'ın (order_id, customer_id) composite foreign key'ini HasAlternateKey ve HasForeignKey ile modelle. Üretilen migration'ı bu yazıdaki T-SQL DDL ile karşılaştır: hangi farklar kaldı ve hangisini kabul edersin?
Seri burada bitiyor: düz bir sipariş Excel'inden başlayıp ER diyagramına, key'lere, functional dependency'lere, normal form'lara, denormalization'a, anti-pattern'lere, dimensional ve document modellere ve sonunda review edilebilir bir şemaya geldik. Bu şemanın üzerine kurulacak pratik problemler (public id, soft delete, audit, çok dilli veri, multi-tenancy) Çözüm Desenleri serisinde; şemanın motor tarafı SQL Server Derinlemesine serisinde, büyüdüğünde ne olacağı ise Veritabanı Performansı ve Scaling serisinde.