ElementAPI mimarisi

ElementAPI Türkçe bir kimya atlasıdır: 118 element, 214 bileşik ve kısa öğrenme rotaları. Yanında sanal bir ticaret demosu çalışır (para birimi KREDI, gerçek para ve kargo yok). Bu sayfa projenin nasıl kurulduğunu anlatır: hangi servis ne yapar, servisler nasıl konuşur, veri nasıl tutarlı kalır.

Kaynak kod · Lisans MIT

Genel görünüm

Tarayıcı yalnızca web uygulamasıyla ve API kapısıyla (gateway) konuşur. Gateway istekleri servislere iletir. Servisler birbirine doğrudan çağrı yapmak yerine RabbitMQ üzerinden olay gönderir. Her servisin kendi veritabanı vardır; başka bir servisin tablosuna kimse dokunmaz.

İstemci
web-appReact 19 · :6241
Açık API kullanıcıları/api/v2, API anahtarı
↓ HTTP
Giriş
gateway.NET · YARP · :5000 · Redis ile hız sınırı
↓ HTTP
Servisler
identity.NET · :5001
catalog.NET · :5002
compound.NET · :5007
orderNode/TS · :5003 · saga
walletJava · :5005
inventoryJava · :5008
shipment.NET · :5004
notification.NET · :5006
↕ olaylar (MassTransit zarfı)
Altyapı
PostgreSQL 16servis başına ayrı veritabanı
RabbitMQservisler arası olaylar
Redisyalnız gateway

Atlası tek başına açmak için ayrı bir science servisi de var (docker-compose.science.yml, :5080). Veritabanı ve broker kullanmaz.

Servisler

ServisDilNe yaparKorunan kural
gateway.NET 10, YARPTek giriş noktası: yönlendirme, kimlik doğrulama, hız sınırıDurum tutmaz
identity.NET 10Hesap, oturum, API anahtarı, webhook aboneliğiAnahtar ve webhook sayısı sınırı, iptal edilen anahtar bir daha çalışmaz
catalog.NET 10Element verisi, piyasa fiyatlarıFiyat her tamamlanan sipariş için bir kez hareket eder
compound.NET 10Bileşik verisi (salt okuma)Yok; yalnızca testler
orderNode 22, TypeScriptSiparişi alır, saga'yı yönetirSipariş yalnızca izin verilen geçişlerle ilerler
walletJava 21, Spring BootKREDI bakiyesi, varlıklar, defterBakiye eksiye düşmez; bir sipariş bir kez tahsil, bir kez iade edilir
inventoryJava 21, Spring BootStok ve rezervasyonStok asla fazla satılmaz; rezervasyon bir kez kapanır
shipment.NET 10Gönderim kaydı (simülasyon)Bir sipariş için gönderim bir kez açılır
notification.NET 10E-posta ve webhook bildirimleriHer webhook X-Element-Delivery kimliği taşır, alıcı tekrarı atabilir

Sipariş akışı (saga)

Bir sipariş beş servise dokunur ama hiçbiri ötekinin veritabanına yazmaz. Dağıtık bir transaction yerine order servisi adımları sırayla yönetir (orkestrasyon). Her adım, kendi servisinde ayrı bir yerel transaction'dır. Bir adım başarısız olursa order, önceki adımları geri alan telafi olaylarını gönderir.

Durumlar

Submitted ──StockReserved──▶ StockReserved ──PaymentProcessed──▶ Shipping ──ShipmentDispatched──▶ Completed
    │                              │                                 │
    └─ StockReservationFailed      └─ PaymentFailed                  └─ ShipmentFailed
       veya zaman aşımı ─────────────── veya zaman aşımı ──────────────── veya zaman aşımı ──▶ Failed

Completed ve Failed son durumlardır, bir daha değişmez. Geçiş tablosu tek bir yerde durur (order-service/src/domain/sagaTransitions.ts); tabloya uymayan, geç ya da tekrar gelen olay sipariş üzerinde hiçbir şey değiştirmez.

Hangi olayı kim dinler

