Summary
This issue proposes the full removal of the Observer/LazyObject reactive notification pattern from QLNet, targeting a v2.0 release. This is a breaking change that significantly improves thread safety, readability, performance and long-term maintainability.
Why the pattern exists
QLNet inherited the Observer/LazyObject pattern from C++ QuantLib, which was designed for Excel RTD (Real-Time Data) use cases:
Cell A1 = yield curve rate → changes
Cell B1 = bond NPV → auto-notified, recalculates
Cell C1 = duration → auto-notified, recalculates
When a single market input changes, the dirty flag propagates through a dependency chain, and all dependent objects recalculate on next access. This makes perfect sense in a live spreadsheet.
Why it does not fit QLNet's use case
QLNet targets C# microservices and applications, not Excel. The typical lifecycle is:
Request in → build objects → calculate → return result → GC everything
Objects do not outlive a request. Nobody registers observers on pricing objects. The entire notification chain fires zero times in practice, yet carries its full complexity cost in every deployment.
Scope (measured from codebase)
| Metric |
Count |
registerWith() occurrences |
467 across 196 files |
notifyObservers() occurrences |
150 across 43 files |
update() implementations |
62 |
Files implementing IObservable/IObserver |
26 |
| Infrastructure files to delete |
4 (Observer.cs, WeakEventSource.cs, ObservableValue.cs, LazyObject.cs simplified) |
What we gain permanently
Thread safety — the full picture
Removing the observer pattern eliminates the largest category of thread-safety issues:
1. LazyObject — foundational flaw (45+ subclasses affected)
calculated_ and frozen_ are plain bool fields with no synchronisation. Two threads can simultaneously pass the if (!calculated_ && !frozen_) guard and both execute performCalculations(), corrupting the cached state. This affects every Instrument, PricingEngine, TermStructure, and calibration helper in the library.
// Thread A and Thread B both reach here at the same time:
if (!calculated_ && !frozen_) // both evaluate TRUE
{
calculated_ = true;
performCalculations(); // runs TWICE — state corrupted
}
2. Handle.Link.linkTo() — broken observer chain under concurrency
linkTo() is not atomic. Two threads racing on linkTo() can leave h_ pointing to one object while the observer is registered with a different object — notifications are silently lost and the dependency graph is wrong.
// Thread A: linkTo(objectX, true)
// Thread B: linkTo(objectY, true)
// Result: h_ = objectY, but registered with objectX → notifications lost
3. ObservableValue<T> — unprotected read/write on backing field
Assign(t) writes value_ while value() reads it with no synchronisation. Lower severity in practice because writes are usually done during setup, but unsafe if any background thread refreshes market data.
4. WeakEventSource — recursive notification chains
Although WeakEventSource itself locks its handler list, the notification chain it drives (Instrument → TermStructure → Index → ...) can form cycles and re-enter locks on the same thread, or deadlock if two chains notify each other concurrently.
Beyond the observer pattern, the following independent static shared state issues exist and are also addressed in v2.0:
| Class |
File |
Issue |
Fix |
IndexManager.data_ |
Indexmanager.cs |
Plain Dictionary — ContainsKey+Add not atomic, Clear unsafe mid-read. Thread A calling getHistory() while Thread B calls clearHistories() → exception or null reference. CRITICAL — all index fixing access goes through here. |
ConcurrentDictionary + lock compound ops |
SeedGenerator.rng_ |
SeedGenerator.cs |
Singleton with shared MersenneTwisterUniformRng rng_. get() both reads and advances the RNG state with no lock → two threads get the same seed or corrupt the RNG internal state. |
lock on get() |
ExchangeRateManager |
ExchangeRateManager.cs |
Shared Dictionary<int, List<Entry>> — concurrent add/lookup unprotected. |
ReaderWriterLockSlim or ConcurrentDictionary |
After both the observer removal and these fixes, the library will be fully thread-safe for concurrent calculation workloads.
Readability — every class loses ~20% noise
Every instrument, term structure, model and pricer currently contains boilerplate registerWith(...) / update() / notifyObservers() calls unrelated to financial logic. Removing them makes the financial math the only thing in each class.
Performance
- Zero
WeakReference allocations per object
- No virtual dispatch through observer chains on market data changes
- No
WeakEventSource list traversal
- Predictable, deterministic calculation — no hidden re-triggers
Microservice fit
Objects become effectively immutable after construction — safe to use from any thread with no setup, no SavedSettings backup/restore needed in production code.
Simpler onboarding
New contributors currently need to understand the full observer chain before safely modifying any pricing class. After removal: read performCalculations(), that is the entire contract.
What is removed
IObservable / IObserver interfaces, Callback delegate
WeakEventSource.cs
ObservableValue<T>
LazyObject: frozen_, update(), notifyObservers(), freeze(), unfreeze(), event source
- All
registerWith() / unregisterWith() / notifyObservers() / update() calls across all 196 affected files
Settings.notifyObservers() on setEvaluationDate()
What is kept
Migration guide for users
If you used registerWith / update(): Remove these calls. The reactive push pattern is gone. Call instrument.recalculate() explicitly after changing inputs, or rebuild the object.
If you used freeze() / unfreeze(): Remove these calls. Objects cache automatically for their lifetime.
If you relied on cascading recalculation: Replace with explicit recalculate() after input changes, or build → calculate → discard.
Implementation plan
- Phase 1 — Delete pattern infrastructure (
Observer.cs, WeakEventSource.cs, ObservableValue.cs, simplify LazyObject.cs)
- Phase 2 — Strip observer calls from all 196 source files, grouped by domain
- Phase 3 — Update test suite, add concurrent-access tests
- Phase 4 — Fix independent static shared state thread-safety issues (
IndexManager, SeedGenerator, ExchangeRateManager)
- Phase 5 — Update
ChangeLog.txt, bump to v2.0
Feedback welcome before implementation starts.
Summary
This issue proposes the full removal of the Observer/LazyObject reactive notification pattern from QLNet, targeting a v2.0 release. This is a breaking change that significantly improves thread safety, readability, performance and long-term maintainability.
Why the pattern exists
QLNet inherited the Observer/LazyObject pattern from C++ QuantLib, which was designed for Excel RTD (Real-Time Data) use cases:
When a single market input changes, the dirty flag propagates through a dependency chain, and all dependent objects recalculate on next access. This makes perfect sense in a live spreadsheet.
Why it does not fit QLNet's use case
QLNet targets C# microservices and applications, not Excel. The typical lifecycle is:
Objects do not outlive a request. Nobody registers observers on pricing objects. The entire notification chain fires zero times in practice, yet carries its full complexity cost in every deployment.
Scope (measured from codebase)
registerWith()occurrencesnotifyObservers()occurrencesupdate()implementationsIObservable/IObserverObserver.cs,WeakEventSource.cs,ObservableValue.cs,LazyObject.cssimplified)What we gain permanently
Thread safety — the full picture
Removing the observer pattern eliminates the largest category of thread-safety issues:
1.
LazyObject— foundational flaw (45+ subclasses affected)calculated_andfrozen_are plainboolfields with no synchronisation. Two threads can simultaneously pass theif (!calculated_ && !frozen_)guard and both executeperformCalculations(), corrupting the cached state. This affects everyInstrument,PricingEngine,TermStructure, and calibration helper in the library.2.
Handle.Link.linkTo()— broken observer chain under concurrencylinkTo()is not atomic. Two threads racing onlinkTo()can leaveh_pointing to one object while the observer is registered with a different object — notifications are silently lost and the dependency graph is wrong.3.
ObservableValue<T>— unprotected read/write on backing fieldAssign(t)writesvalue_whilevalue()reads it with no synchronisation. Lower severity in practice because writes are usually done during setup, but unsafe if any background thread refreshes market data.4.
WeakEventSource— recursive notification chainsAlthough
WeakEventSourceitself locks its handler list, the notification chain it drives (Instrument→TermStructure→Index→ ...) can form cycles and re-enter locks on the same thread, or deadlock if two chains notify each other concurrently.Beyond the observer pattern, the following independent static shared state issues exist and are also addressed in v2.0:
IndexManager.data_Indexmanager.csDictionary—ContainsKey+Addnot atomic,Clearunsafe mid-read. Thread A callinggetHistory()while Thread B callsclearHistories()→ exception or null reference. CRITICAL — all index fixing access goes through here.ConcurrentDictionary+ lock compound opsSeedGenerator.rng_SeedGenerator.csMersenneTwisterUniformRng rng_.get()both reads and advances the RNG state with no lock → two threads get the same seed or corrupt the RNG internal state.lockonget()ExchangeRateManagerExchangeRateManager.csDictionary<int, List<Entry>>— concurrent add/lookup unprotected.ReaderWriterLockSlimorConcurrentDictionaryAfter both the observer removal and these fixes, the library will be fully thread-safe for concurrent calculation workloads.
Readability — every class loses ~20% noise
Every instrument, term structure, model and pricer currently contains boilerplate
registerWith(...)/update()/notifyObservers()calls unrelated to financial logic. Removing them makes the financial math the only thing in each class.Performance
WeakReferenceallocations per objectWeakEventSourcelist traversalMicroservice fit
Objects become effectively immutable after construction — safe to use from any thread with no setup, no
SavedSettingsbackup/restore needed in production code.Simpler onboarding
New contributors currently need to understand the full observer chain before safely modifying any pricing class. After removal: read
performCalculations(), that is the entire contract.What is removed
IObservable/IObserverinterfaces,CallbackdelegateWeakEventSource.csObservableValue<T>LazyObject:frozen_,update(),notifyObservers(),freeze(),unfreeze(), event sourceregisterWith()/unregisterWith()/notifyObservers()/update()calls across all 196 affected filesSettings.notifyObservers()onsetEvaluationDate()What is kept
LazyObject.calculate()/performCalculations()— compute-once caching is preserved. Objects still calculate lazily on first access and cache the result for their lifetime.recalculate()is kept for explicit invalidation.Handle<T>/RelinkableHandle<T>— indirection mechanism kept, only notification side removedSettings.evaluationDateas[ThreadStatic]Migration guide for users
If you used
registerWith/update(): Remove these calls. The reactive push pattern is gone. Callinstrument.recalculate()explicitly after changing inputs, or rebuild the object.If you used
freeze()/unfreeze(): Remove these calls. Objects cache automatically for their lifetime.If you relied on cascading recalculation: Replace with explicit
recalculate()after input changes, or build → calculate → discard.Implementation plan
Observer.cs,WeakEventSource.cs,ObservableValue.cs, simplifyLazyObject.cs)IndexManager,SeedGenerator,ExchangeRateManager)ChangeLog.txt, bump to v2.0Feedback welcome before implementation starts.