What is the Publish/Subscribe Design Pattern?
The Publish/Subscribe Design Pattern is a technique for one or more objects to know when something happens to another object – without them needing to continually inspect the object.
In this example, I’ll show a non-pattern way to monitor an object, a way using the publish/subscribe pattern, and an enhanced way to pass additional information when “publishing”.
The sample code will be for a game. If the player moves to a location that is poisonous, they take damage. If the player’s hit points drop to zero or less, the game wants to know, so it can display a message in the UI.
Shared code
For all version of the code, the Location class stays the same. It’s a simple class with two properties: Name and IsPoisonous.
namespace DesignPatternsCSharpNet6.PublishSubscribe.NonPattern.Models; public class Location { public string Name { get; } public bool IsPoisonous { get; } public Location(string name, bool isPoisonous) { Name = name; IsPoisonous = isPoisonous; } }
Non-Pattern Version
In this version, the Player class only holds values.
namespace DesignPatternsCSharpNet6.PublishSubscribe.NonPattern.Models; public class Player { public string Name { get; } public int HitPoints { get; set; } public Player(string name, int hitPoints) { Name = name; HitPoints = hitPoints; } }
When the GameSession class changes the player’s hit points, the GameSession class is responsible for checking if the hit points reached zero or lower. We do this by having the GameSession’s MoveTolLocation function call CheckIfPlayerDied after it applies damage to the CurrentPlayer object.
If we add more ways for the player to take damage, using more functions in the GameSession class, each of those functions will need to call CheckIfPlayerDied. This is easy to forget if you’re adding lots of functions for doing damage to the player.
If we ever add other classes/objects that can change the player’s hit points, they may all have to coordinate with each other. The GameSession object won’t know the player died if some other object in the program hurt the player.
using System.Collections.ObjectModel; using DesignPatternsCSharpNet6.PublishSubscribe.NonPattern.Models; namespace DesignPatternsCSharpNet6.PublishSubscribe.NonPattern.ViewModels; public class GameSession { public ObservableCollection<string> UiMessages { get; } = new ObservableCollection<string>(); public Player CurrentPlayer { get; } public Location CurrentLocation { get; set; } public GameSession() { CurrentPlayer = new Player("Scott", 10); CurrentLocation = new Location("Home", false); } public void MoveToLocation(Location location) { CurrentLocation = location; if(CurrentLocation.IsPoisonous) { // Player takes one point damage in poisonous locations CurrentPlayer.HitPoints--; CheckIfPlayerDied(); } } private void CheckIfPlayerDied() { if(CurrentPlayer.HitPoints <= 0) { UiMessages.Add("You died"); } } }
Pattern Version
In the pattern version, we add the event PlayerDied on line 25 of the Player class.
This is for the “subscribe” part of the design pattern.
Think of an event like a property. It’s something other objects can see and “set”. However, in this case, the other objects aren’t setting a value in a property. They’re adding a pointer to one of their functions that they want the Player object to run. We’ll see the subscription part when we look at the code for the GameSession class.
We also changed the code for the Player’s HitPoints property. Now, in the setter, we check if the hit points dropped to zero or lower. If they did, we run the code on line 20. This is the “publish” part of the pattern.
In this case, the Player class is not just going to notify the GameSession class that the player died. The Player object is going to run the function the GameSession passed into the PlayerDied eventhandler.
The Invoke will run the function that was added to the PlayerDied event, passing in “this” (the player object) and “EventArgs.Empty”.
namespace DesignPatternsCSharpNet6.PublishSubscribe.Pattern.Models; public class Player { private int _hitPoints; public string Name { get; } public int HitPoints { get => _hitPoints; set { _hitPoints = value; if(_hitPoints <= 0) { // When the player's HitPoint property is zero or lower, // raise a PlayerDied notification to all subscribed objects. PlayerDied?.Invoke(this, EventArgs.Empty); } } } public event EventHandler? PlayerDied; public Player(string name, int hitPoints) { Name = name; HitPoints = hitPoints; } }
In the constructor of the GameSession class, on line 21, we handle the “subscribe” from the GameSession object’s point-of-view. This line tells the Player object’s PlayerDied event to run the GameSession.HandlePlayerDied function in the PlayerDied.Invoke.
Notice that the parameters for PlayerDied?.Invoke (in the Player class) match the HandlePlayerDied method signature in the GameSession class. That’s because the Player object is calling the HandlePlayerDied function.
In this case, PlayerDied?.Invoke is passing EventArgs.Empty because HandlePlayerDied doesn’t need any additional information. All it cares about is knowing that the event happened. In the next version, we’ll pass additional information to the HandlePlayerDied function.
using System.Collections.ObjectModel; using DesignPatternsCSharpNet6.PublishSubscribe.Pattern.Models; namespace DesignPatternsCSharpNet6.PublishSubscribe.Pattern.ViewModels; public class GameSession { public ObservableCollection<string> UiMessages { get; } = new ObservableCollection<string>(); public Player CurrentPlayer { get; } public Location CurrentLocation { get; set; } public GameSession() { CurrentPlayer = new Player("Scott", 10); CurrentLocation = new Location("Home", false); // Subscribe to the PlayerDied event. // When the GameSession object receives this notification, // it will run the HandlePlayerDied function. CurrentPlayer.PlayerDied += HandlePlayerDied; } public void MoveToLocation(Location location) { CurrentLocation = location; if(CurrentLocation.IsPoisonous) { // Player takes one point damage in poisonous locations CurrentPlayer.HitPoints--; } } private void HandlePlayerDied(object? sender, EventArgs eventArgs) { UiMessages.Add("You died"); } }
Pattern Version (with additional information)
To pass additional information with events, we typically create a custom EventArgs class. This class inherits from System.EventArgs and adds additional properties to hold the additional information.
In this sample, we have the PlayerDiedEventArgs class. Its base class is System.EventArgs and it has a NumberOfDeaths property that’s populated from its constructor.
namespace DesignPatternsCSharpNet6.PublishSubscribe.Pattern_CustomEventArgs.Common; public class PlayerDiedEventArgs : EventArgs { public int NumberOfDeaths { get; } public PlayerDiedEventArgs(int numberOfDeaths) { NumberOfDeaths = numberOfDeaths; } }
In the Player class, we have a new _numberOfDeaths class-level variable to keep track of the number of times this player died.
When the player dies, we increment this counter on line 21.
On line 25, when we invoke the methods subscribed to the PlayerDied event, this time we pass a new PlayerDiedEventArgs object with the number of deaths, instead of the previous EventArgs.Empty.
We also had to change the PlayerDied event on line 30, so it’s a typed EventHandler – one that uses PlayerDiedEventArgs.
using DesignPatternsCSharpNet6.PublishSubscribe.Pattern_CustomEventArgs.Common; namespace DesignPatternsCSharpNet6.PublishSubscribe.Pattern_CustomEventArgs.Models; public class Player { private int _hitPoints; private int _numberOfDeaths; public string Name { get; } public int HitPoints { get => _hitPoints; set { _hitPoints = value; if(_hitPoints <= 0) { _numberOfDeaths++; // When the player's HitPoint property is zero or lower, // raise a PlayerDied notification to all subscribed objects. PlayerDied?.Invoke(this, new PlayerDiedEventArgs(_numberOfDeaths)); } } } public event EventHandler<PlayerDiedEventArgs>? PlayerDied; public Player(string name, int hitPoints) { Name = name; HitPoints = hitPoints; } }
In the GameSession class, we change the HandlePlayerDied method’s second parameter from EventArgs to PlayerDiedEventArgs, since we’re passing in the new custom event arguments object.
Then, on line 37, we have the NumberOfDeaths property available from the EventArgs object that was passed into the function.
using System.Collections.ObjectModel; using DesignPatternsCSharpNet6.PublishSubscribe.Pattern_CustomEventArgs.Common; using DesignPatternsCSharpNet6.PublishSubscribe.Pattern_CustomEventArgs.Models; namespace DesignPatternsCSharpNet6.PublishSubscribe.Pattern_CustomEventArgs.ViewModels; public class GameSession : IDisposable { public ObservableCollection<string> UiMessages { get; } = new ObservableCollection<string>(); public Player CurrentPlayer { get; } public Location CurrentLocation { get; set; } public GameSession() { CurrentPlayer = new Player("Scott", 10); CurrentLocation = new Location("Home", false); // Subscribe to the PlayerDied event. CurrentPlayer.PlayerDied += HandlePlayerDied; } public void MoveToLocation(Location location) { CurrentLocation = location; if(CurrentLocation.IsPoisonous) { // Player takes one point damage in poisonous locations CurrentPlayer.HitPoints--; } } private void HandlePlayerDied(object? sender, PlayerDiedEventArgs eventArgs) { UiMessages.Add("You died"); UiMessages.Add($"This was death number: { eventArgs.NumberOfDeaths }"); } public void Dispose() { CurrentPlayer.PlayerDied -= HandlePlayerDied; } }
What to watch out for
If a subscribing object ever gets “deleted”, you need to remember to have it unsubscribe from the events it subscribed to. Otherwise, the garbage collector may keep the object in memory, so the publishing object can still invoke the function on it.
You can handle this by having your subscribing objects implement the IDisposable interface.
Add a Dispose function to the class that unsubscribes from everything the object would have subscribed to. This way, the pointer to the function will be removed from the publisher’s event, so it won’t be invoked, and the object can be removed by garbage collection.
Also, using events can become complex.
I once worked on an application that acted like a spreadsheet. There were 50 textboxes on the screen for the user to enter numeric values. When a textbox’ value was changed, it would publish a “changed” event, causing other values to be re-calculated. However, the values that were re-calculated also had “changed” events that were fired – changing other values that had “changed” events. Trying to keep track of all the events, and that they were called in the correct order, was extremely difficult.