Developer TurkeyBlog

Node.js Projesinde Prisma ve PostgreSQL ile Şema Tasarımı

Ben Enes Şahin, 2022'den beri üretimde çalışan bir e-ticaret backend'inde Prisma ve PostgreSQL kullanıyorum. Bu yazıda bir şemayı sıfırdan tasarlarken önemli gördüğüm kararları, örnek bir sipariş şemasıyla anlatıyorum.

Neden Prisma

Prisma, veritabanı şemasını tek bir dosyada (schema.prisma) tanımlamanı, oradan hem migration'ları hem de tip güvenli bir istemci (client) üretmeni sağlayan bir ORM. Ham SQL yazmaya göre avantajı, şemadaki bir alanı değiştirdiğinde TypeScript derleyicisinin onu kullanan her yeri anında sana göstermesi; bir ilişkiyi yanlış kurarsan bunu üretimde değil, editörde fark ediyorsun.

Modelleri ilişkilerle kurmak

Bir sipariş sisteminde kullanıcı, sipariş ve sipariş kalemi arasındaki ilişki şöyle görünüyor:

schema.prisma
model User {
  id     String  @id @default(cuid())
  email  String  @unique
  role   Role    @default(CUSTOMER)
  orders Order[]
}
 
model Order {
  id         String      @id @default(cuid())
  userId     String
  user       User        @relation(fields: [userId], references: [id])
  status     OrderStatus @default(PENDING)
  items      OrderItem[]
  totalCents Int
  createdAt  DateTime    @default(now())
 
  @@index([userId])
}
 
model OrderItem {
  id        String  @id @default(cuid())
  orderId   String
  order     Order   @relation(fields: [orderId], references: [id], onDelete: Cascade)
  productId String
  quantity  Int
  priceCents Int
}
 
enum Role {
  CUSTOMER
  ADMIN
}
 
enum OrderStatus {
  PENDING
  PAID
  SHIPPED
  CANCELLED
}

Burada birkaç kararın altını çizmek istiyorum:

  • Parayı Int (cent cinsinden) tutmak, Float kullanmamak gerekir. Kayan noktalı sayılar parasal hesaplamalarda yuvarlama hatası yapabiliyor; 10.10 TL gibi bir tutar Float ile bazen 10.099999999 olarak saklanabiliyor. Cent'e çevirip tam sayı olarak tutmak bu sınıf hatayı tamamen ortadan kaldırıyor.
  • OrderItem, Order silinince onDelete: Cascade ile otomatik siliniyor, ama Order, User silinince silinmiyor (cascade yazmadım). Bir siparişin kalemleri sipariş olmadan anlamsız, ama bir kullanıcı silinse bile sipariş geçmişinin durması gerekiyor; muhasebe ve iade süreçleri buna bağlı.
  • @@index([userId]): "kullanıcının siparişlerini getir" bir sipariş sisteminde en sık çalışan sorgulardan biri, bu yüzden userId üzerinde açık bir indeks tanımlamak mantıklı. Prisma foreign key için otomatik indeks eklemiyor, bunu elle yazman gerekiyor.

Migration akışı

Şemada değişiklik yaptığında:

npx prisma migrate dev --name add_order_status

Bu komut hem yeni bir SQL migration dosyası üretiyor hem de yerel veritabanına uyguluyor. Migration dosyalarını git'e commit etmek gerekir; production'a çıkarken prisma migrate deploy ile aynı dosyalar sırayla uygulanıyor. Böylece yerel şema ile production'daki şema arasında fark kalmıyor, "bende çalışıyordu" durumları migration seviyesinde ortadan kalkıyor.

N+1 sorgu tuzağı

Prisma'da ilişkili veriyi çekerken en sık yapılan hata, döngü içinde sorgu atmak:

// Kötü: her sipariş için ayrı bir sorgu atıyor (N+1)
const orders = await prisma.order.findMany();
for (const order of orders) {
  order.items = await prisma.orderItem.findMany({ where: { orderId: order.id } });
}

Bunun yerine include ile ilişkiyi tek sorguda getiriyorum:

// İyi: tek sorguda, join ile
const orders = await prisma.order.findMany({
  include: { items: true },
});

100 siparişlik bir listede ilki 101 sorgu atarken ikincisi tek sorgu atıyor. Küçük veri setlerinde fark görünmüyor, ama liste büyüdükçe ilk yaklaşım gerçek bir performans sorununa dönüşüyor.

Transaction ile veri bütünlüğü

Bir sipariş oluştururken hem Order kaydı açılıyor hem stok düşülüyor. Bu iki işlemden biri başarılı olup diğeri başarısız olursa (örneğin stok yetersizse) veritabanı tutarsız bir duruma düşer. Bunu $transaction ile engellemek gerekir:

await prisma.$transaction(async (tx) => {
  const product = await tx.product.findUniqueOrThrow({ where: { id: productId } });
  if (product.stock < quantity) throw new Error("Stok yetersiz");
 
  await tx.product.update({
    where: { id: productId },
    data: { stock: { decrement: quantity } },
  });
 
  await tx.order.create({
    data: { userId, totalCents, items: { create: [{ productId, quantity, priceCents }] } },
  });
});

Transaction içindeki herhangi bir adım hata fırlatırsa, o ana kadar yapılan tüm değişiklikler geri alınıyor. Stok düşürülüp sipariş oluşturulamadan yarıda kalmış bir kayıt riski böylece ortadan kalkıyor.

Sonuç

Bir Prisma şeması tasarlarken önemli gördüğüm dört karar: parasal alanları tam sayı olarak tutmak, cascade kurallarını verinin gerçek yaşam döngüsüne göre seçmek, sık sorgulanan alanlara indeks eklemeyi unutmamak ve birden fazla adımı olan işlemleri transaction'a almak. Bunların hiçbiri Prisma'ya özgü değil, ama Prisma bu kararları şemada açıkça görünür kılıyor.

Sık sorulan sorular

Parayı veritabanında neden Float yerine Int tutmalıyım?

Kayan noktalı sayılar yuvarlama hatası yapabilir; 10.10 gibi bir tutar 10.099999999 olarak saklanabilir. Tutarı kuruş (cent) cinsinden tam sayı tutmak bu hatayı ortadan kaldırır.

Prisma foreign key için otomatik indeks oluşturur mu?

Hayır, Prisma foreign key alanları için otomatik indeks eklemez. Sık sorguladığın alanlara @@index ile elle indeks tanımlaman gerekir.

Prisma'da N+1 sorgu problemi nedir?

Döngü içinde her kayıt için ayrı sorgu atmaktır; 100 kayıt için 101 sorgu çalışır. include ile ilişkili veriyi tek sorguda getirerek önlenir.

Prisma'da $transaction ne zaman kullanılır?

Birden fazla adımı olan ve biri başarısız olursa hepsinin geri alınması gereken işlemlerde, örneğin sipariş oluştururken stok düşmek gibi. Adımlardan biri hata verirse o ana kadar yapılan değişiklikler geri alınır.

Bu konuda takıldın mı, ya da ekibine geliştirici mi arıyorsun?

Yazıyla ilgili sorunu ya da iş teklifini bana yaz.

İletişime geç
Tüm yazılar