Dahili domain event sistemi: emitter, listener ve teslimat
Ödeme orkestrasyonunda bir iş kuralı tamamlandığında, örneğin bir ödeme kaydı oluşturulduğunda, sistem genelde sadece veritabanına yazmakla yetinmez. Bildirim göndermek, denetim günlüğü üretmek, ileride eklenecek entegrasyonları tetiklemek veya analitik hatları beslemek gibi yan etkiler devreye girer. Bu yan etkileri doğrudan use case gövdesine gömmek, kısa vadede hızlı görünse de uzun vadede okunabilirliği düşürür, test etmeyi zorlaştırır ve tek bir değişiklik dalgasının tüm akışı kırmasına yol açar.
Bu yazıda, Hsrcpay backend’inde bu soruna verdiğimiz cevabı ele alıyoruz: domain event tabanlı, süreç içi (in-process) bir olay otobüsü. Olaylar, anlamlı domain anlarında yayımlanır; listener’lar ilgilendikleri EventType için kayıt olur ve teslimat katmanı bu abonelikleri eşleyerek handler’ları çağırır. Tasarımın özü şudur: use case “ne olduğunu” bir kez ilan eder; “bunun sonucunda kim ne yapacak” sorusu ayrı modüllerde, gevşek bağlı şekilde yaşar.
Bugünkü sınır (bilinçli seçim): Bu hat şu an için tek gerçeklik kaynağı (SOT) değildir. Olay, “bir şey oldu, uygun taraflar harekete geçsin” diye gönderilen hafif bir bildirimdir; ödeme veya hesap gibi asıl durum zaten veritabanı ve domain kurallarında kalır. Otobüs üzerindeki bir bildirimin gecikmesi, tekrarlanması veya süreç içi modelde teorik olarak kaybolması, çekirdek iş kuralını tek başına bozmamalıdır; listener’lar buna göre tasarlanır: kendi içlerinde hata yönetimi, idempotent yan etkiler ve gerektiğinde kalıcı bir “iş kaydı” (örneğin webhook delivery satırı) oluşturma sorumluluğu onlarındır. Gerçek teslimat garantisi, ileride broker ve kuyruk katmanına taşınacaktır (aşağıda yol haritası).
Son dönemde ödeme akışı, vault entegrasyonu ve bootstrap katmanı üzerinde yapılan iterasyonlarla birlikte bu yapı da aynı çatı altında evrilmeye devam ediyor. Özellikle sandbox ve prod altyapılarının ayrı çözümlenmesi (resolveInfra / resolveRepositories), event bus’un da iki ayrı örnek üzerinden izole çalışmasını zorunlu kılıyor; birim testlerde bu izolasyon, gerçek bir ödeme oluşturma senaryosu üzerinden doğrulanıyor.
Aşağıda her ana başlıkta önce kavramı ve tasarım gerekçesini uzunca açıklıyoruz, ardından ilgili teknik kod örneklerini veriyoruz. Gösterilen parçalar, okunabilirlik için repo kaynaklarından sadeleştirilmiş veya birleştirilmiş alıntılardır.
Mimari harita: çift otobüs ve bootstrap kaydı
Finansal yazılımda test ve canlı verinin birbirine karışması telafi edilmesi zor hatalara yol açar. Bu yüzden altyapı bootstrap’ında prod ve sandbox için ayrı Prisma istemcileri, önbellek, vault ve benzeri servisler oluşturulduğu gibi, event bus için de iki ayrı örnek tutulur. Üretim modunda her iki ortam için ProdEventBus kullanılır; geliştirme ve birim testlerde ise davranışı kontrollü tutmak için DummyEventBus tercih edilir; ikisi de aynı IDomainEventBus sözleşmesini uygular, böylece uygulama kodu somut sınıfa sıkı bağlanmaz.
Uygulama ayağa kalkarken kayıtlı domain event listener’lar, kasıtlı olarak hem prod hem sandbox otobüsüne eklenir. Böylece aynı listener sınıfı tek bir kez yazılır; fakat tetiklenen olay, isteğin sandbox bayrağına göre doğru veritabanı ve doğru otobüs üzerinden akar. Bu ayrım, “listener kodu bir kez, davranış iki izole dünya” ilkesini destekler.
// Kaynak: apps/backend/src/infrastructure/bootstrap/infrastructure.ts
// createEventBuses - moda göre ProdEventBus veya DummyEventBus çifti
export function createEventBuses(
mode: "production" | "development" | "unit_test",
): EnvGroup<IDomainEventBus> {
if (mode === "production") {
return {
prod: new ProdEventBus(),
sandbox: new ProdEventBus(),
};
} else {
return {
prod: new DummyEventBus(),
sandbox: new DummyEventBus(),
};
}
}
// bootstrapInfrastructure içinde prod / sandbox infra gruplarına atanır:
// eventBus: eventBus.prod ve eventBus: eventBus.sandbox// Kaynak: apps/backend/src/infrastructure/bootstrap/event-listeners.ts (özet)
export const bootstrapEventListeners = (
ib: Pick<
TInfrastructureBootstrap,
| "repositories"
| "infra"
| "environment"
| "application"
| "paymentOrchestrator"
>,
) => {
const listeners = [...createPaymentModuleListeners(ib)];
for (const listener of listeners) {
ib.infra.prod.eventBus.registerDomainEventListener(listener);
ib.infra.sandbox.eventBus.registerDomainEventListener(listener);
}
};Olay nesnesi: DomainEventAbstractBase ve somut olaylar
Domain event, “geçmişte oldu ve geri alınamaz” anlamında bir gerçeklik bildirimidir. Teknik olarak DomainEventAbstractBase alt sınıfları; her biri EventType ile etiketlenir, kimlik, oluşturulma zamanı ve sandbox bilgisini ortak bir depodan (eventStorage) sunar. Somut olaylar, örneğin ödeme oluşturuldu, ilgili varlıkları (payment, account) depoya koyar ve tip güvenli getter’larla okunur.
Tasarımda geleceğe dönük olarak broadcastTo ve broadcastMap alanları da tanımlıdır; bunlar şu anki publish döngüsünde henüz kullanılmıyor, fakat ileride çok hedefli yayın veya haritalama katmanı eklendiğinde genişleme noktası olarak duruyor. Bu ayrıntıyı yol haritası bölümünde tekrar ele alacağız.
// Kaynak: apps/backend/src/domain/common/event-abstract-base.ts (özet)
import type { AccountEntity } from "../resources/account";
export abstract class DomainEventAbstractBase {
public abstract type: EventType;
public eventStorage = new DomainEventStorage();
public broadcastTo: TEventBroadcastFieldOptions;
public broadcastMap: TEventBroadcastMapperFunction;
constructor(sandboxMode: boolean = false) {
this.eventStorage.set("id", generateEventId(sandboxMode));
this.eventStorage.set("sandboxMode", sandboxMode);
this.eventStorage.set("createdAt", new Date());
}
public get id(): string {
return this.eventStorage.get<string>("id")!;
}
public get accountId(): string | undefined {
return this.eventStorage.get<AccountEntity>("account")?.id;
}
public get createdAt(): Date {
return this.eventStorage.get<Date>("createdAt")!;
}
public get sandboxMode(): boolean {
return this.eventStorage.get<boolean>("sandboxMode")!;
}
}// Kaynak: apps/backend/src/domain/resources/payment/events/created.event.ts
export class PaymentCreatedDomainEvent extends DomainEventAbstractBase {
public type = EventType.PAYMENT_CREATED;
constructor(
payment: PaymentEntity,
account: AccountEntity,
options: TEventCreteOptions,
) {
super(options.sandboxMode);
this.eventStorage.set("payment", payment);
this.eventStorage.set("account", account);
}
public get payment(): PaymentEntity {
return this.eventStorage.get<PaymentEntity>("payment")!;
}
public get account(): AccountEntity {
return this.eventStorage.get<AccountEntity>("account")!;
}
}Emitter: use case içinden publish
Emitter rolü burada klasik bir EventEmitter değil; altyapıdan gelen IDomainEventBus.publish çağrısıdır. Kritik nokta, use case’in hangi ortamda çalıştığını bildiği anda resolveInfra(ib.infra, sandboxMode) ile doğru eventBus örneğini seçmesidir. Böylece sandbox isteği sandbox veritabanına yazdıktan sonra olayı sandbox otobüsüne bırakır; prod isteği prod otobüsüne. Bu tek satırlık çözümleme, yanlışlıkla canlı dinleyicilere test olayı gitmesi riskini mimari seviyede azaltır.
Use case gövdesi ödeme satırını oluşturduktan sonra tek bir domain olayı yayımlar; e-posta, dış webhook veya raporlama gibi işler bu çağrının hemen altında sıralanmaz; bunlar listener tarafına taşınır. Örneğin bir webhook senaryosunda listener tipik olarak doğrudan HTTP atmayı “son adım” saymaz; webhook delivery kaydı açar ve gerçek HTTP çağrısı worker tarafından kuyruk veya zamanlayıcı ile yürütülür. Bugünkü domain event ise bu zincirde yalnızca ilk uyarıdır.
Üretim modunda ProdEventBus.publish çağrısı, eşleşen listener’ların handle sonuçlarını beklemeden döner (fire-and-forget). Yan etkiler aynı tick içinde tamamlanmış sayılmaz; bu, HTTP isteğinin gecikmesini düşürür fakat “publish bitti, dolayısıyla tüm yan etkiler de bitti” varsayımını kaldırır. Geliştirme ve birim test ortamında kullanılan DummyEventBus ise aynı sözleşmeyi korurken handle çağrılarını publish içinde sırayla await eder; testlerde spy listener’ın dolması bu yüzden deterministik kalır.
// Kaynak: apps/backend/src/application/use-cases/payment/create-payment.useCase.ts (özet)
const repos = resolveRepositories(this.ib.repositories, sandboxMode);
const infra = resolveInfra(this.ib.infra, sandboxMode);
const result = await repos.payment.createPayment({
account_id: params.accountId,
amount: params.amount,
currency: params.currency,
status: "CREATED",
// ...
});
await infra.eventBus.publish(
new PaymentCreatedDomainEvent(result, account, { sandboxMode }),
);Listener ve abonelik: sözleşme, listenTo ve subscription
Listener’lar DomainEventListenerAbstract alt sınıflarıdır; constructor’da TInfrastructureBootstrap’tan gelen ib ile repository ve diğer servislere erişebilirler (ileride tam DI ile zenginleştirilebilir). listenTo alanı tek bir EventType, dizi veya fabrika fonksiyonu olabilir; otobüs kaydı sırasında resolveListenerEventTypes bunu düz bir EventType[] listesine indirger. Bugünkü beklenti: handler içinde try-catch, anlamlı log, tekrar güvenli yan etki ve “ağır işi” kalıcı kayda bağlama; otobüsün kendisi garantili iş kuyruğu değildir.
Kayıt işlemi DomainEventBusSubscription döner: benzersiz bir kimlik ve abone olunan olay türleri içerir. Kimlik, cuid2 üzerinden üretilen üç parçanın birleşiminden oluşur (createId çıktısından tireler ve belirli bir önek temizlenerek); ProdEventBus ve DummyEventBus aynı kayıt şemasını paylaşır. Testlerde veya dinamik senaryolarda unregisterDomainEventListener ile abonelik kaldırılabilir; bu, sızıntı ve çift tetiklemeyi önlemek için özellikle test teardown’larında değerlidir.
// Kaynak: apps/backend/src/infrastructure/infra/interfaces/event-bus.ts (özet)
/** First attempt plus three retries when dispatching to a listener. */
export const MAX_LISTENER_DISPATCH_ATTEMPTS = 4;
export function resolveListenerEventTypes(
listener: DomainEventListenerAbstract,
): EventType[] {
const raw =
typeof listener.listenTo === "function"
? (listener.listenTo() ?? [])
: (listener.listenTo ?? []);
return Array.isArray(raw) ? raw : [raw];
}
export interface IDomainEventBus {
publish(event: DomainEventAbstractBase): Promise<void>;
registerDomainEventListener(
listener: DomainEventListenerAbstract,
): Promise<DomainEventBusSubscription>;
unregisterDomainEventListener(
subscription: DomainEventBusSubscription,
): Promise<void>;
}// Kaynak: apps/backend/src/domain/common/event-listener.ts
export abstract class DomainEventListenerAbstract {
constructor(
protected readonly ib: Pick<
TInfrastructureBootstrap,
"repositories" | "infra" | "environment" | "application"
>,
) {}
public abstract listenTo:
| TEventListenerDefinition
| TEventListenerDefinitionCallable;
public abstract handle(
event: DomainEventAbstractBase,
sandboxMode: boolean,
): Promise<void>;
}// Kaynak: apps/backend/src/application/payment/listeners/test.listener.ts
export class TestPaymentListener extends DomainEventListenerAbstract {
public listenTo = EventType.PAYMENT_CREATED;
public override handle(
event: PaymentCreatedDomainEvent,
sandboxMode: boolean,
) {
return Promise.resolve();
}
}// Kaynak: apps/backend/src/application/payment/listeners/index.ts
export const createPaymentModuleListeners = (ib) => {
return [new TestPaymentListener(ib)];
};Teslimat: Prod’da fire-and-forget, geliştirmede await, yeniden deneme ve loglama
Event delivery artık somut sınıfa göre ikiye ayrılıyor.
ProdEventBus (production modu): publish başlarken aboneliklerin bir anlık görüntüsünü alır (Array.from(this.subscriptions.values())), eşleşen her subscription için listener.handle çağrısını ayrı bir async IIFE içinde başlatır ve sonuçları beklemez. Böylece publish hızlıca döner; birden fazla listener eşleşirse handler’lar pratikte üst üste (concurrent) çalışabilir. Yeniden deneme döngüsü bu IIFE’nin içindedir; hata halinde aynı listener’a karşı MAX_LISTENER_DISPATCH_ATTEMPTS kadar tekrar denenir.
DummyEventBus (development / unit_test): Aynı eşleşme ve yeniden deneme mantığı publish gövdesinde doğrudan await ile çalışır; subscription’lar döngü sırasına göre sırayla işlenir ve use case publish bitene kadar listener tamamlanmış olur.
Her iki uygulamada da MAX_LISTENER_DISPATCH_ATTEMPTS (toplam dört deneme: ilk + üç yeniden deneme) ile aynı süreç içi geçici hatalara karşı sınırlı dayanıklılık hedeflenir. CHError ile diğer hatalar log mesajında ayrıştırılır; deneme sayısı tükendiğinde olay türü ile birlikte “max retries” kaydı düşülür. Bu, process yeniden başlarsa veya mesaj hiç işlenmezse ortadan kaybolan bir olayı geri getirmez; o yüzden kritik dış entegrasyonlar için asıl SOT ve retry, delivery tablosu + worker + broker hattına yazılır. Bu ayrım, üretimde istek süresini kısaltırken testlerde deterministik doğrulamayı korur.
// Kaynak: apps/backend/src/infrastructure/infra/prod/event-bus.ts (publish özeti)
async publish(event: DomainEventAbstractBase): Promise<void> {
const subscriptions = Array.from(this.subscriptions.values());
for (const subscription of subscriptions) {
if (!subscription.events.some((e) => e === event.type)) continue;
const listener = this.eventListeners.get(subscription.id);
if (!listener) continue;
// Fire and forget: dispatch and don't await or collect the result.
(async () => {
for (let attempt = 1; attempt <= MAX_LISTENER_DISPATCH_ATTEMPTS; attempt++) {
try {
await listener.handle(event, event.sandboxMode);
break;
} catch (error) {
if (error instanceof CHError) {
logger.error("Error handling event:", error);
} else {
logger.error("Unexpected error handling event:", error);
}
if (attempt >= MAX_LISTENER_DISPATCH_ATTEMPTS) {
logger.error("Max retries reached for event:", event.type);
break;
}
}
}
})();
}
// Not: publish Promise'i, handler tamamlanmadan çözülür
}Aşağıdaki şema, sandbox isteği için yüksek seviyeli akışı özetler:
Birim testler: spy listener ve teardown
Testler, spesifikasyonun çalışan koddaki yansımasıdır. bus.test.ts içinde PaymentCreatedSpyListener, createAccountContextForTestScenarios ile hazırlanan bootstrap ve sandbox hesabı üzerinden ödeme oluşturur; ardından son çağrının sandboxMode === true ve type === PAYMENT_CREATED olduğunu doğrular. Test ortamında otobüs DummyEventBus olduğundan publish listener’ı await eder ve spy deterministik dolar. finally bloğunda unregisterDomainEventListener çağrısı, diğer testlere sızmayı engeller.
Bu test aynı zamanda şunu ispatlar: olay üretimi, ödeme oluşturma use case’i ile entegre; dinleme ise otobüs kaydıyla yaşar. Bu dosya hattında korunan davranış nettir: sandbox izolasyonu ve PAYMENT_CREATED olayının gerçekten yayımlandığı.
// Kaynak: apps/backend/src/__tests__/event-bus/bus.test.ts (özet)
class PaymentCreatedSpyListener extends DomainEventListenerAbstract {
public listenTo = EventType.PAYMENT_CREATED;
public invocations: { sandboxMode: boolean; type: EventType }[] = [];
async handle(
event: PaymentCreatedDomainEvent,
sandboxMode: boolean,
): Promise<void> {
this.invocations.push({ sandboxMode, type: event.type });
}
}
it("should publish PAYMENT_CREATED to sandbox listeners with sandboxMode true", async () => {
const { ib, sandboxAccount } = await createAccountContextForTestScenarios();
const spy = new PaymentCreatedSpyListener(ib);
const subscription =
await ib.infra.sandbox.eventBus.registerDomainEventListener(spy);
try {
const payment = await ib.useCases.payment.create.execute(
{
accountId: sandboxAccount.id,
amount: 100n,
currency: Currency.TRY,
},
true,
new SystemActor(),
);
expect(payment).toBeDefined();
expect(spy.invocations.length).toBeGreaterThanOrEqual(1);
const last = spy.invocations[spy.invocations.length - 1]!;
expect(last.sandboxMode).toBe(true);
expect(last.type).toBe(EventType.PAYMENT_CREATED);
} finally {
await ib.infra.sandbox.eventBus.unregisterDomainEventListener(subscription);
}
});Yol haritası: broker soyutlaması, kuyruk, job queue ve mikroservisler
Bugünkü sistem, tek süreç içinde anlaşılır bir uyandırma hattı sunuyor; gelecekte bu katmanlar güncellenecek. Hedef, IDomainEventBus arkasında soyutlanmış bir üretim-tüketim yüzeyi kurmak: Kafka, RabbitMQ, NATS, Redis Streams veya ihtiyaca uygun herhangi bir broker ile bağlanabilen adaptörler, üzerinde tutarlı (consistent), yeniden denenebilir (retryable) ve kuyruklu (queued) teslimat; ayrıca genel amaçlı job queue ile uzun süren veya yoğun işlerin worker’larda yönetilmesi. Domain event yayını, bu dünyada “mesaj üret” adımına indirgenir; tüketim, offset, dead-letter ve idempotency anahtarları operasyonel standart haline gelir.
Sonraki adımları dört kademede düşünüyoruz.
Birinci kademe - domain içi broadcast ve kanallar. broadcastTo / broadcastMap alanlarını publish hattına bağlayarak, tek bir olayın birden fazla mantıksal kanala veya türe map edilmesini sağlamak mümkün olacak. Örneğin aynı PaymentCreated olayı hem “operasyonel webhook” hem “iç analitik” kanalına yönlendirilebilir; kanal bazlı dinleyici grupları, broker ve worker ile eşleştirildiğinde birbirini bloke etme riski azalır.
İkinci kademe - broker ve tutarlı teslimat. Birden fazla düğüm çalıştığında in-process otobüs yalnızca o süreçle sınırlı kalır. Burada amaç, olayları merkezi bir broker üzerinden yayımlamak, tüketicilerin ölçeklenebilir ve gerektiğinde yeniden oynanabilir şekilde işlemesini sağlamaktır. Mevcut IDomainEventBus sözleşmesi, uygulama kodunu kırmadan infra adaptörleriyle genişletilebilir; use case hâlâ publish çağırır, fakat mesaj disk veya cluster düzeyinde yaşar.
Üçüncü kademe - job queue ve webhook delivery. Webhook gibi dış çağrılar için kalıcı delivery kaydı, durum makinesi ve worker kontrollü HTTP denemeleri; domain event yalnızca “iş oluştur” tetikleyicisi kalır. Böylece hem operasyonel gözlem hem de müşteri SLA’sı, olay nesnesinin kendisine değil kuyruk ve tablo SOT’una dayanır.
Dördüncü kademe - domain modüllerinin mikroservis mimarisine taşınması. Uzun vadede bounded context’ler ayrı deploy birimlerine bölündüğünde, olaylar süreçler arası olduğu kadar servisler arası sınır nesneleri haline gelir. Her servis kendi veritabanını ve yaşam döngüsünü korur; şema ve sürümleme ile yönetilir. Bugünkü EventType disiplini, bu ayrıma hazırlık için zemin oluşturur.
// Geleceğe dönük temsilî arayüz - repo dışı örnek
interface ClusterDomainEventBus {
publish(
event: DomainEventAbstractBase,
options?: { channels?: string[] },
): Promise<void>;
subscribe(
eventTypes: EventType[],
handlerId: string,
onEvent: (envelope: unknown) => Promise<void>,
): Promise<SubscriptionHandle>;
}Not: application/shared/events altında reflect-metadata tabanlı ayrı bir listener denemesi de bulunuyor; ana üretim hattı infra ProdEventBus ve DomainEventListenerAbstract üzerinden ilerliyor. Eski deneme ile yeni hattın birleştirilip birleştirilmeyeceği ayrı bir refaktör konusu.
Özetle: Hsrcpay’de dahili domain event sistemi bugün çift otobüs, ortak listener kaydı, resolveInfra ile doğru emitter ve tip güvenli olay nesneleri ile “bir şey oldu, dinleyiciler uyansın” ihtiyacını karşılıyor; kalıcı teslimat ve SOT ise listener’ların açtığı kayıtlar ve ileride broker + kuyruk + worker katmanında şekillenecek. Üretimde ProdEventBus fire-and-forget çalışır; geliştirme ve testte DummyEventBus aynı arayüzle await edilen teslimat sunar. 12 Nisan 2026 itibarıyla bu yazıdaki davranış, özellikle PAYMENT_CREATED hattı ve sandbox testi ile kod tabanında doğrulanabilir durumda. Broker soyutlaması, job queue, tutarlı yeniden denenebilir kuyruk ve mikroservis sınırları ise bilinçli bir sonraki evrimdir.