Skip to content

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.

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.

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 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

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) }
}

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

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)

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:

  1. Create an ApplicationContext (e.g., AuthPrincipal.Internal)
  2. Wrap the service call in withContext(appCtx) { ... }
  3. Use CompletableDeferred + withTimeout to await the notification

The CompletableDeferred + ApplicationContext + withTimeout pattern is repeated across many test files. Extract a reusable TestObserverHelper or extend Harness to reduce duplication.