OlayGönderenDinleyen ve yaptığı
OrderSubmittedEventorderinventory: stok ayırır
StockReservedEvent / StockReservationFailedEventinventoryorder: ödemeye geçer / siparişi düşürür
PaymentRequestedEventorderwallet: KREDI çeker
PaymentProcessedEvent / PaymentFailedEventwalletorder: gönderime geçer / siparişi düşürür
ShipmentRequestedEventordershipment: gönderim kaydı açar
ShipmentDispatchedEvent / ShipmentFailedEventshipmentorder: tamamlar / siparişi düşürür
AssetsCreditedEventorderwallet: alınan gramı kullanıcının varlıklarına ekler
OrderCompletedEventorderinventory: ayrılan stoğu kesin düşer · catalog: fiyatı bir kez yukarı iter
OrderStockReleaseEventorder (telafi)inventory: ayrılan stoğu geri bırakır
PaymentRefundRequestedEventorder (telafi)wallet: çekilen KREDI'yi iade eder (çekilmediyse hiçbir şey yapmaz)
UpdateOrderStatusEventordernotification: kullanıcıya e-posta ve webhook gönderir
ElementSoldEventwalletinventory: stoğa geri ekler · catalog: fiyatı bir kez aşağı iter

Örnek senaryolar

Aşağıdaki senaryolar sistemin hem normal akışta hem de bir şeyler ters gittiğinde ne yaptığını adım adım gösterir.

1. Mutlu yol: 10 g altın siparişi
  1. Tarayıcı POST /api/v1/orders isteğini Idempotency-Key başlığıyla gönderir. Gateway oturumu doğrular ve isteği order'a iletir.
  2. order fiyatı ve görünen stoğu catalog'dan, bakiyeyi wallet'tan sorar. Stok yetmiyorsa hemen 409, bakiye yetmiyorsa 402 döner. Bu ağ çağrıları transaction'dan önce yapılır; transaction açıkken başka bir servisi beklemek kilitleri uzatırdı.
  3. order tek transaction'da siparişi Submitted olarak yazar, aynı transaction'da outbox tablosuna iki olay ekler: UpdateOrderStatusEvent ve OrderSubmittedEvent. Kullanıcı hemen 202 Accepted alır; gerisi arka planda yürür.
  4. Outbox relay commit edilmiş satırları RabbitMQ'ya yayımlar ve broker onayını bekler.
  5. inventory, Au stok satırını kilitler (FOR UPDATE), 10 g ayırır, mesaj kimliğini inbox'a ve StockReservedEvent'i outbox'a aynı transaction'da yazar.
  6. order siparişi kilitler, StockReserved durumuna geçer ve PaymentRequestedEvent yazar.
  7. wallet cüzdan satırını kilitler, bakiyeyi kontrol eder, KREDI'yi çeker, deftere kayıt atar, PaymentProcessedEvent yazar.
  8. order Shipping durumuna geçer; shipment gönderim kaydını açıp TRK-… takip numarasıyla ShipmentDispatchedEvent gönderir.
  9. order Completed olur. AssetsCreditedEvent ile wallet 10 g altını varlıklara ekler; OrderCompletedEvent ile inventory ayrılan stoğu kesin düşer, catalog altının fiyatını bir kez yukarı iter.
  10. Her durum değişikliğinde notification kullanıcıya bildirim gönderir.
2. Stok yetmiyor

order, istek anında stoğu zaten kontrol eder. Bu senaryo kontrolle rezervasyon arasında başka bir siparişin stoğu almasıyla oluşur. Ön kontrol yalnızca bir kolaylıktır; asıl kararı stok satırını kilitleyen inventory verir.

  1. inventory stok satırını kilitler ve stok − ayrılmış < istenen olduğunu görür.
  2. Hiçbir şey ayırmadan StockReservationFailedEvent gönderir.
  3. order siparişi Failed yapar ve telafi olaylarını yazar: stok bırakma ve iade.
  4. İki telafi de bu durumda boşa çalışır: ayrılmış stok ve çekilmiş KREDI yoktur. Telafiler idempotent yazıldığı için "gerekmeyen telafi" zarar vermez. Order hangi adımın gerçekten yapıldığını takip etmek zorunda kalmaz.
3. Bakiye yetmiyor

Bu durum da ön kontrolden sonra bakiye değişirse oluşur (örneğin aynı anda verilen ikinci bir sipariş).

  1. Stok ayrıldı, order PaymentRequestedEvent gönderdi.
  2. wallet cüzdanı kilitler; bakiye yetmediği için Wallet aggregate'i tahsilatı reddeder ve INSUFFICIENT_ELX nedeniyle PaymentFailedEvent gönderir.
  3. order Failed olur; OrderStockReleaseEvent ile inventory ayrılan 10 g'ı geri bırakır.
  4. Kullanıcı "Ödeme başarısız" bildirimini alır. Bakiyesi ve stok, sipariş hiç verilmemiş gibi kalır.
