İçeriğe geç
·24 dk okuma

Veritabanı Şema Tasarımı: İyi Yaşlanan Kalıplar

Normalizasyon kuralları, isimlendirme kuralları, soft delete, audit trail, multi-tenancy kalıpları, versiyon stratejileri ve pişman olduğum şema kararları. PostgreSQL odaklı.

Paylaş:X / TwitterLinkedIn

Şema, gelecekteki kendinle yapılan bir sözleşme. Eklediğin her sütun, atladığın her constraint, her "bunu sonra düzeltiriz" — hepsi birikir. Üç yıl önce alınan tek bir kötü şema kararının takıma her çeyrekte tam bir sprint'e mal olduğu sistemlerde çalıştım.

Veritabanı her şeyden uzun yaşar. Frontend framework'ün değişecek. API katmanın yeniden yazılacak. Deploy stratejin evrimleşecek. Ama veri? Veri kalır. Ve ilk gün ona verdiğin şekil seni sonsuza kadar takip eder, çünkü 200 milyon satırlı bir tabloyu migrate etmek bir React bileşenini refactor etmekle aynı şey değil.

Bu, seni takip etmeyecek şema kararları alma hakkında öğrendiklerim. PostgreSQL odaklı, çünkü kullandığım ve güvendiğim bu, ama bu kalıpların çoğu herhangi bir ilişkisel veritabanı için geçerli.

İsimlendirme Kuralları: Önemli Olan Sıkıcı Şey#

Mimari kararlardan çok isimlendirme kuralları hakkında tartışma gördüm. Yıllar boyunca projeler arası geçiş yaptıktan sonra benimsediğim şey:

Her şey için snake_case. Tablolar, sütunlar, index'ler, constraint'ler. camelCase yok, PascalCase yok. PostgreSQL tırnaksız tanımlayıcıları zaten küçük harfe katlar, yani createdAt her yerde çift tırnak kullanmadıkça createdat olur. Veritabanıyla savaşma.

Çoğul tablo adları. Bir tablo birçok satır tutar. user değil users. order değil orders.

Foreign key'ler için _id son eki. user_id, order_id, tenant_id. Primary key sadece id.

Timestamp'ler için _at son eki. created_at, updated_at, deleted_at, published_at.

Boolean'lar için is_ ön eki. is_active, is_verified, is_published.

Denormalize sayaçlar için _count son eki. comment_count, follower_count.

Mesele şu: tutarlılık mükemmelliği yener. email_address mi email mi email_addr mı tartışmakla haftalarca uğraşan takımlar gördüm. Bir kalıp seç ve her yerde uygula. En kötü isimlendirme kuralı tutarsız uygulanan.

