E-money cüzdan sistemi. Double-entry ledger + saga orchestration.
Referans uygulama: mimari production seviyesinde, kapsam bilinçli olarak dar. Dış kurumlar sahte ama sınırları gerçek — banka ayrı bir process, arada HTTP ve callback var, wallet'ı göremiyor. Katman ayrımı, transaction sınırları, idempotency, concurrency stratejisi ve invariant zorlaması gerçekte nasıl yapılıyorsa öyle.
Çekirdek (immediate, ACID). Cüzdanlar arası transfer: tek serviste, tek DB, double-entry, optimistic lock. Saga yok — dağıtık karmaşıklık tutarlılığın kritik olduğu yere taşınmıyor.
Kenar (eventual). Dış dünyayla konuşan akışlar. İki hat da çalışıyor:
- Top-up: webhook ayrı bir serviste kendi veritabanıyla, arada RabbitMQ, tüketici üçüncü bir uygulamada.
- Withdrawal saga: orchestrator kendi veritabanında saga durumunu yürütüyor, wallet parayı düşüyor, adaptör bankayı HTTP ile arıyor. Banka "aldım" diyor, sonuç sonra callback ile geliyor — saga o arada gerçekten bekliyor. Banka reddederse compensation cüzdana parayı geri yazıyor: silmeyle değil, üç bacaklı ters kayıtla.
| deployable | ingress | Postgres | RabbitMQ |
|---|---|---|---|
personal-mobile-api |
public — ön API: bireysel mobil uygulama | — | — |
personal-web-bff |
public — ön API: bireysel web uygulamasının BFF'i | — | — |
business-api |
public — ön API: işyerinin sistem entegrasyonu | — | — |
business-web-bff |
public — ön API: işyeri panelinin BFF'i | — | — |
backoffice-bff |
iç ağ — ön API: backoffice panelinin BFF'i | — | — |
wallet-api |
iç ağ — ön API'ler çağırıyor | hiwallet_wallet / wallet_app |
— |
onboarding |
iç ağ — kayıt ve kimlik doğrulaması | hiwallet_onboarding / onboarding_app, kendi Postgres sunucusu |
— |
staff-admin |
iç ağ — personel yönetimi: çalışanlar, roller, izinler | hiwallet_staff_admin / staff_admin_app, kendi Postgres sunucusu |
— |
topup-webhook |
IP kısıtlı — sağlayıcı | hiwallet_topup / topup_app |
publish |
wallet-consumer |
yok | hiwallet_wallet / wallet_app |
consume |
withdrawal-orchestrator |
iç ağ — çekim saga'sı | hiwallet_withdrawal |
ikisi de |
bank-adapter |
yok | hiwallet_bank / bank_app |
ikisi de |
bank-webhook |
IP kısıtlı — banka | hiwallet_bank / bank_app |
— |
hiwallet-keycloak |
public — müşterilerin ve işyerlerinin kimlik sağlayıcısı (bizim kodumuz değil) | kendi Postgres sunucusu | — |
hiwallet-staff-keycloak |
iç ağ — çalışanların kimlik sağlayıcısı, ayrı kurulum (bizim kodumuz değil) | kendi Postgres sunucusu | — |
İstemci yalnızca kendi ön API'sine bağlanıyor. wallet-api ve orchestrator iç servis;
ön API veritabanına bağlanmıyor ve ledger'a giden her istek wallet-api'den geçiyor. Ön
API'ler ihtiyaç doğdukça açılıyor, her biri public ya da yalnızca iç ağdan erişiliyor.
Tarayıcıdan kullanılan arayüzün ön API'si BFF: token tarayıcıdaki koda verilmez, tarayıcı
yalnızca HttpOnly oturum cookie'si taşır. personal-mobile-api, personal-web-bff ve
business-api'nin uçları yazıldı; backoffice-bff'in görüntüleme uçları var, işyeri
BFF'i sağlık uçlarıyla ayakta.
Token'ı Keycloak imzalıyor. Müşterilerin ve çalışanların Keycloak'ı ayrı kurulum: kendi veritabanı, kendi yöneticisi. Ön API token'ı doğruluyor ve iç servise aynen iletiyor; iç servis yeniden doğruluyor. Hangi kimliğin hangi hesabın kullanıcısı olduğu wallet'ta duruyor ve müşteri yalnızca kendi hesabına erişiyor.
Kayıt onboarding'de: e-posta kodu ve parola, sonra Keycloak'ta kullanıcı ve wallet'ta
hesap. Parola Keycloak'ta, kişisel veri (e-posta, telefon, TCKN, onaylar) onboarding'in
kendi Postgres sunucusunda, ledger'da yalnızca doğrulamanın sonucu: hesabın seviyesi.
Seviye (Unknown, Unverified, Verified, Contracted) hangi hareketin ayda ne kadar
yapılabildiğini belirliyor. Giriş Keycloak'ın sayfasında, HiWallet temasıyla.
Webhook'larda ve ingress'siz uygulamalarda ayrımın sebebi erişim seviyesi: banka webhook'u belirli IP bloklarına açılacak. IP kısıtı process seviyesinde uygulanamaz.
Bir de başka kurumların yerinde duran uygulamalar var:
| temsil ettiği kurum | ne yapıyor | |
|---|---|---|
bank-fake |
bankamız | para girişi ve çıkışı; hafızası bellekte, veritabanı yok |
stripe-fake |
kart sağlayıcısı | yalnızca para girişi; veritabanı yok |
sms-fake |
SMS sağlayıcısı | mesajı göndermiyor, kutusunda tutuyor; veritabanı yok |
nvi-fake |
nüfus kaydı | kimlik bilgisi eşleşiyor mu; eşleşmeyen numara senaryoyla |
mailpit |
e-posta sağlayıcısı | SMTP'yi kabul ediyor, dışarı göndermiyor (bizim kodumuz değil) |
Hepsi canlıda yok — yerlerine kurumların kendi endpoint'leri geçiyor. .Fake son ekinin
ölçütü "test amaçlı mı" değil, "başka bir kurumun yerine mi duruyor" (decisions.md
madde 35).
bank-fake'in iki yönde de çalışması tesadüf değil: aynı banka hem gelen havaleyi
bildiriyor hem giden transferi kabul ediyor. Ledger'da da öyle — clearing/bank-fake
iki yönde de hareket ediyor. Stripe'ın nostro'su yok, çünkü nostro bir banka hesabı.
Kodları da src/ altında değil, kökteki fakes/ klasöründe: canlıda deploy
edilen hiçbir şey oradan çıkmıyor. src/ → fakes/ referansı derleme hatası
(HIW001) — kural yorumda değil, derleyicide.
Ölçüt iki yöne de işliyor: farklı erişim seviyesi aynı process'te birleşmiyor, aynı
erişim seviyesi de gereksiz bölünmüyor. wallet-consumer iki kuyruğu birden dinliyor —
top-up event'leri ve çekim komutları — çünkü ikisi de ingress'siz ve aynı ledger'a
aynı kütüphaneyle yazıyor.
wallet-api ve wallet-consumer aynı şemayı yazıyor ve aynı kütüphaneyi
(WalletService.Core) paylaşıyor. Ayrı deployable, tek kod tabanı — çünkü ledger
invariant'larının bir kısmı hiçbir constraint tarafından zorlanmıyor (cüzdanın negatife
düşememesi, bakiye satırlarının artan id sırasıyla güncellenmesi, version'ın birer
artması). İkinci bir kopya, ilk sapmada sessizce bozulurdu.
wallet-api'nin RabbitMQ bağımlılığı yok: yalnızca Postgres'e bağlı. Ön API'lerin
ne Postgres ne RabbitMQ bağlantısı var.
Ana mesaj bu ayrımda: tutarlılığın kritik olduğu çekirdeği tek boundary'de ACID tut, sadece dışarıyla konuşan kenarı dağıt.
| durum | |
|---|---|
Hesap ve cüzdan endpoint'leri (/v1/accounts, /v1/wallets) |
evet |
| Transfer çekirdeği (5 tip), limit ve komisyon | evet |
| Double-entry ledger, zero-sum invariant | evet — DB trigger + testler |
| Optimistic lock, retry, idempotency | evet |
POST /v1/transfers, ProblemDetails |
evet |
| Baseline: OTel, health, rate limiting, validation | evet |
| Top-up hattı (webhook → inbox → relay → RabbitMQ → consumer) | evet |
| HMAC imza, iki kademe idempotency, dead-letter | evet |
| Deployable ayrımı erişim seviyesine göre | evet |
| Hattın gerçek bir broker'a karşı uçtan uca koşması | evet — webhook → RabbitMQ → ledger |
| Withdrawal saga: state machine, outbox, IBAN doğrulama | evet |
| Compensation: üç bacaklı ters kayıt (komisyon dahil) | evet |
| Saga zincirinin uçtan uca koşması | evet — API → wallet → adaptör → banka → callback → saga |
| Banka entegrasyonu: asenkron sonuç, callback + mutabakat | evet |
| Sahte sağlayıcılar top-up'ı tetikliyor (tekrar, gecikme, sırasız) | evet |
| Sekiz container'ın compose'dan ayağa kalkması | evet |
| Ön API'lerin compose'dan ayağa kalkması | evet |
personal-mobile-api'nin uçları: cüzdan, transfer, çekim |
evet — wallet-api ve orchestrator'a iletiyor |
business-api'nin uçları: hesap, cüzdan, transfer, müşteriye promo, çekim |
evet — işyerinin entegrasyonu client credentials ile |
| Bireysel web uygulaması: kayıt, doğrulama, giriş, cüzdan, hareketler, transfer, çekim | evet |
| BFF oturumu: şifreli cookie, token yenileme, X-CSRF | evet |
| Backoffice paneli: müşteri kaydı, çekim incelemesi, personel promo'su, kampanyalar | evet — testte; panel compose'da denenmedi |
| İşyeri BFF'inin uçları | hayır — sağlık uçlarıyla ayakta |
| Müşteri başına rate limit ön API'de | evet — anahtar token'daki kimlik; iç servislerde yok |
| Kimlik doğrulama: Keycloak, token ön API'de ve iç serviste doğrulanıyor | evet — testte kendi imzaladığı token'la |
| Sahiplik: müşteri yalnızca kullanıcısı olduğu hesaba erişiyor | evet — çekimde wallet düşmeden önce doğruluyor |
| Keycloak'ın compose'dan ayağa kalkması | evet — işyerinin token'ıyla business-api üzerinden wallet-api'ye kadar |
| Her ön API yalnızca kendisi için verilmiş token'ı kabul ediyor | evet — aud |
| Çalışan kimliği: ayrı Keycloak kurulumu, izinle yetki, OTP zorunlu | evet — giriş ve OTP compose'da denendi; izin modeli testte |
| Personel yönetimi: izinler kodda; roller, çalışanlar ve kayıt panelden, personel yönetiminin veritabanında | evet — testte; compose'da denenmedi |
| Çalışanın izni her istekte: rolü alınan çalışanın bir sonraki isteği reddediliyor | evet — testte |
| Kayıt: e-posta kodu, parola, Keycloak'ta kullanıcı, wallet'ta hesap | evet |
| Temel doğrulama: telefon (SMS), kimlik (nüfus kaydı), sözleşme ve aydınlatma metni | evet |
| Doğrulama seviyesine göre aylık limitler | evet — transfer, ödeme, çekim; yükleme hayır |
Verified: kendi banka hesabından ilk havale |
hayır |
Contracted: backoffice'ten |
hayır |
| Takılmış saga taraması (job altyapısı + advisory lock) | evet |
| Business günlük özeti | evet |
Sağlayıcı ücreti tahakkuku (provider_fees, Net/Invoiced) |
evet |
Settlement: clearing kapanır, nostro hareket eder |
evet — top-up ve çekim |
Fatura işleme, uyuşmazlıkta PendingReview |
evet |
| Promo: işyerinin kendi müşterisine verdiği parti, ödemede harcama, süre sonu | evet |
| Promo: kampanya motoru, platform fonlu parti, koruma hesabı açığı raporu | evet — kampanyalar SQL ile |
| Promo: personel promo'su, kampanya yönetimi, işyerinin promo kabulü | evet — backoffice'ten, kendi izinleriyle |
| Çekim incelemesi: eşiğin üstü bekliyor, çalışan serbest bırakıyor ya da iptal ediyor | evet — iptal banka reddinden ayrı durumda |
| Koruma hesabının fonlama kaydı | hayır |
| Mutabakat raporu (projeksiyon, yaşlanma, fatura) | evet |
| Çekim settlement'ı (banka ücreti saga üzerinden) | evet |
| Relay tekilliği: sıra broker'a varmadan bozulmuyor | evet — advisory lock |
640 test: 199 unit (DB'siz), 441 integration — gerçek Postgres ve gerçek RabbitMQ. Web uygulamalarının testleri ayrı (Vitest): bireysel uygulamanın 14, panelin 22.
İki uçtan uca zincir koşuyor. Top-up: HTTP → inbox → relay → broker → tüketici →
ledger. Withdrawal: POST /v1/withdrawals → orchestrator → wallet-consumer →
bank-adapter → (HTTP) banka → callback → bank-webhook → inbox → relay →
orchestrator. Beş host ayrı ayrı ayakta; aralarında hem broker hem gerçek HTTP var.
cp .env.example .env # <DOLDUR> yazan yerleri doldur
docker compose up --buildSırayla: Postgres ayağa kalkar, beş rol, dört uygulama veritabanı ve integration testlerin
veritabanı (hiwallet_schema_check) kurulur; onboarding'in Postgres'i kendi rolüyle
ayrıca kalkar → beş migrator şemaları uygular → uygulamalar başlar (on ikisi bizim,
beşi başka kurumların yerinde). RabbitMQ paralel kalkar; hiçbiri onu BEKLEMEZ.
curl http://localhost:8091/health/ready # wallet-api
curl http://localhost:8092/health/ready # topup-webhook
curl http://localhost:8093/health/ready # withdrawal-orchestrator
curl http://localhost:8094/health/ready # bank-fake (BİZİM DEĞİL, canlıda yok)
curl http://localhost:8095/health/ready # bank-webhook
curl http://localhost:8096/health/ready # stripe-fake (BİZİM DEĞİL, canlıda yok)
curl http://localhost:8097/health/ready # personal-mobile-api
curl http://localhost:8098/health/ready # business-api
curl http://localhost:8099/health/ready # backoffice-bff
curl http://localhost:8100/health/ready # business-web-bff
curl http://localhost:8102/health/ready # personal-web-bff
curl http://localhost:8103/health/ready # onboarding
curl http://localhost:8104/health/ready # sms-fake (BİZİM DEĞİL, canlıda yok)
curl http://localhost:8105/health/ready # nvi-fake (BİZİM DEĞİL, canlıda yok)
curl http://localhost:8108/health/ready # staff-admin
curl http://localhost:8101/realms/hiwallet/.well-known/openid-configuration # keycloak, müşteriler
curl http://localhost:8107/realms/hiwallet-staff/.well-known/openid-configuration # keycloak, çalışanlarwallet-api (8091) ve orchestrator (8093) canlıda iç ağda; compose'da elle denemek için
host'a açıklar.
Bireysel web uygulaması http://localhost:8102'de. Kayıt uygulamanın kendi sayfasında
(/kayit): e-postaya giden kod Mailpit'te (http://localhost:8106), telefona giden kod
sms-fake'in kutusunda (GET http://localhost:8104/v1/messages). Giriş Keycloak'ın
sayfasından, HiWallet temasıyla. Oturum cookie'si yalnızca HTTPS'te ve localhost'ta
yazıldığı için başka bir makineden bu portla girilemiyor; orada ters proxy arkasındaki
adres kullanılıyor (docs/verify-compose.md).
Arayüzü geliştirirken Vite'ın sunucusu, arkada compose'daki BFF:
cd web/personal && npm install && npm run dev # http://localhost:5173Backoffice paneli http://localhost:8099'da; giriş çalışanların Keycloak'ından, OTP ile. Geliştirirken:
cd web/backoffice && npm install && npm run dev # http://localhost:5174wallet-consumer'ın host'a açılmış portu yok — health check container'ın içinden
koşuyor (docker compose ps ile görülür). Ingress'i olmayan bir uygulamanın port
açmasının sebebi olmazdı.
API dokümanı, yalnızca Development'ta. Ters proxy arkasında aynı sayfa
https://hiwallet-<servis>.<PROXY_DOMAIN>/scalar/ adresinde:
| Scalar arayüzü | Sayfadaki giriş | |
|---|---|---|
wallet-api |
http://localhost:8091/scalar/ | müşteri, işyeri |
withdrawal-orchestrator |
http://localhost:8093/scalar/ | müşteri, işyeri |
personal-mobile-api |
http://localhost:8097/scalar/ | müşteri |
onboarding |
http://localhost:8103/scalar/ | müşteri |
staff-admin |
http://localhost:8108/scalar/ | — |
business-api |
http://localhost:8098/scalar/ | işyeri |
backoffice-bff |
http://localhost:8099/scalar/ | — |
business-web-bff |
http://localhost:8100/scalar/ | — |
stripe-fake |
http://localhost:8096/scalar/ | — |
bank-fake |
http://localhost:8094/scalar/ | — |
sms-fake |
http://localhost:8104/scalar/ | — |
nvi-fake |
http://localhost:8105/scalar/ | — |
OpenAPI dokümanı her serviste /openapi/v1.json. Sondaki eğik çizgi bilerek: eğik
çizgisiz adres 302 ile ona yönleniyor. Tarayıcı takip ediyor, curl varsayılan
olarak etmiyor.
Doküman ve arayüz kimliksiz açık; uçları çağırmak token istiyor. Sayfadaki girişte
müşteri Keycloak'ın giriş sayfasına gidiyor, işyeri istemci kimliğini ve gizli
anahtarını giriyor. Ayrıntısı docs/verify-compose.md "Kimlik" bölümünde.
topup-webhook'ta yok: o sözleşmeyi sağlayıcı dayatıyor, biz belgelemiyoruz.
Host portlarının varsayılanı (8091–8106, 5433, 5673) alışıldık portlardan
bilerek kaçıyor: 8080, 5432 ve 5672 geliştirme makinelerinde çoğu zaman dolu.
.env'den değiştirilebilir.
Stack compose'dan koşuyor ve uçtan uca akışları geçiyor. Top-up, çekimin mutlu yolu ve telafi yolu compose üzerinde doğrulandı: banka reddettiğinde bakiye
500→500dönüyor ve ters kaydınrevenuebacağı yerinde. Yapısal tarafta uygulamalarhealthy, migrator'lar şemaları uyguluyor,wallet_appkonteyner içinde deledger_entries'i güncelleyemiyor ve her rol yalnızca kendi veritabanına bağlanabiliyor. Adımlar, ölçülen çıktılar ve açık uçlar: docs/verify-compose.mdBu doğrulama bankanın SENKRON cevap verdiği sürümde yapıldı. Asenkron hat (adaptör → banka → callback → webhook) testlerde koşuyor ama compose üzerinde henüz tekrarlanmadı;
verify-compose.md'de açık uç olarak duruyor.
migrator wallet_owner ile, uygulama wallet_app ile bağlanır. Ayrım şart:
ledger_entries üzerindeki REVOKE UPDATE, DELETE yalnızca tablo sahibi olmayan
bir role işler — sahiplik yetkisi örtüktür ve revoke edilemez. Tek rolle çalışılsaydı
append-only kuralı tamamen süs olurdu.
Ölçülmüş hali (AppRolePrivilegeTests):
| rol | SELECT | UPDATE | DELETE |
|---|---|---|---|
wallet_app |
izin var | 42501 reddedildi |
42501 reddedildi |
wallet_owner |
izin var | izin var | izin var |
Hesap ve cüzdan açma. Diğer her şeyin başlangıcı; aşağıdaki walletId'ler buradan gelir.
ACCOUNT=$(curl -s -X POST http://localhost:8091/v1/accounts \
-H 'Content-Type: application/json' -d '{"type":"Person"}' | jq -r .accountId)
WALLET=$(curl -s -X POST http://localhost:8091/v1/accounts/$ACCOUNT/wallets \
-H 'Content-Type: application/json' -d '{"name":"Birikim","currency":"TRY"}' | jq -r .walletId)
curl -s http://localhost:8091/v1/wallets/$WALLETCüzdan sıfır bakiyeyle açılır ve para yalnızca ledger üzerinden girer — top-up ya da transfer. Bakiyeye doğrudan yazan bir endpoint yok, olsaydı zero-sum invariant'ı delerdi.
Bir hesabın aynı para biriminde birden fazla cüzdanı olabilir (decisions.md madde 20);
GET /v1/accounts/{id} hepsini bakiyeleriyle listeler. Günlük limit bu yüzden cüzdan
değil hesap bazında uygulanır.
Komisyonlu ödeme. Komisyon ayrı bir transfer değil, aynı atomik işlemin ek bacağı:
curl -X POST http://localhost:8091/v1/transfers \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"fromWalletId":"...","toWalletId":"...","amount":200,"currency":"TRY","type":"Payment"}'Ledger'a üç satır düşer: gönderen -204, alan +200, revenue +4. Toplam sıfır.
Idempotency. Aynı Idempotency-Key ile ikinci request yeni transfer yapmaz:
curl -X POST http://localhost:8091/v1/transfers \
-H 'Idempotency-Key: ayni-istek' -H 'Content-Type: application/json' -d '{...}'İkinci response aynı transactionId ve "replayed": true döner.
Yetersiz bakiye / limit aşımı → 422 + rule alanı.
Concurrency çakışması (retry tükendi) → 409. İkisi karıştırılmaz.
Elle denemenin en kolay yolu: Rider'da fakes/akislar.http,
fakes/Bank.Fake/bank-fake.http ve fakes/Stripe.Fake/stripe-fake.http. Sağ üstten
ortamı seç, request'leri sırayla koş; kimlikler bir sonrakine kendiliğinden taşınıyor.
Aşağıdaki curl örnekleri aynı işi yapıyor.
Top-up (dışarıdan para girişi). En kolayı sahte sağlayıcıya söylemek — imzayı o hesaplıyor:
curl -X POST http://localhost:8096/v1/topups -H 'Content-Type: application/json' \
-d "{\"walletId\":\"$WALLET\",\"amount\":100,\"currency\":\"TRY\",\"mode\":\"Normal\"}"mode: Normal | Duplicate | Delayed | OutOfOrder. Duplicate aynı event'i
iki kez gönderiyor — bakiye bir kez artmalı. OutOfOrder aynı cüzdana N event'i
ters sırada gönderiyor.
Bankadan yükleme için aynı endpoint 8094'te (bank-fake), clearing/bank-fake'e yazar.
Elle göndermek istersen imza ham gövde baytları üzerinde HMAC-SHA256:
BODY='{"eventId":"evt_1","walletId":"...","amount":100.00,"currency":"TRY","reference":"pi_1","occurredAt":"2026-03-01T10:00:00+00:00"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$STRIPE_FAKE_WEBHOOK_SECRET" -hex | awk '{print $2}')
curl -X POST http://localhost:8092/v1/webhooks/topup/stripe-fake -H 'Content-Type: application/json' -H "X-Hive-Signature: sha256=$SIG" --data "$BODY"Response 202 Accepted — 200 değil, bilerek: verilen söz "işledim" değil "kalıcı
kaydettim". Para response döndüğünde henüz cüzdanda değil.
Yol: 202 (inbox commit'inden sonra) → relay → RabbitMQ → tüketici → ledger.
Ledger'a iki satır düşer: cüzdan +100, clearing/stripe-fake -100 (sağlayıcıdan
alacak). Toplam sıfır.
Aynı webhook ikinci kez gelirse yine 202 döner ama "duplicate": true ve bakiye
değişmez. İki kademe de devrede: inbox (provider, event_id) UNIQUE onu kuyruğa hiç
koymaz, koysa bile tüketicideki processed_events yutar.
İmza tutmazsa 401 ve inbox'a hiçbir şey yazılmaz.
Withdrawal (dışarıya para çıkışı). Saga'nın evi. Idempotency-Key burada
zorunlu — transfer'dekinin aksine: çekim çok adımlı ve dışarıya para çıkarıyor,
anahtarsız bir tekrar ikinci bir banka transferi başlatırdı.
curl -X POST http://localhost:8093/v1/withdrawals \
-H 'Idempotency-Key: cekim-1' -H 'Content-Type: application/json' \
-d '{"accountId":"...","walletId":"...","amount":100,"currency":"TRY",
"destinationIban":"TR33 0006 1005 1978 6457 8413 26"}'Response 202 Accepted: döndüğünde hiçbir para hareket etmemiş durumda. IBAN sınırda
mod-97 ile doğrulanıyor; geçersizse 400 ve saga hiç başlamıyor.
Mutlu yolda ledger'a üç satır düşer: cüzdan -102, clearing/bank-fake +100,
revenue +2.
Durumu GET /v1/withdrawals/{id} ile izleyebilirsin: initiated → debited →
bank_transfer_pending → settling → completed.
bank_transfer_pending'de birkaç saniye takılı görmen normal ve istenen şey:
banka çağrısı "aldım" diyor, sonuç callback ile sonra geliyor (decisions.md
madde 35). Süreyi BANK_SETTLEMENT_DELAY belirliyor.
Banka reddederse compensation. Sahte bankaya söyleyerek tetiklenir — anahtar
saga kimliği, alan adı clientReference (bankanın gözünde bizim referansımız):
curl -X POST http://localhost:8094/v1/scenarios -H 'Content-Type: application/json' \
-d '{"clientReference":"<ID>","outcome":"Failure"}'Ters kayıt orijinalin aynası ve üç bacaklı:
cüzdan +102, clearing -100, revenue -2. Komisyon iadesi koşulsuz — başarısız
bir çekimin sebebi ya bizde ya bankada.
revenue bacağının kritikliği şurada: atlansaydı kayıt yine dengeli olurdu,
zero-sum trigger'ı susardı ve müşteri gerçekleşmemiş bir işlemin komisyonunu ödemiş
kalırdı. Bu yüzden ters kayıt politikadan yeniden üretilmiyor — orijinal işlemin
bacakları okunup negatifleniyor.
Orijinal kayıt silinmiyor; ledger append-only, saga başına iki işlem kalıyor.
Zero-sum, yük altında. EszamanliTransferler_ZeroSumKorunur_VeHicbirCuzdanNegatifDusmez
500 eşzamanlı transfer atıyor ve dört şeyi doğruluyor: her transaction'ın toplamı sıfır,
sistem genelinde toplam sıfır, hiçbir cüzdan negatif değil, projeksiyon ledger'dan
sapmamış. Testin iddiası "her transfer başarılı olur" değil — çakışanlar 409 alıyor —
"ne olursa olsun invariant korunur."
dotnet test
cd web/personal && npm test
cd web/backoffice && npm testIntegration testler bir Postgres sunucusu ister; bağlantı
ConnectionStrings__IntegrationTests'ten gelir. Her koşu kendi schema'sını açar,
migration'ı oraya uygular, sonunda düşürür — izolasyon böyle sağlanıyor, Docker
gerekmiyor. topup-webhook, withdrawal-orchestrator, banka entegrasyonu ve sahte banka için ayrı schema'lar
açılıyor: canlıdaki ayrı veritabanı sınırları testte de korunuyor, servisler
birbirinin tablosunu göremiyor.
Uçtan uca testler ayrıca bir RabbitMQ ister (RabbitMq__*). Top-up hattı bir de
rabbitmq_consistent_hash_exchange eklentisi istiyor — routing key cüzdan kimliği ve
partition'ı o exchange seçiyor. Withdrawal zinciri düz bir direct exchange kullanıyor,
eklenti istemiyor:
docker exec <rabbitmq> rabbitmq-plugins enable rabbitmq_consistent_hash_exchangeİkisinden biri eksikse test Assert.SkipUnless ile atlanıyor ve mesajda eksiğin ne
olduğu yazıyor — kurulumu zorunlu kılmak yerine varsa doğrulanıyor, atlanan test yeşil
değil "skipped" görünüyor.
Her koşu kendine özel bir exchange/kuyruk ön eki kullanıyor (RabbitMq:NamePrefix) ve
sonunda topolojiyi siliyor: aynı broker'a bakan iki koşu birbirinin kuyruğundan mesaj
çekmiyor, broker'da da çöp birikmiyor.
set -a; . ./.env; set +a
dotnet test| dosya | ne anlatır |
|---|---|
| docs/architecture.md | diyagramlar: topoloji, akışlar, paranın nerede durduğu |
| docs/overview.md | sistem: kapsam, servisler, akışlar, saga, çıkış kriteri |
| docs/baseline.md | uygulamadan bağımsız 12 zorunlu katman |
| docs/decisions.md | kararlar, gerekçeler, elenen alternatifler |
| docs/ledger-schema.md | şemanın okunabilir karşılığı |
| docs/structure.md | yeni dosya nereye konur |
| docs/api-examples.md | her endpoint için request ve beklenen response |
| docs/verify-compose.md | compose'u ayağa kaldırma ve doğrulama |
| CLAUDE.md | pazarlıksız kurallar |
Şemanın tek kaynağı EF migration'ları; ledger-schema.md onları açıklar, üretmez.
Baştan sona okunacak tek doküman yok. Hangisini açacağın ne yaptığına bağlı:
Sistemi tanımak ya da hatırlamak için: architecture.md → overview.md →
ledger-schema.md. İlki en hızlı resmi veriyor, hepsi diyagram. overview.md'nin
1–10 numaralı maddeleri sistemi anlatıyor; o numaralara decisions.md ve kod
yorumları atıf yapıyor, bu yüzden sabitler. Üçüncüsünü ancak tablo yapısı
gerektiğinde aç.
Kod yazarken: CLAUDE.md → structure.md → decisions.md'de ilgili madde.
İlki kuralların listesi, ikincisi yeni dosyanın nereye konacağı. decisions.md
baştan sona OKUNMAZ — 1400 satır ve referans niteliğinde; merak ettiğin maddeyi ara
(banka entegrasyonu 35, servis sınırı 7 ve 33, aktör 34).
Elle denerken: fakes/ altındaki .http dosyaları → api-examples.md →
verify-compose.md. İlki Rider'da en hızlı yol, ikincisi bir şey bozulduğunda
karşılaştırman için beklenen response'ları veriyor.
Yeni karar alırken: decisions.md. Karar oraya gerekçesiyle yazılıyor, sonra
CLAUDE.md'ye ve ilgili dokümanlara yayılıyor.
baseline.md nadiren açılır: uygulamadan bağımsız ve neredeyse hiç değişmiyor.
Eksik değil, elenmiş — gerekçeleri decisions.md madde 12'de:
- Rate limiting in-memory. Çok instance'ta efektif limit instance başınadır.
- Secret yönetimi
.env+ User Secrets. Vault yok. - Caching yok. Bakiye projeksiyonu cache değil, kalıcı read tablosu.
- Multi-tenancy yok. Person/business ayrımı hesap tipidir, tenancy değil.
Ayrıca kayda geçirilmiş, henüz sorun olmayan bir darboğaz var: komisyonlu her transfer
tek revenue satırını güncelliyor ve eşzamanlı komisyonlu transferler cüzdanları farklı
olsa bile çakışıyor (decisions.md madde 23).
MIT — LICENSE.
Bu bir referans uygulamasıdır, canlıya çıkmaya hazır bir e-para sistemi değil. Yukarıdaki
bilinçli sınırlamalar okunmadan canlıda kullanılmamalı. stripe-fake ve
bank-fake yerel simülatörlerdir; hiçbir ödeme sağlayıcısıyla ilişkisi yoktur.