All posts
Part 03 of 10
69static events929 references. 218 subscriptions. 6 memory leaks.
Part 03 of 10

The God Object Has 69 Static Events

929 references. 218 subscriptions. 6 memory leaks.

Engineers·12 min read

For engineers. Technical depth ahead, but the story's for everyone.

In Part 1, the app crashed at sixty characters. In Part 2, I showed you the state of the codebase. Now I need to show you the thing at the center of it all — the reason nothing could be fixed in isolation.

One class. 929 references. 69 static events. 150 files depending on it. Meet EveMonClient.

01

The Crime Scene

EveMonClient was a public static partial class split across two files:

  • EveMonClient.cs — 576 lines. Lifecycle, collections, paths, diagnostics.
  • EveMonClient.Events.cs — 1,280 lines. Events and firing methods.

Total: 1,856 lines doing six completely different jobs:

  1. Starting and stopping the application
  2. Broadcasting events to every screen in the app
  3. Holding references to every global collection
  4. Managing filesystem paths
  5. Running the diagnostic trace system
  6. Tracking global state flags

In software, we call this a "god object." It knows everything, controls everything, and everything depends on it. Change one thing and you risk breaking everything else.

Here's why it mattered for the crash.

02

Why This Broke at Sixty Characters

Every part of the application communicated through EveMonClient's events. When a character's skill queue updated, the scheduler called EveMonClient.OnCharacterSkillQueueUpdated(), which fired the CharacterSkillQueueUpdated event, which every open screen received.

With five characters, that's manageable. With thirty, it's 600 event firings per update cycle — still tolerable. With sixty, it's 1,200. And every one of those events went through the UI thread via ThreadSafeInvoke, which blocked the calling thread until the UI finished processing.

The scheduler was trying to push updates through a single-threaded bottleneck. At sixty characters, the bottleneck collapsed.

03

The Evidence

MetricCount
Files referencing EveMonClient150 out of ~1,100
Total references in codebase929
Event subscriptions (+=)218
Event unsubscriptions (-=)212
Net subscription imbalance6 leaked
Dead events (zero subscribers)5

Read that second-to-last line again. 218 places in the code subscribed to events. Only 212 unsubscribed. Six event handlers leaked — staying in memory, receiving events forever, even after the screens they belonged to were closed.

Five of the leaks were on TimerTick — the event that fired every second. Static objects that live for the lifetime of the process, so the leak was technically harmless but architecturally wrong. The sixth was on MainWindow — a genuine memory leak that would compound if the window were ever recreated.

And five events — ESIKeyMonitoredChanged, CharacterListUpdated, CharacterPortraitUpdated, CharacterContractBidsDownloaded, CorporationContractBidsDownloaded — were declared, had firing methods that ran on every trigger, but nothing in the entire codebase subscribed to them. Pure dead code, running on every cycle, doing nothing.

04

The Four Problems

Memory leaks. The += operator creates a strong reference. Subscribe on screen open, forget to unsubscribe on close, and the screen stays in memory forever — still receiving every event. Open and close the skill planner ten times, and you have ten phantom planners in memory, each processing every event.

Untestable architecture. The events were static — attached to the class, not an instance. To test any code that used events, you needed the real EveMonClient.Initialize(), which needed Settings.Initialize(), which needed filesystem access. Nothing could be tested in isolation. This is the root cause of 23 tests for 210,000 lines.

Threading bottleneck. Events fired from background threads used ThreadSafeInvoke to reach the UI thread — blocking the caller until the UI processed the event. Under load, background threads piled up waiting for the UI thread, which was busy processing the previous batch. This is what killed the app at sixty characters.

Broadcast storm. With thirty characters, subscribing to CharacterUpdated meant receiving updates for all thirty. Every handler had to check: "Is this about my character?" Thirty characters times twenty event types equals 600 subscriptions where 580 out of 600 did nothing on every fire.

05

The Fix: 243 Lines

The replacement was an EventAggregator — a message broker where each event is its own type, subscriptions are instance-based and disposable, and per-character filtering is built in.

// Subscribe — type-safe, scoped to this screen
_sub = AppServices.EventAggregator.Subscribe<CharacterUpdatedEvent>(OnUpdate);

// When the screen closes — clean, no leaks
_sub.Dispose();

Key design decisions:

  • Type-keyed subscriptions — the compiler catches mismatches, not runtime
  • Per-list locks — not a global lock, so publishing one event type doesn't block another
  • Strong by default, weak optional — strong references unless you explicitly opt into garbage-collector-friendly weak references
  • Exception isolation — one failing subscriber never breaks others
  • Per-character filteringSubscribeForCharacter<T>() delivers events only for the character you care about. Thirty characters, one subscription, zero wasted checks

243 lines. Replacing 1,280 lines of boilerplate.

06

The Migration

I used the Strangler Fig pattern — named after the tree that grows around a host tree and gradually replaces it.

Phase 1: The EventAggregator wraps the old events. When old events fire, the aggregator publishes the new equivalent. New code subscribes to the aggregator. Old code still works.

Phase 2: Migrate subscribers one by one. Each screen switches from EveMonClient.Xxx += handler to EventAggregator.Subscribe<XxxEvent>(). Each migration is a single commit, independently testable.

Phase 3: Delete the old events.

February 15th was the big day. Five commits:

bee27ed3 — Complete UI event migration to EventAggregator
02e7e990 — Per-character event scoping
bdbc08d3 — Remove 67 dead static events (-410 lines)
8996cb77 — Remove 68 dead firing methods
a935f971 — Complete event migration, remove dual channels

67 events removed. 68 firing methods removed. 410+ lines deleted. The god object went from 1,280 lines of active events to a thin compatibility shim — kept only because 122 files still referenced it for collection access, and those would be migrated next.

07

Proof It Works

23 tests covering EventAggregator alone:

  • Basic pub/sub, multiple subscribers, unsubscription, weak references
  • Concurrent publish from 500 tasks
  • Concurrent subscribe and publish from 50 threads simultaneously
  • Re-entrant publish 10 levels deep
  • 1000 concurrent publishing threads

The god object is tamed. But everything was still in one assembly. To split the project apart — to actually enforce the boundaries that would prevent this from happening again — I needed to do surgery on a codebase that was still running in production.

Previous: Part 2 — What I Found When I Opened the Hood

Next: Part 4 — Surgery on a Beating Heart