sql
-- İyi: tutarlı, okunabilir, sürpriz yok
CREATE TABLE users (
    id              BIGSERIAL PRIMARY KEY,
    email           TEXT NOT NULL,
    display_name    TEXT NOT NULL,
    password_hash   TEXT NOT NULL,
    is_active       BOOLEAN NOT NULL DEFAULT true,
    is_verified     BOOLEAN NOT NULL DEFAULT false,
    login_count     INTEGER NOT NULL DEFAULT 0,
    last_login_at   TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Kötü: tutarsız büyük/küçük harf, karışık kurallar, belirsiz isimler
CREATE TABLE User (
    UserID          SERIAL PRIMARY KEY,
    EmailAddress    VARCHAR(255),
    name            VARCHAR(100),
    passwd          VARCHAR(255),
    active          BOOLEAN,
    verified        INT,             -- bu 0/1 mı? doğrulama seviyesi mi?
    lastLogin       TIMESTAMP,       -- snake_case dünyasında camelCase
    created         TIMESTAMP        -- ne oluşturuldu? ne zaman?
);

Bir şey daha: sütun adı olarak asla ayrılmış kelimeler kullanma. type, order, user, group, table — hepsi tırnak içine alırsan çalışır, ama ORM'lerde, query builder'larda ve her dinamik SQL üretim aracında seni yakar. type yerine kind, order yerine sort_order kullan. Gelecekteki kendin sana teşekkür edecek.

Standart Sütunlar: Her Tablonun Aldığı#

Şemalarımdaki her tablo aynı iskeletle başlar. İstisna yok.

sql
CREATE TABLE things (
    id          BIGSERIAL PRIMARY KEY,
    -- ... domain sütunları ...
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

UUID vs BIGSERIAL Tartışması#

Bu, ürettiğinden çok daha fazla tartışma yaratan kararlardan biri. Gerçek ödünleşim:

BIGSERIAL (otomatik artan tam sayı):

  • 8 byte. Kompakt. Index'leme ve join'leme hızlı.
  • Sıralı — id'ye göre sıralayarak ekleme sırası alabilirsin.
  • Tahmin edilebilir — bir kullanıcı ID'sini artırarak diğerlerini tahmin edebilir.
  • Dağıtık sistemlerde iyi çalışmaz (koordinasyon gerektirir).

UUID v4 (rastgele):

  • 16 byte. Daha büyük index'ler, daha yavaş join'ler (ama nadiren darboğaz).
  • Tahmin edilemez — bilgi sızıntısı yok.
  • Koordinasyon olmadan dağıtık sistemlerde çalışır.
  • Korkunç index yerelliği — rastgele UUID'ler B-tree index'lerini parçalar.

UUID v7 (zaman-sıralı, RFC 9562):

  • 16 byte, ama zaman-sıralı olduğu için B-tree yerelliği mükemmel.
  • Dış kullanım için yeterince tahmin edilemez.

Mevcut tutumum: dahili tablolar için BIGSERIAL, dış dünyaya açılan her şey için UUID v7. Bir ID bir URL'de, bir API yanıtında veya bir webhook payload'ında görünecekse, UUID kullan. Kullanıcıların asla görmediği saf bir join tablosuysa, BIGSERIAL yeterli.

Timestamp'ler: Her Zaman TIMESTAMPTZ#

TIMESTAMP değil TIMESTAMPTZ kullan. "TZ" versiyonu değeri UTC'de saklar ve okuma sırasında oturum timezone'una göre dönüştürür. TZ'siz versiyon timezone bağlamı olmadan ne verirsen onu saklar — bu da farklı timezone'lardaki iki sunucu aynı tabloya yazarsa sessiz veri bozulması anlamına gelir.

updated_at Trigger'ı#

PostgreSQL'de MySQL'in ON UPDATE CURRENT_TIMESTAMP'ı yok. Trigger gerekir:

sql
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
CREATE TRIGGER set_updated_at
    BEFORE UPDATE ON users
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

Evet, tablo başına bir trigger gerekiyor. Evet, sıkıcı. Tablo eklediğinde trigger'ı otomatik oluşturan bir migration yardımcısı yaz. Buna değer çünkü alternatif uygulamandaki her UPDATE sorgusunda updated_at = NOW() ayarlamayı hatırlamak — ve unutacaksın.

Normalizasyon: Kuralları Ne Zaman Kırmak Gerekir#

Her bilgisayar bilimleri dersi 3NF'e (Üçüncü Normal Form) kadar normalizasyon öğretir:

  • 1NF: Her sütun tek bir atomik değer tutar. Dizi yok, virgülle ayrılmış liste yok.
  • 2NF: Her anahtar olmayan sütun tüm birincil anahtara bağlıdır.
  • 3NF: Geçişli bağımlılık yok.

Pratikte, 3NF, işlemsel tablolar için tatlı noktadır. Oradan başla ve sadece belirli, ölçülebilir bir nedenin olduğunda sapma yap.

sql
CREATE TABLE order_items (
    id              BIGSERIAL PRIMARY KEY,
    order_id        BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    product_id      BIGINT NOT NULL REFERENCES products(id),
    quantity        INTEGER NOT NULL CHECK (quantity > 0),
    unit_price_cents INTEGER NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

order_items üzerindeki unit_price_cents'e dikkat et. Bu kasıtlı denormalizasyon. Sipariş anındaki fiyatın anlık görüntüsünü alıyoruz çünkü ürünün fiyatı sonra değişebilir. Bu en yaygın ve doğru denormalizasyon nedenlerinden biri.

Denormalizasyonun Doğru Olduğu Durumlar#

Raporlama tabloları. Analytics dashboard'un render olmak için 8 tablo join'lemesi gerekiyorsa, denormalize bir raporlama tablosu oluştur ve bir arka plan işiyle doldur.

Materialized view'lar. PostgreSQL'in MATERIALIZED VIEW'ı hafife alınıyor. İsteğe bağlı yenilediğin denormalize bir anlık görüntü.

sql
CREATE MATERIALIZED VIEW monthly_revenue AS
SELECT
    DATE_TRUNC('month', o.created_at) AS month,
    COUNT(DISTINCT o.id) AS order_count,
    SUM(o.total_cents) AS revenue_cents
FROM orders o
WHERE o.status != 'cancelled'
GROUP BY DATE_TRUNC('month', o.created_at)
ORDER BY month DESC;
 
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue;

Dinamik nitelikler için JSON sütunları. Farklı satırların farklı alanlara ihtiyaç duyduğu durumlar — ürün varyantları, form gönderimleri, kullanıcı tercihleri.

Asla Kırmadığım Tek Kural#

Gerçeğin-kaynağı tablolarını asla denormalize etme. Kopyaları, anlık görüntüleri, raporları ve önbellekleri denormalize et. Kanonik veri normalize kalır. Denormalize kopya bayatlayınca (ve bayatlayacak), normalize kaynaktan yeniden oluşturursun.

Foreign Key'ler ve Constraint'ler#

Foreign key atlamanın her bahanesini duydum. "Yazmaları yavaşlatıyor." "Uygulamada zorluyoruz." "Esnekliğe ihtiyacımız var."

Bunların hepsi yanlış.

Foreign key'ler bir şemaya ekleyebileceğin en etkili tek şey. Hiçbir uygulama kodunun yakalayamayacağı tüm bug kategorilerini önler — race condition'lar, kısmi başarısızlıklar, başarısız transaction'lardan kalan orphan satırlar. Foreign key veritabanı motorunun kendisinden bir garanti, depolama seviyesinde zorlanan. ORM'inin beforeDelete hook'u bir öneri.

ON DELETE Stratejileri#

RESTRICT (varsayılan): Alt satırlar varsa silme başarısız olur. Çoğu ilişki için bunu kullan.

CASCADE: Alt satırlar otomatik silinir. Dikkatli ve kasıtlı kullan. Alt satırın ebeveyn olmadan anlamsız olduğu "parçası" ilişkileri için iyi.

sql
ALTER TABLE order_items
    ADD CONSTRAINT fk_order_items_order
    FOREIGN KEY (order_id) REFERENCES orders(id)
    ON DELETE CASCADE;

SET NULL: Foreign key sütunu NULL'a ayarlanır. İlişki opsiyonel olduğunda ve alt satır kendi başına anlamlı olduğunda kullan.

Temel kuralım: varsayılan olarak RESTRICT, bileşim ilişkileri için CASCADE, opsiyonel ilişkiler için SET NULL.

Check Constraint'ler: Ucuz Sigorta#

Check constraint'ler yazma zamanında neredeyse hiçbir maliyeti yok ve çöp verinin sistemine girmesini sonsuza kadar engeller:

sql
CREATE TABLE products (
    id              BIGSERIAL PRIMARY KEY,
    name            TEXT NOT NULL CHECK (LENGTH(name) > 0),
    price_cents     INTEGER NOT NULL CHECK (price_cents >= 0),
    discount_pct    NUMERIC(5,2) CHECK (discount_pct BETWEEN 0 AND 100),
    status          TEXT NOT NULL CHECK (status IN ('draft', 'active', 'archived')),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Partial Unique Index'ler#

Unique constraint'ler basit durumlar için sıradan. Ama partial index'lerle — sadece belirli satırlara uygulanan unique constraint'lerle — ilginçleşir:

sql
-- Kullanıcı başına sadece bir aktif abonelik (ama birçok iptal edilmişi olabilir)
CREATE UNIQUE INDEX uq_active_subscription
    ON subscriptions (user_id)
    WHERE status = 'active';
 
-- Kullanıcı başına sadece bir birincil adres
CREATE UNIQUE INDEX uq_primary_address
    ON addresses (user_id)
    WHERE is_primary = true;

Bu PostgreSQL'in en güçlü özelliklerinden biri. Kullan.

Soft Delete: Herkesin Nefret Ettiği Kalıp#

Soft delete kavramca basit: DELETE FROM users WHERE id = 42 yerine UPDATE users SET deleted_at = NOW() WHERE id = 42 yaparsın. Satır veritabanında kalır ama normal sorgulardan filtrelenir.

Uygulamandaki her sorgunun artık WHERE deleted_at IS NULL içermesi gerekiyor. Her. Bir. Sorgu. ORM'ler bunda yardımcı olur ama bu her okuma operasyonu üzerinde bir vergi. Bir kere kaçır ve "silinen" veriyi kullanıcılara gösteriyorsun.

Unique Constraint Sorunu#

Soft delete'in çirkinleştiği yer burası. UNIQUE (email) varsa ve bir kullanıcı hesabını soft-delete ederse, e-postası hâlâ tabloda. Aynı e-postayla tekrar kayıt olamaz.

Düzeltme partial unique index:

sql
CREATE UNIQUE INDEX uq_users_email_active
    ON users (email)
    WHERE deleted_at IS NULL;

Bu aynı zamanda uygulama kodunun soft-delete edilmiş bir varlığa referans verilen durumu da ele alması gerektiği anlamına gelir. Her join, her arama, her foreign key takibinde — referans verilen tabloda da deleted_at IS NULL kontrol etmeniz gerekir. Ya da kontrol etmezsiniz ve uygulamanız "[silinmiş kullanıcı] tarafından sipariş" gösterir, ki bu kime sorduğuna bağlı olarak ya bir bug ya da bir özellik.

Benim Yaklaşımım#

Soft delete'i sadece kurtarmanın bir iş gereksinimi olduğu kullanıcıya dönük varlıklar için kullanıyorum — kullanıcı hesapları, projeler, dokümanlar. Destek temsilcisinin bir silmeyi geri yüklemesi gerekebilecek şeyler. Geri kalan her şey için audit trail ile hard delete kullanıyorum (bununla ilgili devamı aşağıda).

sql
-- Soft delete: kullanıcıya dönük, kurtarılabilir
CREATE TABLE documents (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title       TEXT NOT NULL,
    content     TEXT NOT NULL,
    owner_id    UUID NOT NULL REFERENCES users(id),
    deleted_at  TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_documents_active ON documents (owner_id) WHERE deleted_at IS NULL;
 
-- Hard delete: dahili, UI'dan kurtarılamaz (ama denetlenen)
CREATE TABLE api_keys (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    key_hash    TEXT NOT NULL,
    last_used_at TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Silindiğinde gider. audit_log var olduğunu kaydeder.

Audit Trail'ler: Neyin Değiştiğini ve Kimin Yaptığını Bil#

Her ciddi uygulama bir noktada "bu kayda ne oldu?" sorusuna cevap vermek zorunda. Audit trail'ler bu cevabı uygulama log'larını kazmadan sağlar.

Ayrı Audit Tablosu Kalıbı#

En basit yaklaşım: her değişikliği kaydeden tek bir audit_log tablosu.

sql
CREATE TABLE audit_log (
    id              BIGSERIAL PRIMARY KEY,
    table_name      TEXT NOT NULL,
    record_id       TEXT NOT NULL,          -- hem UUID hem BIGINT PK'ları ele almak için text
    operation       TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
    old_values      JSONB,                  -- INSERT için NULL
    new_values      JSONB,                  -- DELETE için NULL
    changed_fields  TEXT[],                 -- hangi sütunlar değişti (UPDATE için)
    changed_by      UUID REFERENCES users(id),
    changed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    ip_address      INET,
    user_agent      TEXT
);
 
CREATE INDEX idx_audit_log_table_record ON audit_log (table_name, record_id);
CREATE INDEX idx_audit_log_changed_by ON audit_log (changed_by);
CREATE INDEX idx_audit_log_changed_at ON audit_log (changed_at);

Zorluk onu doldurmak. Uygulama kodunda yapabilirsin (açık, ama unutması kolay) veya trigger'larla (otomatik, ama changed_by gibi bağlam geçirmesi daha zor).

Trigger Tabanlı Yaklaşım#

Trigger'lar ham SQL veya veritabanı yönetici operasyonlarından bile her değişikliği otomatik olarak yakalar:

sql
CREATE OR REPLACE FUNCTION audit_trigger_function()
RETURNS TRIGGER AS $$
DECLARE
    old_data JSONB;
    new_data JSONB;
    changed  TEXT[];
    col      TEXT;
BEGIN
    IF TG_OP = 'DELETE' THEN
        old_data := to_jsonb(OLD);
        INSERT INTO audit_log (table_name, record_id, operation, old_values, changed_at)
        VALUES (TG_TABLE_NAME, OLD.id::TEXT, 'DELETE', old_data, NOW());
        RETURN OLD;
    ELSIF TG_OP = 'UPDATE' THEN
        old_data := to_jsonb(OLD);
        new_data := to_jsonb(NEW);
        -- Değişen alanları bul
        FOR col IN SELECT key FROM jsonb_each(new_data)
        LOOP
            IF old_data->col IS DISTINCT FROM new_data->col THEN
                changed := array_append(changed, col);
            END IF;
        END LOOP;
        -- Sadece gerçekten bir şey değiştiyse kaydet
        IF array_length(changed, 1) > 0 THEN
            INSERT INTO audit_log (table_name, record_id, operation, old_values, new_values, changed_fields, changed_at)
            VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'UPDATE', old_data, new_data, changed, NOW());
        END IF;
        RETURN NEW;
    ELSIF TG_OP = 'INSERT' THEN
        new_data := to_jsonb(NEW);
        INSERT INTO audit_log (table_name, record_id, operation, new_values, changed_at)
        VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'INSERT', new_data, NOW());
        RETURN NEW;
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;
 
-- Denetlemek istediğin tablolara uygula
CREATE TRIGGER audit_users
    AFTER INSERT OR UPDATE OR DELETE ON users
    FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();
 
CREATE TRIGGER audit_orders
    AFTER INSERT OR UPDATE OR DELETE ON orders
    FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();

Dezavantajı: trigger'lar hangi uygulama kullanıcısının değişikliği yaptığını bilmez. Bunu oturum değişkenleriyle aşabilirsin:

sql
-- Uygulamanda, sorgudan önce:
SET LOCAL app.current_user_id = 'uuid-of-current-user';
 
-- Trigger fonksiyonunda:
changed_by_id := current_setting('app.current_user_id', true)::UUID;

Bu çalışır ama kırılgan hisseder. Pratikte hibrit bir yaklaşım kullanıyorum: veri yakalama için trigger'lar, oturum bağlamını ayarlamak için uygulama kodu.

History Table Kalıbı#

Tam versiyon geçmişine ihtiyaç duyduğun tablolar için (sadece "ne değişti" değil "T anındaki durum neydi"), ayrılmış bir history tablosu daha temiz:

sql
CREATE TABLE documents (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title           TEXT NOT NULL,
    content         TEXT NOT NULL,
    version         INTEGER NOT NULL DEFAULT 1,
    owner_id        UUID NOT NULL REFERENCES users(id),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE document_history (
    id              BIGSERIAL PRIMARY KEY,
    document_id     UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    title           TEXT NOT NULL,
    content         TEXT NOT NULL,
    version         INTEGER NOT NULL,
    changed_by      UUID REFERENCES users(id),
    changed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (document_id, version)
);

documents'a yapılan her güncellemeden önce mevcut durumu document_history'ye kopyala ve versiyonu artır. Artık dokümanı herhangi bir zaman noktasında yeniden oluşturabilir, versiyonlar arası farkları gösterebilir, hatta eski versiyonları geri yükleyebilirsin.

Ödünleşim depolama. content sütunun büyükse ve sık değişiyorsa, history tablosu hızla büyüyebilir. Çoğu uygulama için bu sorun değil — depolama ucuz ve gerektiğinde eski versiyonları soğuk depolamaya arşivleyebilirsin.

Multi-Tenancy: Üç Yaklaşım, Acını Seç#

Multi-tenancy başta eklemesi kolay ve sonradan eklemesi neredeyse imkansız olan şeylerden biri. Uygulamanın birden fazla organizasyona hizmet edeceği ihtimali varsa, ilk günden inşa et.

Satır Seviyesi: Her Tabloda tenant_id#

En yaygın yaklaşım. Her tablonun tenant_id sütunu var ve her sorgu ona göre filtreliyor.

sql
CREATE TABLE tenants (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    slug        TEXT NOT NULL UNIQUE,
    plan        TEXT NOT NULL DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'enterprise')),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE projects (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   UUID NOT NULL REFERENCES tenants(id),
    name        TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE tasks (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   UUID NOT NULL REFERENCES tenants(id),
    project_id  UUID NOT NULL REFERENCES projects(id),
    title       TEXT NOT NULL,
    status      TEXT NOT NULL DEFAULT 'todo',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Her index sorgu performansı için tenant_id içermeli
CREATE INDEX idx_projects_tenant ON projects (tenant_id);
CREATE INDEX idx_tasks_tenant_project ON tasks (tenant_id, project_id);

Risk veri sızıntısı — bir eksik WHERE tenant_id = ... ve Kiracı A'nın verisini Kiracı B'ye gösteriyorsun. PostgreSQL'in Row-Level Security (RLS) bu bug sınıfını ortadan kaldırır:

sql
-- Tabloda RLS'yi etkinleştir
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
 
-- Oturum değişkenine dayalı bir politika oluştur
CREATE POLICY tenant_isolation ON projects
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Tablo sahipleri için bile RLS'yi zorla
ALTER TABLE projects FORCE ROW LEVEL SECURITY;

Artık uygulama kodun WHERE tenant_id = ... koşulunu unutsa bile, PostgreSQL otomatik olarak ekler. Bu derinlemesine savunma ve multi-tenant sistemlerde PostgreSQL lehine en güçlü argümanlardan biri.

sql
-- Uygulamanın bağlantı middleware'inde:
SET LOCAL app.current_tenant_id = 'tenant-uuid-here';
 
-- Artık RLS etkin tablolardaki tüm sorgular otomatik filtrelenir
SELECT * FROM projects;
-- PostgreSQL dahili olarak ekler: WHERE tenant_id = 'tenant-uuid-here'

Artılar: Tek veritabanı, basit operasyonlar, verimli kaynak kullanımı, yönetici için kolay kiracılar arası sorgular. Eksiler: Disiplin gerektirir (veya RLS), her sorgu tenant_id'ye dokunur, kiracılara kendi yedekleme/geri yükleme vermek daha zor.

Şema-Kiracı-Başına#

Her kiracı kendi PostgreSQL şemasını alır. Tüm şemalar aynı veritabanını paylaşır ama tablolar namespace ile izole.

sql
-- Her kiracı için bir şema oluştur
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;
 
-- Tablolar kiracının şemasında yaşar
CREATE TABLE tenant_acme.projects (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Mevcut kiracı için search_path'i ayarla
SET search_path TO tenant_acme, public;
 
-- Artık niteliksiz sorgular kiracının şemasına gider
SELECT * FROM projects;  -- tenant_acme.projects'i sorgular

Artılar: Güçlü izolasyon, kiracılar arası veri sızıntısı riski yok, kolay kiracı başına yedekleme/geri yükleme, kiracıların şema varyasyonları olabilir. Eksiler: Şema migration karmaşıklığı (N şemayı migrate etmen gerekir), connection pool yönetimi, ~10.000 şema civarında pratik limitler.

Veritabanı-Kiracı-Başına#

Her kiracı kendi veritabanını alır. Maksimum izolasyon.

Artılar: Tam izolasyon, bağımsız ölçekleme, kolay yedekleme/geri yükleme, büyük kiracıları özel donanıma yerleştirebilirsin. Eksiler: Bağlantı yönetimi kabusu, kiracılar arası sorgu imkansız, migration N kez çalıştırılmalı, ciddi operasyonel yük.

Hangisi?#

Çoğu SaaS uygulaması için: satır seviyesi + RLS ile başla. İşletmesi en basit olan bu ve RLS kullanım durumlarının büyük çoğunluğu için yeterince güçlü izolasyon sağlar. Şema-kiracı-başına'ya sadece sözleşmeli izolasyon gereksinimlerin varsa geç (kurumsal müşteriler, düzenlenmiş sektörler). Veritabanı-kiracı-başına fiziksel izolasyonu kesinlikle garanti etmen gerektiğinde — ve o zaman bile operasyonel yükün senin için karşılandığı yönetilen veritabanlarını düşün.

JSON/JSONB Sütunları: Kaçış Kapısı#

PostgreSQL'in JSONB'si olağanüstü. İlişkisel bir sistemin içinde doküman-veritabanı esnekliği verir. Ama her güçlü araç gibi kötüye kullanması kolay.

JSONB'nin Doğru Seçim Olduğu Durumlar#

Dinamik kullanıcı tanımlı nitelikler. Her ürün kategorisinin farklı nitelikleri olan e-ticaret platformu — ayakkabıların size ve color'ı var, elektroniklerin voltage ve wattage'ı var. EAV tablosu veya olası her nitelik için bir sütun yerine:

sql
CREATE TABLE products (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name            TEXT NOT NULL,
    category_id     UUID NOT NULL REFERENCES categories(id),
    price_cents     INTEGER NOT NULL CHECK (price_cents >= 0),
    attributes      JSONB NOT NULL DEFAULT '{}',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Örnek veri:
-- Ayakkabı: {"size": "10", "color": "black", "material": "leather"}
-- Laptop: {"ram_gb": 16, "storage_gb": 512, "screen_inches": 14.0}

Uygulama ayarları ve yapılandırma. Kullanıcı tercihleri, feature flag'ler, bildirim ayarları — sık şekil değiştiren ve ilişkisel bütünlüğe ihtiyaç duymayan şeyler.

sql
CREATE TABLE user_settings (
    user_id     UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
    preferences JSONB NOT NULL DEFAULT '{
        "theme": "system",
        "notifications": {"email": true, "push": false},
        "locale": "en",
        "timezone": "UTC"
    }',
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Webhook payload'ları, API yanıtları, olay verileri. Şemanın dış bir sistem tarafından kontrol edildiği ve haber vermeden değişebileceği her şey.

JSONB'de GIN Index'ler#

Index olmadan JSONB içinde sorgulamak tam tablo taraması gerektirir. GIN index'ler hızlandırır:

sql
-- Tüm JSONB sütununu indexle (@> containment sorguları için iyi)
CREATE INDEX idx_products_attributes ON products USING GIN (attributes);
 
-- Sorgu: color = "black" olan tüm ürünleri bul
SELECT * FROM products
WHERE attributes @> '{"color": "black"}';
 
-- Sorgu: bu niteliklerden herhangi birine sahip ürünleri bul
SELECT * FROM products
WHERE attributes ?| ARRAY['color', 'size'];

Sık sorguladığın belirli yollar için hedefli index daha verimli:

sql
-- Belirli bir yolu indexle
CREATE INDEX idx_products_color ON products ((attributes->>'color'));
 
-- Artık bu normal bir B-tree araması
SELECT * FROM products
WHERE attributes->>'color' = 'black';

Migration Tuzağı#

JSONB'nin seni ısırdığı yer: JSON içindeki alanlara kolayca NOT NULL constraint veya varsayılan değer ekleyemezsin. Ürün niteliklerine yeni bir zorunlu alan eklersen, mevcut her satırı geri doldurman gerekir. Normal bir sütunda migration bunu atomik olarak halleder. JSONB'de her satıra dokunan bir UPDATE yazıyorsun ve geri doldurma tamamlanana kadar uygulama kodunun eksik alanları zarif bir şekilde ele almasını umuyorsun.

sql
-- Yeni sütun eklemek: temiz, atomik, tek ifade
ALTER TABLE products ADD COLUMN weight_kg NUMERIC(10,3);
 
-- Yeni JSONB alanı eklemek: dağınık, geri doldurma gerektirir
UPDATE products
SET attributes = attributes || '{"weight_kg": null}'
WHERE NOT (attributes ? 'weight_kg');
-- Artı eksik weight_kg'yi ele almak için uygulama kodunu güncelle
-- Artı doğrulama mantığını güncelle
-- Artı nitelikleri içeren her API yanıtını güncelle

Kuralım: bir JSONB alanını WHERE koşulunda haftada ikiden fazla sorguluyorsan, muhtemelen bir sütun olmalı. JSONB harika bir kaçış kapısı. Korkunç bir varsayılan.

Şema Migration'ları: Gece 3'te Production'ı Kırma#

Şema migration'ları teorinin gerçeklikle buluştuğu yer. Şeman kağıt üzerinde harika görünüyor, ama şimdi 50 milyon satırlı bir tabloyu iş saatlerinde kesinti olmadan değiştirmen gerekiyor.

Migration Araçları#

Çoğunu kullandım. Kısa değerlendirmeler:

Drizzle (TypeScript): Mevcut favorim. Şema-kod-olarak, şemadan üretilen tip-güvenli sorgular, temiz migration SQL'i. Geliştirme için push komutu hızlı.

Prisma (TypeScript): Basit şemalar için harika DX. Gelişmiş PostgreSQL özellikleriyle (partial index'ler, özel tipler, RLS) zorlanır. Migration motoru şaşırtıcı kararlar verebilir.

Flyway (Java/CLI): Sağlam, savaşta test edilmiş, SQL-öncelikli. Ham SQL migration'ları yazıyorsan, Flyway güvenilir şekilde takip eder. Sihir yok, sürpriz yok.

golang-migrate (Go/CLI): Flyway'e benzer ama daha hafif. Go projeleri veya sadece basit bir up/down migration çalıştırıcı istediğinde harika.

Sıfır Kesintili Sorun#

En tehlikeli şema değişiklikleri tabloyu kilitleyen. PostgreSQL'de ALTER TABLE ... ADD COLUMN varsayılan değerle eskiden tablonun tamamını yeniden yazma boyunca kilitlerdi. PostgreSQL 11'den beri basit varsayılanlar (sabitler) metadata-only ve anlık. Ama diğer operasyonlar hâlâ kilitler:

sql
-- GÜVENLİ: metadata-only, anlık (PostgreSQL 11+)
ALTER TABLE users ADD COLUMN bio TEXT;
ALTER TABLE users ADD COLUMN is_premium BOOLEAN NOT NULL DEFAULT false;
 
-- TEHLİKELİ: tablonun tamamını yeniden yazar, tam tablo kilidi
ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(500);
ALTER TABLE users ADD COLUMN score INTEGER NOT NULL DEFAULT compute_score();
 
-- TEHLİKELİ: doğrulamak için tablonun tamamını tarar
ALTER TABLE orders ADD CONSTRAINT check_total CHECK (total_cents >= 0);

NOT NULL Sütunları Güvenle Eklemek#

Veri olan mevcut bir tabloya doğrudan NOT NULL ekleyemezsin — mevcut satırların değeri olmadığı için başarısız olur. Naif yaklaşım:

sql
-- Bu tabloyu kilitler ve yeniden yazar. Büyük tabloda yapma.
ALTER TABLE users ADD COLUMN avatar_url TEXT NOT NULL DEFAULT 'https://example.com/default.png';

PostgreSQL 11'den beri bu aslında sabit varsayılanlar için güvenli — metadata-only. Ama varsayılanın bir fonksiyonsa veya hesaplanmış değerlerle geri doldurman gerekiyorsa, expand-contract kalıbını kullan.

Expand-Contract Kalıbı#

Sıfır kesinti şema değişiklikleri için altın standart. Üç faz:

Faz 1: Genişlet — Yeni sütunu nullable olarak ekle. Hem eski hem yeni sütuna yazan uygulama kodunu deploy et.

sql
-- Migration 1: Nullable sütunu ekle
ALTER TABLE users ADD COLUMN normalized_email TEXT;

Faz 2: Migrate — Mevcut satırları toplu olarak geri doldur. Uygulamanız yeni veriler için zaten yeni sütuna yazıyor.

sql
-- Migration 2: Toplu geri doldur (büyük tablolar için bunu tek ifadede yapma)
UPDATE users SET normalized_email = LOWER(TRIM(email))
WHERE normalized_email IS NULL
LIMIT 10000;
-- Tüm satırlar dolana kadar tekrarla

Faz 3: Daralt — Tüm satırlar dolduğunda NOT NULL constraint ekle ve eski sütunu kaldır (varsa).

sql
-- Migration 3: Constraint ekle (tam tablo taramasından kaçınmak için NOT VALID kullan, sonra ayrıca doğrula)
ALTER TABLE users ADD CONSTRAINT users_normalized_email_not_null
    CHECK (normalized_email IS NOT NULL) NOT VALID;
 
-- Migration 4: Constraint'i doğrula (AccessExclusiveLock değil ShareUpdateExclusiveLock alır)
ALTER TABLE users VALIDATE CONSTRAINT users_normalized_email_not_null;

NOT VALID + VALIDATE CONSTRAINT hilesi kritik. Check constraint'i normal eklemek ağır kilit tutarken tablonun tamamını tarar. NOT VALID constraint'i taramadan ekler (sadece yeni yazmalara uygulanır) ve VALIDATE CONSTRAINT okumaları veya yazmaları engellemeden daha hafif bir kilitle tarar.

Index Oluşturma#

Büyük tablolarda index oluşturmak varsayılan olarak yazmaları engeller. Her zaman CONCURRENTLY kullan:

sql
-- YAZMALARI ENGELLER: canlı tabloda yapma
CREATE INDEX idx_users_email ON users (email);
 
-- ENGELLEMİYOR: bunu kullan
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);

CONCURRENTLY daha uzun sürer ve bir transaction içinde çalıştırılamaz, ama tabloyu kilitlemez. Ödünleşim production'da her zaman buna değer.

Pişman Olduğum Kararlar#

Her şemanın pişmanlıkları var. İşte benimkiler.

Dış ID'ler İçin UUID Yerine SERIAL#

Bir projenin başında SERIAL primary key kullandım ve URL'lerde doğrudan gösterdim: /users/42, /orders/1337. Bu bilgi sızdırdı (rakipler kullanıcı sayımızı tahmin edebilirdi), sayfalama tahmin edilebilirdi ve iki bölgenin veritabanlarını birleştirmemiz gerektiğinde bozuldu. Dışa dönük ID'ler için UUID'lere geçiş aylar süren bir migration gerektirdi.

Ders: veritabanının dışına çıkan her şey için UUID kullan. Dahili join tabloları için istersen SERIAL/BIGSERIAL kullan, ama otomatik artan bir tam sayının bir URL'de görünmesine asla izin verme.

Constraint Yok ("Sonra Ekleriz")#

Constraint olmadan bir tablo yayınladık çünkü "hızlı gidiyoruz ve sonra ekleriz." İki hafta içinde veride negatif fiyatlar, boş isimler ve test sırasında birinin yazdığı ve asla temizlemediği "oof" değerinde bir order_status vardı.

Sonradan constraint eklemek gerektirdi:

  1. Tüm geçersiz veriyi bulmak
  2. Ne yapılacağına karar vermek (düzeltme, silme veya eski haliyle bırakma)
  3. Veriyi geri dolduran/düzelten VE constraint'i ekleyen bir migration yazmak

Bu, constraint'i ilk gün eklemekten daha uzun sürdü. Constraint'lerle başla. Gerekirse gevşet. Asla tersi değil.

Virgülle Ayrılmış String'ler Yerine Diziler veya Join Tabloları#

Bir keresinde tag'leri virgülle ayrılmış string olarak sakladım: "javascript,react,nextjs". Sorgulamak kabusa döndü:

sql
-- Virgülle ayrılmış değerleri böyle sorgularsın. Bunu yapma.
SELECT * FROM posts WHERE tags LIKE '%react%';
-- Bu aynı zamanda "react-native" ve "preact" ile de eşleşir
 
-- Yapmam gereken:
CREATE TABLE post_tags (
    post_id     UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    tag_id      UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
    PRIMARY KEY (post_id, tag_id)
);

Ya da en azından PostgreSQL'in native dizi tipini kullanmalıydım:

sql
CREATE TABLE posts (
    id      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title   TEXT NOT NULL,
    tags    TEXT[] NOT NULL DEFAULT '{}',
    -- ...
);
 
CREATE INDEX idx_posts_tags ON posts USING GIN (tags);
 
-- Temiz sorgular
SELECT * FROM posts WHERE 'react' = ANY(tags);
SELECT * FROM posts WHERE tags @> ARRAY['react', 'nextjs'];

Diziler kendi niteliklerine ihtiyaç duymayan basit listeler için kabul edilebilir. İlişki üzerinde metadata'ya ihtiyaç duyduğun anda ("bu tag'i kim ekledi" veya "ne zaman eklendi"), join tablosu gerekir.

Sütun Adı Olarak "type"#

sql
-- O sırada iyi görünmüştü
CREATE TABLE notifications (
    id      BIGSERIAL PRIMARY KEY,
    type    TEXT NOT NULL,  -- "email", "sms", "push"
    -- ...
);
 
-- Sonra herhangi bir ORM'de sorgulamaya çalışırsın:
-- notification.type  ← her dilin tip sistemiyle çakışır
-- "type" çoğu SQL lehçesinde ayrılmış kelime
-- Her yerde tırnak içine almak zorunda kalırsın: SELECT "type" FROM notifications

kind, category veya notification_type kullan. type hariç her şey. Benzer şekilde order'dan (yerine sort_order veya position), user'dan (yerine account veya ön ekle) ve group'tan (yerine team veya group_name) kaçın.

Baştan created_at Eklememek#

Bir tablo created_at almadı çünkü "ihtiyacımız yok." Üç ay sonra bir sorunu debug etmemiz gerekti ve kayıtların ne zaman oluşturulduğu hakkında hiçbir fikrimiz yoktu. Geriye dönük eklemek tüm mevcut satırların aynı timestamp'i (migration timestamp'i) alması anlamına geliyordu ve tarihsel veriyi yararsız kılıyordu.

Her tablo created_at alır. İstisna yok. Maliyet bir sütun. Olmamasının maliyeti ihtiyaç duyana kadar bilinmez.

İş Mantığını Veritabanı View'larına Koymak#

Bir keresinde bir view zinciri oluşturdum — active_users users'ı filtreledi, premium_active_users active_users'ı filtreledi ve bir raporlama view'ı hepsini join'ledi. Birisi users tablosunu değiştirene kadar harika çalıştı ve üç view sessizce bozuldu. Query planner da birden fazla view katmanı arasında optimize etmekte zorlandı.

View'lar kolaylık ve salt-okunur erişim kalıpları için harika. Değişen iş mantığını kodlamak için korkunç. İş kurallarını versiyonlanan, test edilen ve deploy edilebilen uygulama kodunda tut. View'ları raporlama kısayolları olarak kullan, mimari yapı taşları olarak değil.

Hepsini Bir Araya Getirmek#

İşte bu kalıpları birleştirdiğinde iyi tasarlanmış bir şema nasıl görünür. Basitleştirilmiş bir proje yönetim sistemi:

sql
-- Extension'lar
CREATE EXTENSION IF NOT EXISTS "pgcrypto";  -- gen_random_uuid() için
 
-- Updated_at trigger fonksiyonu (tekrar kullanılabilir)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
-- Kiracılar
CREATE TABLE tenants (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name            TEXT NOT NULL CHECK (LENGTH(name) > 0),
    slug            TEXT NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9-]+$'),
    plan            TEXT NOT NULL DEFAULT 'free'
                    CHECK (plan IN ('free', 'pro', 'enterprise')),
    settings        JSONB NOT NULL DEFAULT '{}',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON tenants
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
-- Kullanıcılar
CREATE TABLE users (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    email           TEXT NOT NULL,
    display_name    TEXT NOT NULL CHECK (LENGTH(display_name) > 0),
    password_hash   TEXT NOT NULL,
    is_active       BOOLEAN NOT NULL DEFAULT true,
    is_verified     BOOLEAN NOT NULL DEFAULT false,
    last_login_at   TIMESTAMPTZ,
    deleted_at      TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE UNIQUE INDEX uq_users_email_per_tenant
    ON users (tenant_id, email)
    WHERE deleted_at IS NULL;
 
CREATE INDEX idx_users_tenant ON users (tenant_id) WHERE deleted_at IS NULL;
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON users
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
-- RLS'yi etkinleştir
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Projeler
CREATE TABLE projects (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    name            TEXT NOT NULL CHECK (LENGTH(name) > 0),
    description     TEXT NOT NULL DEFAULT '',
    status          TEXT NOT NULL DEFAULT 'active'
                    CHECK (status IN ('active', 'archived', 'deleted')),
    owner_id        UUID NOT NULL REFERENCES users(id),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_projects_tenant ON projects (tenant_id);
CREATE INDEX idx_projects_owner ON projects (owner_id);
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON projects
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Görevler
CREATE TABLE tasks (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    project_id      UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    title           TEXT NOT NULL CHECK (LENGTH(title) > 0),
    description     TEXT NOT NULL DEFAULT '',
    status          TEXT NOT NULL DEFAULT 'todo'
                    CHECK (status IN ('todo', 'in_progress', 'review', 'done')),
    priority        INTEGER NOT NULL DEFAULT 0 CHECK (priority BETWEEN 0 AND 4),
    assignee_id     UUID REFERENCES users(id) ON DELETE SET NULL,
    due_at          TIMESTAMPTZ,
    completed_at    TIMESTAMPTZ,
    sort_order      INTEGER NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_tasks_tenant_project ON tasks (tenant_id, project_id);
CREATE INDEX idx_tasks_assignee ON tasks (assignee_id) WHERE assignee_id IS NOT NULL;
CREATE INDEX idx_tasks_status ON tasks (tenant_id, status) WHERE status != 'done';
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON tasks
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON tasks
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Görev yorumları
CREATE TABLE task_comments (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    task_id         UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
    author_id       UUID NOT NULL REFERENCES users(id),
    body            TEXT NOT NULL CHECK (LENGTH(body) > 0),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_task_comments_task ON task_comments (task_id);
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON task_comments
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
ALTER TABLE task_comments ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON task_comments
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Audit log (RLS yok — sadece yönetici tablosu)
CREATE TABLE audit_log (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    table_name      TEXT NOT NULL,
    record_id       TEXT NOT NULL,
    operation       TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
    old_values      JSONB,
    new_values      JSONB,
    changed_fields  TEXT[],
    changed_by      UUID REFERENCES users(id),
    changed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_audit_log_tenant_table ON audit_log (tenant_id, table_name, record_id);
CREATE INDEX idx_audit_log_changed_at ON audit_log (changed_at);

Kalıplara dikkat et:

  • Her tablonun tenant_id'si ve RLS'si etkin (tenants ve audit_log hariç).
  • Her tablonun trigger'larla created_at ve updated_at'i var.
  • Tüm primary key'ler için UUID'ler (dışa dönük varlıklar).
  • Durum enum'ları, uzunluklar ve aralıklar için check constraint'ler.
  • Uygun ON DELETE davranışıyla foreign key'ler.
  • Erişim kalıpları için tasarlanmış index'ler (tenant + project, tenant + status).
  • Soft delete sadece users'da (hesap kurtarma gerektiği yerde), projects'de durum tabanlı arşivleme.

Son Düşünceler#

Şema tasarımı gösterişli değil. Hiç kimse "Her Tabloya Check Constraint Ekledim ve Bize Altı Aylık Debug'ı Kurtardı" başlıklı bir konferans konuşması vermedi. Ama iyi şema tasarımının yaptığı tam olarak bu — sorunları o kadar sessizce önler ki var olacaklarını bile bilmezsin.

Bu yazıdaki kalıplar yeni değil. Yıllar boyu migration yazma, veri bozulması debug etme ve production yükü altında şemaları yeniden düzenleme sonucu ortaya çıktı. Her biri, benim veya birlikte çalıştığım birinin önce diğer yolu deneyip bedelini ödemiş olması yüzünden var.

Constraint'lerle başla. Foreign key kullan. Her şeye created_at ekle. Bir isimlendirme kuralı seç ve acımasızca uygula. Multi-tenancy için RLS kullan. JSONB konusunda dikkatli ol. Migration'larını deploy etmeden önce production boyutundaki veriyle test et.

Veritabanı temeldir. Doğru yap ve üzerine inşa edilen her şey basitleşir. Yanlış yap ve hiçbir zekice uygulama kodu seni kurtaramaz.

İlgili Yazılar