4. Kredi tavanını aşan sipariş

Toplam 50.000 KREDI'yi aşarsa order isteği HTTP katmanında 400 ile reddeder; saga hiç başlamaz. Aynı kural Order aggregate'inde ve wallet'ta da var. Biri atlansa bile öteki yakalar.

5. Aynı istek iki kez geldi (çift tıklama, ağ kopması)
  1. İstemci aynı Idempotency-Key ile isteği tekrarlar.
  2. order bu anahtarı sipariş kimliği olarak kullandığı için mevcut siparişi bulur ve onu 200 ile döner. İkinci sipariş oluşmaz, KREDI iki kez çekilmez.
  3. Aynı anahtar farklı bir sipariş gövdesiyle gelirse yanıt 409 olur.
6. Aynı mesaj iki kez geldi
  1. wallet KREDI'yi çekti, commit etti, ama RabbitMQ'ya onay (ack) göndermeden çöktü.
  2. RabbitMQ PaymentRequestedEvent'i yeniden teslim eder.
  3. wallet yeni transaction'da mesaj kimliğini processed_messages tablosuna yazmaya çalışır; kimlik zaten var, işlem orada durur. KREDI ikinci kez çekilmez.
  4. catalog'da aynı koruma sipariş başına bir işaretle yapılır: fiyat bir sipariş için yalnızca bir kez hareket eder.
7. Broker kapalıyken sipariş verildi
  1. RabbitMQ yeniden başlıyor. order siparişi ve outbox satırlarını yine de commit eder; kullanıcı yanıtını alır.
  2. Relay yayımlayamaz, satırlar pending kalır. Hiçbir olay kaybolmaz.
  3. Broker döndüğünde relay satırları sırayla yayımlar ve her birini broker onayından sonra işaretler.
  4. Outbox olmasaydı iki kötü sonuçtan biri olurdu: önce yayımla sonra commit et (commit başarısızsa var olmayan bir sipariş için olay gider) ya da önce commit et sonra yayımla (yayım başarısızsa sipariş sonsuza kadar Submitted kalır).
8. Son 5 gram için iki eş zamanlı sipariş
  1. İki OrderSubmittedEvent aynı anda inventory'ye ulaşır.
  2. İkisi de aynı stok satırını FOR UPDATE ile kilitlemeye çalışır; ikincisi birincinin commit'ini bekler.
  3. Birinci 5 g'ı ayırır. İkinci, kilidi aldığında güncel değeri okur, stok kalmadığını görür ve StockReservationFailedEvent gönderir.
  4. Kod bir gün yanlış yazılsa bile veritabanındaki stock_items_never_oversold CHECK kısıtı fazla satışı reddeder. Bu senaryo Testcontainers ile gerçek PostgreSQL üzerinde test ediliyor; kilit kaldırıldığında test kırılıyor.
9. Bir servis cevap vermiyor (zaman aşımı)
  1. shipment servisi kapalı; sipariş Shipping durumunda bekliyor. KREDI çekilmiş, stok ayrılmış.
  2. order'daki süpürücü düzenli aralıklarla süresi geçmiş saga'ları arar. Varsayılan süreler: Submitted 120 sn, StockReserved 120 sn, Shipping 180 sn.
  3. Süresi geçen siparişi kilitler, süreyi kilit altında yeniden kontrol eder (bu arada gerçek bir olay gelip siparişi ilerletmiş olabilir), sonra Failed yapar ve stok bırakma + iade gönderir.
  4. shipment geri gelip geç bir ShipmentDispatchedEvent gönderirse order bunu yok sayar; Failed son durumdur. Düşen bir siparişe geç gelen ödeme onayı ise yalnızca kendi iadesini tetikler.
10. Varlık satışı (masadan satış)
  1. Kullanıcı POST /api/v1/desk/sell ile 3 g altın satar.
  2. wallet tek transaction'da varlığı azaltır, alış fiyatı (bid) üzerinden KREDI ekler, deftere yazar ve outbox'a ElementSoldEvent koyar.
  3. inventory 3 g'ı stoğa geri ekler; catalog altının fiyatını bir kez aşağı iter. İkisi de satışı bir kez işlemek için mesaj kimliğine bakar.
