Practical

Event Systems

How inner layers communicate outward without violating the dependency rule.

The Communication Problem

In a layered architecture, inner layers cannot reference outer layers. The Core can’t call the UI to refresh a health bar. The Use Cases layer can’t tell the audio system to play a sound. The dependency arrows point inward - always.

But things happen in the inner layers that the outer layers need to know about. When a player takes damage, the UI needs to update. When a quest completes, the audio needs to play a fanfare. When the inventory changes, the save system needs to persist the new state.

How does the inner layer communicate outward without depending on the outer layer?

Events.

How Events Solve the Direction Problem

An event is a signal that says “something happened” without saying “and here’s what you should do about it.” The emitter publishes; the subscribers react. The emitter has no knowledge of who’s listening, how many listeners there are, or what they do.

Core layer (emitter):
"The player's health changed." -> emits OnHealthChanged

UI layer (subscriber):
"I heard health changed." -> updates the health bar

Audio layer (subscriber):
"I heard health changed." -> plays a damage sound

Analytics layer (subscriber):
"I heard health changed." -> logs an event

Save layer (subscriber):
"I heard health changed." -> triggers auto-save

The Core knows about OnHealthChanged. It does not know about health bars, damage sounds, analytics, or save systems. The dependency rule is preserved: the outer layers subscribe to events defined in the inner layers. The inner layers never reference the outer layers.

Anatomy of a Good Event System

A well-designed event system for game architecture needs a few properties:

Type-Safe Events

Events should carry typed payloads. An OnDamageDealt event should provide the damage amount, the source, and the target - not just a notification that “something happened about damage.”

// Zero-parameter event
EventSource OnGamePaused;

// Typed event
EventSource<int> OnHealthChanged;                    // new health value
EventSource<string, int> OnItemAdded;                // item ID, quantity
EventSource<string, string, float> OnDamageDealt;    // source, target, amount

Type safety means the compiler catches mismatches between what the emitter sends and what the subscriber expects. No casting, no string parsing, no runtime errors.

Owned Events, Not a Global Bus

A common design is a global event bus - a single static dispatcher where any class can publish any event and any class can subscribe to any event. This sounds flexible. In practice, it creates invisible coupling that’s nearly impossible to debug.

The better pattern: events are properties on the objects that own them. The IPlayerHealth interface exposes OnHealthChanged. The IQuestLog interface exposes OnQuestCompleted. Subscribers must have a reference to the specific source - they can’t listen to “all health changes everywhere” without explicitly subscribing to a specific player’s health.

// Good: Event owned by specific interface
interface IPlayerHealth {
    int CurrentHealth { get; }
    EventSource<int> OnHealthChanged { get; }
}

// Bad: Global event bus
EventBus.Subscribe<HealthChangedEvent>(handler); // Who emitted it? Who knows.

Owned events make dependencies visible. If a class subscribes to playerHealth.OnHealthChanged, you can see the dependency. With a global bus, dependencies are invisible - hidden inside method bodies, discovered only at runtime.

Idempotent Emission

Events should only fire on effective state changes. If a setter receives the same value the field already holds, it should not emit an event. This prevents cascading reactions from no-op operations and makes the system predictable.

// Good: Only emits when value actually changes
set {
    if (_health == value) return;
    _health = value;
    OnHealthChanged.Emit(_health);
}

// Bad: Emits on every call, even if nothing changed
set {
    _health = value;
    OnHealthChanged.Emit(_health);  // May trigger unnecessary UI updates, saves, etc.
}

No Events from Constructors

Events should not fire during object construction. Subscribers may not be ready yet. The order of construction is often unpredictable, especially in game engines where initialization happens across multiple frames or phases.

Emit events only after the object is fully constructed and its state has been explicitly changed through a method or property.

Events in Practice: A Unity Example

Here’s how a typed event system works in a real Unity project. The engine-specific parts are clearly separated from the general pattern.

Event Source Definition (Engine-Agnostic)

The core event type is straightforward - a list of subscribers with an invoke method:

// A zero-parameter event source
public class EventSource {
    private readonly List<Action> _subscribers = new();

    public void Subscribe(Action handler) => _subscribers.Add(handler);
    public void Unsubscribe(Action handler) => _subscribers.Remove(handler);
    public void Emit() {
        foreach (var handler in _subscribers) handler();
    }
}

// A typed event source
public class EventSource<T> {
    private readonly List<Action<T>> _subscribers = new();

    public void Subscribe(Action<T> handler) => _subscribers.Add(handler);
    public void Unsubscribe(Action<T> handler) => _subscribers.Remove(handler);
    public void Emit(T value) {
        foreach (var handler in _subscribers) handler(value);
    }
}

In production, you’d add operator overloads (+= / -=), automatic unsubscription on disposal, and thread safety if needed. But the concept is this simple.

Domain Usage (Core Layer)

// Core/IPlayerHealth.cs - pure domain interface
public interface IPlayerHealth {
    int CurrentHealth { get; }
    int MaxHealth { get; }
    EventSource<int> OnHealthChanged { get; }
}

The Core defines the event as part of the domain contract. It has zero engine dependencies.

Subscription (Outer Layers)

// Controllers/HealthBarController.cs - reacts to domain events, drives the view
public class HealthBarController {
    private IPlayerHealth _playerHealth;
    private IHealthBarView _view;

    public HealthBarController(IPlayerHealth playerHealth, IHealthBarView view) {
        _playerHealth = playerHealth;
        _view = view;
        _playerHealth.OnHealthChanged.Subscribe(OnHealthUpdated);
    }

    private void OnHealthUpdated(int newHealth) {
        float ratio = (float)newHealth / _playerHealth.MaxHealth;
        _view.SetFillAmount(ratio);
    }
}

The UI controller subscribes to a domain event. It depends on the Core’s IPlayerHealth. The Core has no idea this controller exists.

Lifecycle and Cleanup

In games, objects are created and destroyed frequently. Event subscriptions must be cleaned up to avoid:

A robust event system integrates with your disposal/lifecycle mechanism. When a controller is disposed, all its subscriptions are automatically removed.

Event Anti-Patterns

The Global Event Bus

A single static bus where anything can publish and anything can subscribe. Convenient, untraceable, and a maintenance nightmare. Every class is coupled to every other class through an invisible layer of string-keyed or type-keyed events.

Event Chains

Event A triggers handler B, which emits event C, which triggers handler D, which emits event E… When something goes wrong, the call stack is incomprehensible. Keep event handling shallow. If a handler needs to trigger complex logic, delegate to a use case rather than emitting another event.

Overusing Events for Direct Communication

Not everything should be an event. If class A always needs to call exactly one method on class B, that’s a direct dependency - express it as an injected interface. Events are for one-to-many, optional, or cross-layer communication. Using them for everything makes the code harder to follow, not easier.

Forgetting Idempotence

Emitting events on no-op state changes creates cascading busywork. The UI re-renders. The save system re-saves. The analytics system logs duplicates. Always guard emission with a change check.

Summary

Events are the mechanism that allows inner layers to communicate outward without violating the dependency rule. They enable the Architecture Model’s strict layering by providing a one-way channel from Core to the outside world.

The key principles: