Observer and Notification Patterns
This document describes the patterns for observer registration, notification dispatch, error isolation, and testing async observer behavior in Arda’s Kotlin coroutine-based services.
Notification Dispatch Patterns
Section titled “Notification Dispatch Patterns”Notifications After Commit, Never Inside Transactions
Section titled “Notifications After Commit, Never Inside Transactions”Notifications dispatched inside inTransaction blocks risk becoming “phantom notifications” — sent to observers even if the transaction subsequently rolls back.
Use the .also { rs -> rs.onSuccess { ... } } pattern applied to the inTransaction return value to ensure notifications fire only after a successful commit:
inTransaction(db) { // ... persistence operations Result.success(updatedEntity)}.also { rs -> rs.onSuccess { entity -> notifyObservers(DataAuthorityNotification.UpdateEntity(entity)) }}Cascade Methods Bypass Service-Level Notifications
Section titled “Cascade Methods Bypass Service-Level Notifications”Methods like propagateBusinessAffiliateRenaming call universe.update() directly, not RoleService.update(). This means the notification dispatch in the service method is skipped.
Any cascade method that directly calls a universe must independently handle notifications.
No Double-Notification Risk from Cascade
Section titled “No Double-Notification Risk from Cascade”Because cascade methods call universe.update() directly (not the service), adding notify calls to cascade methods does not risk duplicate notifications. Only one notification path is active per operation.
EntityRecord.fromEntity() Bridges Bitemporal and Notification Domains
Section titled “EntityRecord.fromEntity() Bridges Bitemporal and Notification Domains”The EntityRecord.fromEntity(BitemporalEntity) companion function maps all required fields (rId, asOf, payload, metadata, previous, retired, author, createdBy, createdAt). Essential when cascade methods work with BitemporalEntity pairs while the notification system requires EntityRecord.
Observer Registration Patterns
Section titled “Observer Registration Patterns”Observer Registration in init Blocks Has Wide Test Impact
Section titled “Observer Registration in init Blocks Has Wide Test Impact”Adding observer subscription (e.g., addRoleObserver(...)) in an init block means the mock is called during construction. Every test file that instantiates the containing class — including unrelated modules — must now stub the observer method.
Prefer:
- Deferring registration to a separate
start()/initialize()method - Using log-only error handling instead of re-throwing on failure
Error Isolation in Observer Callbacks
Section titled “Error Isolation in Observer Callbacks”Observer callbacks should use runCatching to prevent errors from propagating back to the ObserverManager. Without isolation, an error in one observer could disrupt notification dispatch to other observers or cause the notifying service’s operation to fail:
observers.forEach { observer -> runCatching { observer.onNotification(event) } .onFailure { e -> log.error("Observer error", e) }}Observer Implementation
Section titled “Observer Implementation”In-Memory Observer Manager
Section titled “In-Memory Observer Manager”Observable.kt provides a simple in-memory implementation of observer registration + notification dispatch. Use for local/dev or as a placeholder before a real event bus integration.
DataAuthorityService emits notifications after successful mutations. Register observers when you need consistent notification semantics for create/update/batch operations.
- Code:
/common-module/lib/src/main/kotlin/cards/arda/common/lib/service/Listener.kt - Code:
/common-module/lib/src/main/kotlin/cards/arda/common/lib/service/Observable.kt
Testing Observers and Async Behavior
Section titled “Testing Observers and Async Behavior”CompletableDeferred for Async Notification Verification
Section titled “CompletableDeferred for Async Notification Verification”CompletableDeferred + withTimeout is a reliable pattern for verifying that a single notification was received asynchronously. Avoids flakiness from Thread.sleep or polling:
val received = CompletableDeferred<DataAuthorityNotification>()service.addObserver { notification -> received.complete(notification) }
service.update(entity)
val notification = withTimeout(5000) { received.await() }assertEquals(expectedId, notification.entityId)Testing Observer Deregistration (Proving Absence)
Section titled “Testing Observer Deregistration (Proving Absence)”CompletableDeferred cannot prove absence of a call. Use a MutableList to collect notifications, combined with delay() to allow async dispatch to complete, then assert the notification count remains unchanged:
val received = mutableListOf<DataAuthorityNotification>()val handle = service.addObserver { received.add(it) }handle.remove() // deregister
service.update(entity)delay(100) // allow time for any dispatch
assertEquals(0, received.size)Multiple Notifications in Tests
Section titled “Multiple Notifications in Tests”For scenarios producing multiple notifications, use an AtomicInteger dispatch counter with separate CompletableDeferred instances for each expected notification. This is both thread-safe and deterministic.
ApplicationContext Required for Observer Tests
Section titled “ApplicationContext Required for Observer Tests”ObserverManager implementations like InMemoryObserverManagerDelegate require ApplicationContext in the coroutine context:
- Create an
ApplicationContext(e.g.,AuthPrincipal.Internal) - Wrap the service call in
withContext(appCtx) { ... } - Use
CompletableDeferred+withTimeoutto await the notification
Centralize the Test Observer Pattern
Section titled “Centralize the Test Observer Pattern”The CompletableDeferred + ApplicationContext + withTimeout pattern is repeated across many test files. Extract a reusable TestObserverHelper or extend Harness to reduce duplication.
Copyright: © Arda Systems 2025-2026, All rights reserved