11. Sunucu yeniden başladı
  1. Docker bütün konteynerleri aynı anda başlatır; PostgreSQL birkaç saniye geç hazır olur.
  2. Servisler ilk bağlantı hatasını "geçici" olarak tanır ve 2 saniye arayla 30 kez dener. Yanlış şifre ya da bozuk migration gibi beklemekle düzelmeyecek hatalarda hemen durur, hata loglarda açıkça görünür.
  3. Bu sayede servisler restart döngüsüne girmez. Test: PostgreSQL 20 sn kapatıldığında order dışındaki servislerin restart sayısı 0 kaldı. order bilerek bir kez yeniden başlar; o sırada gelen mesajlar RabbitMQ'da bekler.

Tutarlılık kuralları (ACID)

Kurallar ADR 0003 içinde yazılı ve bütün servislerde uygulanıyor.

Bir iş, bir transaction

Her kullanım senaryosu tek bir veritabanı transaction'ıdır. Önce ilgili satır kilitlenir (SELECT … FOR UPDATE), karar ondan sonra verilir. Transaction içinde ağ çağrısı yapılmaz.

Transactional outbox

Giden olay, durum değişikliğiyle aynı transaction'da bir tabloya yazılır. Ayrı bir relay commit edilmiş satırları yayımlar ve broker onayını bekler. Veritabanı ile broker arasında "biri yazıldı, öteki yazılmadı" durumu oluşmaz.

Inbox

Olaylar en az bir kez gelir, yani tekrar gelebilir. Alıcı mesaj kimliğini işle aynı transaction'da kaydeder; aynı mesaj ikinci kez gelirse atlanır.

Veritabanı da korur

CHECK kısıtları ve tekil indeksler uygulama hatasına karşı son savunmadır. Kısıtlar NOT VALID eklenir, böylece eski bir satır açılışı bozup servisi restart döngüsüne sokmaz.

Para ondalık sayıdır

Para ve miktar her yerde BigDecimal, decimal veya NUMERIC olarak tutulur, double kullanılmaz.

Açılışta bekleme

Sunucu yeniden başladığında Docker bütün konteynerleri aynı anda açar. Servisler PostgreSQL hazır olana kadar bekler (30 deneme × 2 sn), çöküp yeniden başlamaz.

Servis içi katmanlar (DDD)

Korunacak bir kuralı olan servislerde kod üç katmana ayrılır; bağımlılıklar içe doğru akar.

KatmanİçerikNeye bağımlı
domainAggregate, entity, değer nesneleri (Wallet, Money, StockItem, Order)Hiçbir şeye: framework, SQL, broker yok
applicationMetot başına bir kullanım senaryosu; port arayüzleri (Repository, Outbox)domain
infrastructure, web, messagingJDBC/EF adaptörleri, HTTP controller'lar, kuyruk dinleyicileriapplication, domain

Controller ve dinleyici girdiyi doğrular ve çevirir, repository saklar ve yükler; kararı aggregate verir. Kuralı olmayan servislere (compound, science, gateway) katman eklenmedi, yalnızca test yazıldı.

Testler

Domain kuralları önce test yazılarak geliştirildi; bu testler veritabanı kullanmaz ve milisaniyeler içinde biter. Transaction garantileri Testcontainers ile gerçek PostgreSQL üzerinde sınanır: eş zamanlı yazanlar kuralı bozamaz, durum ve outbox birlikte commit ya da rollback olur.

BölümAraçTest sayısı
walletJUnit 5, Testcontainers28
inventoryJUnit 5, Testcontainers28
ordernode:test38
.NET servislerixUnit217
web-appVitest148
Uçtan uca duman testideploy/scripts/test-smoke.ps120 kontrol

Çalıştırma

Docker Desktop açıkken:

Copy-Item docker/.env.example docker/.env   # şifreleri kendin belirle
./deploy/scripts/present-platform.ps1         # http://localhost:6241
./deploy/scripts/stop-local.ps1               # durdur

Yalnızca atlas için (veritabanı ve broker olmadan): ./deploy/scripts/present-local.ps1, adres http://127.0.0.1:5080.

Belgeler