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.
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(),
};
}
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
export const bootstrapEventListeners = (ib: /* TInfrastructureBootstrap */) => {
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)
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 sandboxMode(): boolean {
return this.eventStorage.get<boolean>("sandboxMode")!;
}
public get createdAt(): Date {
return this.eventStorage.get<Date>("createdAt")!;
}
}// 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, webhook veya raporlama gibi işler bu çağrının hemen altında sıralanmaz; bunlar listener tarafına taşınır veya ileride taşınacak şekilde sınır çizilir.
// 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.
Kayıt işlemi DomainEventBusSubscription döner: benzersiz bir kimlik ve abone olunan olay türleri içerir. 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)
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: senkron dispatch, yeniden deneme ve hata sınıflandırması
Event delivery şu anki uygulamada senkron ve abonelik sırasına bağlıdır: publish çağrıldığında, olay türü eşleşen her subscription için listener.handle(event, event.sandboxMode) await edilir. Bu model, basitlik ve tutarlılık sağlar: use case tamamlanmadan önce listener hataları loglanır ve kontrollü yeniden deneme uygulanır.
MAX_LISTENER_DISPATCH_ATTEMPTS (toplam dört deneme: ilk + üç yeniden deneme) ile geçici hatalara karşı 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 davranış, ileride kuyruk tabanlı asenkron teslimata geçildiğinde de benzer semantiğin (dead-letter, retry policy) taşınmasına zemin hazırlar.
// Kaynak: apps/backend/src/infrastructure/infra/prod/event-bus.ts (publish gövdesi - sadeleştirilmiş)
async publish(event: DomainEventAbstractBase): Promise<void> {
for (const subscription of this.subscriptions.values()) {
if (!subscription.events.some((e) => e === event.type)) continue;
const listener = this.eventListeners.get(subscription.id);
if (!listener) continue;
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;
}
}
}
}
}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, gerçek bootstrap ve sandbox repository ile ödeme oluşturur; ardından son çağrının sandboxMode === true ve type === PAYMENT_CREATED olduğunu doğrular. 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. Son commit geçmişi çoğunlukla genel başlıklar içerse de, 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) {
this.invocations.push({ sandboxMode, type: event.type });
}
}
it("should publish PAYMENT_CREATED to sandbox listeners with sandboxMode true", async () => {
const spy = new PaymentCreatedSpyListener(ib);
const subscription =
await ib.infra.sandbox.eventBus.registerDomainEventListener(spy);
try {
const account = await ib.repositories.sandbox.account.createAccount({
country: "TR",
currency: "TRY",
name: "TEST",
});
await ib.useCases.payment.create.execute(
{ accountId: account.id, amount: 100n, currency: Currency.TRY },
true,
new SystemActor(),
);
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ı: broadcast, cluster ve mikroservisler
Bugünkü sistem, tek süreç içinde güvenilir ve anlaşılır bir domain event hattı sunuyor. Sonraki adımları üç 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ı, yan etkilerin birbirini bloke etmesini azaltmak için ileride kuyruk veya worker havuzlarıyla eşleştirilebilir.
İkinci kademe - cluster düzeyinde event-driven yönetim. 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 (örneğin Redis Streams, NATS, Kafka veya RabbitMQ) üzerinden yayımlamak, tüketicilerin ölçeklenebilir ve yeniden oynanabilir şekilde işlemesini sağlamaktır. Bu geçişte mevcut IDomainEventBus sözleşmesi, altyapı adaptörleriyle korunabilir; uygulama katmanı hâlâ publish ve kayıtlı handler semantiğini görür.
Üçüncü kademe - domain modüllerinin mikroservis mimarisine taşınması. Uzun vadede bounded context’ler ayrı deploy birimlerine bölündüğünde, olaylar sadece süreçler arası değil servisler arası sınır nesneleri haline gelir. Her servis kendi veritabanını ve yaşam döngüsünü korur; entegrasyon olay şemaları ve sürümleme (örneğin olay sürümü veya uyumluluk katmanı) ile yönetilir. Bu, dağıtık tutarlılık ve operasyonel bağımsızlık için bedeli olan bir adımdır; fakat bugünkü domain event ve tip güvenli EventType disiplini, o güne hazırlık için zemin oluşturuyor.
// 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, çift otobüs, ortak listener kaydı, resolveInfra ile doğru emitter, tip güvenli olay nesneleri ve yeniden denemeli senkron teslimat ile bugünün ihtiyaçlarını karşılıyor. 10 Nisan 2026 itibarıyla bu yazıdaki davranış, özellikle PAYMENT_CREATED hattı ve sandbox testi ile kod tabanında doğrulanabilir durumda. Broadcast, cluster broker ve mikroservis ayrımı ise aynı çizgide, kontrollü adımlarla genişletilecek sonraki katmanlar.