ElementAPI'de bir siparişi üç dilde yazılmış dört servise emanet etmek
Tek bir satın alma isteği Node, Java ve .NET servislerinden geçiyor, ama aralarında ortak bir transaction yok. Gateway'in gerçekte neyi denetlediğini, outbox'ı, zaman aşımı süpürücüsünü, KREDI'nin neden yalnızca bir kez iade edilebildiğini ve Idempotency-Key'i neden doğrudan sipariş kimliği yaptığımı kodun üzerinden anlatıyorum.
ElementAPI'de tek bir satın alma isteği dört servise uğruyor. Siparişi, Node ile yazdığım order-service kaydediyor. Stoğu ve KREDI cüzdanını iki Java servisi tutuyor, kargoyu da .NET tarafındaki shipment-service üretiyor. Her servisin kendi PostgreSQL veritabanı var ve aralarında yalnızca RabbitMQ olayları dolaşıyor. Bu yüzden "Sipariş başarısız olursa ayrılan stok bırakılsın, çekilen KREDI iade edilsin, ama iade iki kez yapılmasın" kuralını tek bir veritabanı transaction'ıyla yazamıyorum. Kural, servislere dağılmış küçük önlemlerin toplamından ortaya çıkıyor. Ticaret tarafı bir simülasyon, ama mesajların tekrar gelmesi, geç gelmesi ya da hiç gelmemesi gayet gerçek bir dert.
Kim hangi dilde, kim neyi tutuyor
Ortada tek bir PostgreSQL 16 konteyneri var; ama docker/init-scripts/init.sql içinde yedi ayrı veritabanı açılıyor ve her servis yalnızca kendi veritabanına bağlanıyor.
| Servis | Dil / çatı | Veritabanı | Gateway rotası |
|---|---|---|---|
| gateway-service | .NET 10, YARP 2.3.0 | yok (Redis) | giriş noktası, :5000 |
| identity-service | .NET 10 | element_identity_db | /api/v1/auth, /api/v1/api-keys, /api/v1/webhooks |
| catalog-service | .NET 10 | element_market_db | /api/v1/elements, /api/v2/elements |
| compound-service | .NET 10 | element_compound_db | /api/v1/compounds, /api/v2/compounds |
| order-service | Node 22, TypeScript, Express | element_order_db | /api/v1/orders |
| wallet-service | Java 21, Spring Boot 3.4.1 | element_wallet_db | /api/v1/me, /api/v1/desk |
| inventory-service | Java 21, Spring Boot 3.4.1 | element_inventory_db | /api/v1/stock |
| shipment-service | .NET 10 | element_shipment_db | /api/v1/shipments/track |
| notification-service | .NET 10 | yok | rota yok |
Bunların yanında veritabanına ya da broker'a ihtiyaç duymayan bağımsız bir atlas servisi (science-service) ve React 19 ile Vite 8 üzerinde çalışan web-app var. Stok her zaman inventory'de durmuyordu, bunun izi hâlâ kodda. catalog-service'te bir OrderSubmittedConsumer duruyor ama Program.cs'te kayıtlı değil. Sınıfın yorumu, stoğun artık inventory-service'te olduğunu ve sınıfı yalnızca birim testleri rezervasyon kurallarını hâlâ çalıştırdığı için tuttuğumu anlatıyor.
Üç runtime'ın aynı kanalda anlaşabilmesi için Node ve Java servisleri MassTransit'in JSON zarfını ve exchange adlandırmasını elle üretiyor. Sözleşmeler tek bir yerde duruyor: shared-lib/Events/IntegrationEvents.cs. Bunun bedelini de dosyanın başına not ettim:
// Message contracts shared by the .NET, Node (order-service) and Java (wallet/inventory) services.
// The type names and the "Element.Shared.Events" namespace form the MassTransit message URN:
// renaming a type or a property silently breaks the other languages.Her olay tipinin Element.Shared.Events:<Tip> adlı durable bir fanout exchange'i var (order-service/src/messaging/massTransit.ts). Java servisleri bunlara Spring AMQP @RabbitListener ile bağlanıyor. Wallet ve inventory'yi neden Node'a taşımadığımı docs/adr/0002-java-wallet-inventory.md'ye yazdım. Sebepleri şunlar: sınırlı bağlamlar zaten birbirinden ayrık, yeniden yazmanın maliyeti faydasından büyük, saga'nın sahibi de order-service.
Gateway neye bakıyor, neye bakmıyor
Bütün istemci trafiği YARP gateway'inden geçiyor. Rotalar gateway-service/appsettings.json'da duruyor; API anahtarı isteyen rotaları metadata ile işaretliyorum:
"shipment-track-route": {
"ClusterId": "shipment-cluster",
"Match": {
"Path": "/api/v1/shipments/track/{**remainder}"
},
"Metadata": {
"RequireApiKey": "true"
}
},Bu işaret /api/v1/orders, /api/v1/me, /api/v1/desk ve kargo takibi rotalarında, element geçmişinde ve elementlere yazan POST/PUT/DELETE isteklerinde var. v2 bilim uçları ise yalnızca GET ve OPTIONS kabul ediyor ve herkese açık.
ApiKeyValidationMiddleware her istekte ilk iş X-User-Id ve INTERNAL_API_KEY başlıklarını siliyor, böylece istemci bu başlıkları kendisi uyduramıyor. Sonra yalnızca işaretli rotalarda X-API-Key'i doğruluyor; anahtar ele_live_ ile başlamalı ve ardından 32 karakter gelmeli. Gateway her istekte identity-service'e soruyor ve sonucu önbelleğe almıyor. Bu yüzden iptal edilen bir anahtar, bir sonraki istekte reddediliyor. Doğrulama geçerse arka servise giden isteğe kullanıcı kimliği ve paylaşılan iç anahtar ekleniyor. order-service ikisini birlikte istiyor; gateway'i atlayan bir istek 401 alıyor.
JWT'yi ise gateway doğrulamıyor. API anahtarı ve webhook yönetimi JWT istiyor, ama bu kontrolü identity-service'te ApiKeyController ve WebhooksController üzerindeki [Authorize] yapıyor; gateway bu istekleri olduğu gibi iletiyor.
Hız sınırı iki katmanlı. Anahtar başına limiti Redis'te, ratelimit:{sha256}:{unixSecond} biçiminde iki saniye ömürlü bir sayaçla tutuyorum; varsayılan değer saniyede 10 istek. Redis'e ulaşılamazsa gateway kotayı uygulayamıyor ve isteği 429 ile reddediyor. Platformda Redis kullanan tek servis artık gateway: 17 Eylül'deki sadeleştirmede katalogdaki Redis DTO önbelleğini sildim, identity ve order da bugün Redis kullanmıyor. IP başına limit ise tek bir global limiter (gateway-service/Program.cs):
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
var (partitionKey, permitLimit, window) = RateLimitPolicy.Resolve(context);
return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = permitLimit,
Window = window,
// ...
QueueLimit = 0,
});
});RateLimitPolicy.Resolve her isteği üç bölümden birine koyuyor: kayıt, şifremi unuttum ve doğrulama e-postası POST'ları register:{ip} (dakikada 5), diğer auth POST'ları auth:{ip} (dakikada 15), geri kalan her şey public:{ip} (10 saniyede 60). Kuyruk yok; limit aşılınca doğrudan 429 dönüyor. Limitleri neden Caddy'ye koymadığımı RateLimitPolicy.cs'teki yoruma yazdım: Caddy'nin hazır gelen sürümünde rate_limit eklentisi yok.
Siparişi order-service yönetiyor
Siparişin yaşam döngüsünü order-service orkestre ediyor. Hata kollarıyla birlikte akış şöyle:
POST /api/v1/orders -> Submitted; outbox: UpdateOrderStatusEvent + OrderSubmittedEvent (HTTP 202)
inventory-service (Java) -> StockReservedEvent | StockReservationFailedEvent
order: StockReserved -> PaymentRequestedEvent (toplam > 50000 KREDI ise doğrudan Failed)
wallet-service (Java) -> PaymentProcessedEvent | PaymentFailedEvent
order: Shipping -> ShipmentRequestedEvent
shipment-service (.NET) -> ShipmentDispatchedEvent | ShipmentFailedEvent
order: Completed -> AssetsCreditedEvent + OrderCompletedEvent
her geçişte -> UpdateOrderStatusEvent (notification -> webhook)
hata olayı ya da timeout -> Failed + OrderStockReleaseEvent + PaymentRefundRequestedEventTelafi yalnızca bir hata olayı geldiğinde, 50.000 KREDI tavanı aşıldığında ve zaman aşımında tetikleniyor. Kullanıcının kendisinin başlatabileceği bir iptal ucu yok.
Her olayı tek bir PostgreSQL transaction'ında işliyorum (order-service/src/saga/orchestrator.ts):
await client.query("BEGIN");
const order = await lockOrder(client, event.orderId);
// ...
const isFirstDelivery = await tryMarkMessageProcessed(
client,
event.messageId,
event.type,
order.id,
);
if (!isFirstDelivery) {
await client.query("COMMIT");
return;
}
await applySagaEvent(client, order, event);
await client.query("COMMIT");lockOrder sipariş satırını FOR UPDATE ile kilitliyor; yani aynı siparişin iki olayı paralel işlenemiyor. tryMarkMessageProcessed mesaj kimliğini processed_messages tablosuna ON CONFLICT DO NOTHING ile yazıyor, bu yüzden ikinci teslimat hiçbir şeyi değiştirmiyor. İşaret ile durum değişikliği aynı transaction'ın içinde. İşleyici yarıda kalırsa ikisi birlikte geri alınıyor ve tekrar gelen teslimat yanlışlıkla atlanmıyor.
applySagaEvent, siparişin o anki durumuna uymayan olayları yok sayıyor. İki istisnası var: sipariş Failed olduktan sonra gelen bir StockReservedEvent stoğu bırakma olayı üretiyor, geç gelen bir PaymentProcessedEvent ise iadeyi yeniden istiyor. Bunun neden güvenli olduğunu koddaki yoruma yazdım: wallet'taki iade sipariş başına idempotent.
Olay kaybolmasın, cevap gelmezse sipariş takılı kalmasın
order-service hiçbir olayı doğrudan RabbitMQ'ya göndermiyor. enqueueOutboxEvent olayı çağıranın transaction'ı içinde outbox_messages tablosuna yazıyor; transaction commit olmazsa olay da hiç var olmamış oluyor. Yayınlama işini ayrı bir döngü yapıyor (order-service/src/messaging/outboxDispatcher.ts):
const OUTBOX_BATCH_SIZE = 50;
// ...
// One broker round trip per batch. Any nack throws before marking, so the whole batch is
// re-sent; consumers dedup on messageId (= outbox id), keeping delivery at-least-once.
await channel.waitForConfirms();
await markOutboxPublished(rows.map((row) => row.id));Dispatcher 500 ms'de bir (OUTBOX_POLL_MS) en eski 50 satırı, publisher confirm kullanan bir kanaldan yayınlıyor. Broker partinin tamamını onaylayınca satırları yayınlandı olarak işaretliyor. Outbox satırının kimliği aynı zamanda MassTransit'in messageId'si. Parti yeniden gönderilirse tüketiciler aynı kimliği görüp atlıyor; teslimat at-least-once kalıyor, tüketici de idempotent davranıyor. İlk halinde dispatcher her tick'te tek bir parti gönderiyordu ve koddaki yoruma göre bu, platformu saniyede yaklaşık 11 siparişle sınırlıyordu. Şimdi parti dolu gelirse beklemeden bir sonrakine geçiyor.
İşleyici hata verirse index.ts mesajı 1 saniye bekletiyor, x-retry-count başlığını artırıp kuyruğa geri koyuyor. Dört yeniden denemeden sonra mesaj order-service-saga_failed kuyruğuna park ediliyor; orijinal mesaj ancak kopyası broker tarafından onaylanınca ack'leniyor. Java servislerinde aynı işi Spring'in retry ayarı yapıyor: 5 deneme, 1 saniyeden başlayıp her seferinde ikiye katlanan bir bekleme, sonra mesaj wallet-service_failed ya da inventory-service_failed kuyruğuna gidiyor.
Cevap hiç gelmezse süre sınırı devreye giriyor (order-service/src/config.ts):
sagaSweepMs: intFromEnv("SAGA_SWEEP_MS", 5000),
/** Seconds a saga may stay in each state before the timeout sweeper fails it. */
sagaTimeoutSeconds: {
Submitted: intFromEnv("SAGA_TIMEOUT_SUBMITTED_SEC", 120),
StockReserved: intFromEnv("SAGA_TIMEOUT_STOCK_RESERVED_SEC", 120),
Shipping: intFromEnv("SAGA_TIMEOUT_SHIPPING_SEC", 180),
} as Record<string, number>,Her durum geçişi saga_state.deadline_at değerini yeniliyor. Süpürücü 5 saniyede bir süresi geçmiş sagaları buluyor ama onları hemen başarısız saymıyor. Önce siparişi kilitliyor ve süresinin hâlâ geçmiş olduğunu yeniden kontrol ediyor. Arada bir olay saga'yı ilerlettiyse olay kazanıyor; ilerletmediyse sipariş Failed oluyor ve iki telafi olayı outbox'a yazılıyor.
Aynı işi iki kez yapmamak
İadeyi wallet garanti ediyor
order-service aynı sipariş için birden fazla PaymentRefundRequestedEvent yayınlayabiliyor; zaman aşımından sonra gelen geç bir ödeme bunun tipik örneği. İadenin tek kez yapılmasını wallet-service garanti ediyor ve ilk katman bir veritabanı kısıtı (wallet-service/src/main/resources/schema.sql):
CREATE UNIQUE INDEX IF NOT EXISTS ledger_buy_order
ON ledger (order_id) WHERE kind = 'buy' AND order_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS ledger_refund_order
ON ledger (order_id) WHERE kind = 'refund' AND order_id IS NOT NULL;Bu iki kısmi unique index sipariş başına en fazla bir tahsilat ve bir iade satırına izin veriyor. Kodda aynı kuralı bir satır kilidiyle de koruyorum (LedgerRepository.java):
@Transactional
public void refundIfDebited(UUID userId, UUID orderId, String symbol, BigDecimal grams) {
ensureWallet(userId);
lockWalletAndReadBalance(userId);
if (hasOrderLedgerEntry(orderId, KIND_REFUND)) {
return;
}
// ...
// No debit yet -> the tombstone covers a PaymentRequested that arrives late
// (timeout race, or replayed from wallet-service_failed).
BigDecimal refundAmount = buyAmounts.isEmpty() ? BigDecimal.ZERO : buyAmounts.getFirst();
if (refundAmount.signum() > 0) {
addToBalance(userId, refundAmount);
}
insertOrderLedgerEntry(KIND_REFUND, userId, refundAmount, symbol, grams, orderId);
}Cüzdan satırı FOR UPDATE ile kilitleniyor; sipariş için zaten bir refund kaydı varsa metot hiçbir şey yapmadan çıkıyor. Bence en ilginç durum, tahsilat hiç yapılmamışken gelen iade. Wallet o zaman 0 KREDI'lik bir refund satırı yazıyor. Bu mezar taşından sonra bir PaymentRequestedEvent gelirse debit CANCELLED dönüyor ve wallet ORDER_CANCELLED gerekçeli bir PaymentFailedEvent yayınlıyor. Yani ölmüş bir sipariş için KREDI çekilmiyor. Inventory'de de aynı fikri kullandım: rezervasyondan önce gelen serbest bırakma Released durumunda bir satır yazıyor, geç gelen OrderSubmittedEvent de stok ayıramıyor. Üçüncü katman, order-service'tekiyle aynı şemaya sahip processed_messages tablosu. Wallet'ta dedupe işareti ve defter işlemi aynı @Transactional sınırının içinde.
Idempotency-Key için ayrı bir tablo açmadım
İstemciden gelen tekrarların çözümü daha sade. POST /api/v1/orders isteğe bağlı bir Idempotency-Key başlığı kabul ediyor; gönderildiyse UUID olmak zorunda, değilse 400 dönüyor. Ayrı bir idempotency deposu ya da gövde hash'i tutmuyorum. Anahtar doğrudan siparişin kimliği oluyor (order-service/src/routes/orders.ts):
// The idempotency key doubles as the order id, so a retried request finds its own order.
const orderId = idempotencyKey || randomUUID();
// ...
// Replay before any upstream call: a retry must not depend on catalog/inventory being up.
const existing = idempotencyKey ? await orders.getOrderById(orderId) : null;
if (existing) {
return replayExistingOrder(res, existing, request);
}Benzersizliği orders.id birincil anahtarı sağlıyor; sipariş INSERT ... ON CONFLICT (id) DO NOTHING RETURNING id ile yazılıyor. Aynı anahtarla aynı anda iki istek gelirse kaybeden satır ekleyemiyor, kazananın kaydını okuyup replay'e karar veriyor. replayExistingOrder hash yerine dört alanı karşılaştırıyor: müşteri, element sembolü, miktar ve compound slug. Hepsi aynıysa kayıtlı sipariş 200 ile dönüyor (ilk cevap 202'ydi), biri bile farklıysa 409. Replay fiyat, stok ve cüzdan servislerine hiç gitmeden yapılıyor, bu yüzden o servisler kapalıyken bile yeniden deneme cevabını alıyor.
Bunları nasıl sınıyorum
Saga kurallarını order-service/src/saga.integration.check.ts sınıyor. Dosya yerel PostgreSQL'de saga_test_<uuid> adlı geçici bir şema açıyor, iş bitince de DROP SCHEMA ... CASCADE ile siliyor. Yerel olmayan bir veritabanında çalışmayı reddediyor. RabbitMQ'yu da kullanmıyor; orkestratör fonksiyonlarını doğrudan çağırıyor ve sonucu outbox satırlarını sayarak doğruluyor. deploy/scripts/test-saga.ps1 yalnızca DATABASE_URL'i ayarlayıp bu dosyayı çalıştırıyor. Toplam yedi kontrol var:
stock reserved emits PaymentRequestedshipment failure requests wallet refund once: aynıShipmentFailedEventPromise.allile aynı anda iki kez işleniyor, ama ortaya tek bir iade olayı çıkıyor.late completion cannot revive a failed ordertimeout fails shipping ordercompletion emits AssetsCredited once: aynı anda ikiShipmentDispatchedEvent, ardından geç birShipmentFailedEventgeliyor; sipariş yineCompletedkalıyor.late debit after timeout re-requests refund and stays Failed: iki iade olayı bekleniyor.private status events always carry their owner
Çift tahsilatı bu dosya sınamıyor; onu, gerçek bir wallet-service konteyneriyle çalışan bir Testcontainers testi yakalıyor: WalletServiceIntegrationTests.WalletDebit_IsIdempotent_AndSellRejectsOverHolding. Cüzdan testini 2 Ekim'de gerçek konteynere taşıdım, aynı turda eskimiş iki entegrasyon testini de düzelttim.
Sağlık uçları var, metrik ucu yok
.NET servisleri sağlık uçlarını shared-lib'den alıyor (shared-lib/Extensions/ServiceOpsExtensions.cs):
// Liveness runs no checks: it only proves the process answers HTTP.
var livenessOptions = new HealthCheckOptions
{
Predicate = _ => false,
// ...
};
// ...
app.MapHealthChecks("/health/live", livenessOptions);
app.MapHealthChecks("/health/ready", readinessOptions);
app.MapHealthChecks("/health", readinessOptions);/health/live hiçbir bağımlılığa bakmıyor. /health ise /health/ready ile aynı readiness kontrolünü çalıştıran bir takma ad. Node ve Java servisleri aynı JSON şeklini kendi kodlarıyla üretiyor: order-service src/ops.ts'te, wallet ve inventory HealthController'da PostgreSQL ile RabbitMQ'yu yokluyor, 200 ya da 503 dönüyor. Gateway'deki Redis kontrolünü Degraded olarak kaydettim, yani Redis düşse bile gateway unhealthy sayılmıyor.
Metrik ucunun olmaması da kayda geçmiş bir karar. Projede bir zamanlar Prometheus, Grafana, ELK ve HealthChecks UI vardı. docs/adr/0001-observability-metrics.md'nin bağlam kısmında yazdığı gibi, yerel ve demo çalıştırmalar hafif kalsın diye bu yığını kaldırdım; ADR'deki karar da onları geri getirmemek. Geriye /info ve üç health ucu kaldı. Gateway her istek için bir X-Request-Id üretiyor ya da istemcinin gönderdiğini iletiyor; servisler arası izi bu kimlikle stdout loglarında sürüyorum. Metrik gerekirse özel ağda, ayrı bir ADR ile eklenecek.
Hâlâ zayıf olan yerler
Zayıf yerlerin bir kısmı kodun içinde de yazılı duruyor. inventory-service olaylarını outbox olmadan yayınlıyor; EventPublisher.java'daki yoruma, dual-write sorun çıkarırsa outbox ekleneceğini not ettim. Notification'ın webhook dağıtımı best-effort; kalıcı yeniden denemeyi docs/adr/SIMPLIFICATION-ROADMAP.md'de P3'e erteledim. processed_messages ve yayınlanmış outbox satırlarının temizliği de yalnızca servis açılırken çalışıyor. Tutarlılık ise tek bir mekanizmaya dayanmıyor: order-service'te satır kilidi, dedupe tablosu ve outbox; wallet'ta kısmi unique index, kilit ve mezar taşı; HTTP tarafında da birincil anahtara dönüşen Idempotency-Key var.
Projenin özetine proje sayfasından, kaynak koduna GitHub'dan göz atabilirsin.
