EventBus: Dependency-Free, Decoupled, Scalable, and Traceable Systems Architecture Without Singletons

EventBus: Dependency-Free, Decoupled, Scalable, and Traceable Systems Architecture Without Singletons

What is EventBus and Why Does It Matter?

Managing communication between different components, modules, or systems can quickly become a complex challenge. For example, in a multiplayer game, ensuring that a player’s input seamlessly updates the game state while notifying other players—without creating tightly coupled dependencies—can lead to a tangled web of code. Tight coupling, redundant dependencies, and hard-to-maintain architectures often slow down progress and create scalability bottlenecks.

Now, imagine you want to decouple the multiplayer stack from the rest of the UI and core game logic. What if you need a system that allows you to switch seamlessly from Photon to Unity Netcode for GameObjects without breaking anything?

This is where EventBus comes in. EventBus is a powerful concept that simplifies communication by decoupling components and enabling seamless data flow. Whether you're working on a game, enterprise app, or any modular system, EventBus provides a robust and scalable solution to handle events, pass data, and eliminate architectural friction.

With my custom implementation of EventBus, you can take it a step further: eliminate singletons, avoid dependency injection, and gain full visibility into how events are published, subscribed to, and tracked.

Think of an EventBus like Canada Post on steroids. It delivers your "parcel" (data) without needing to know what’s inside, as long as you provide the required "postage" (implementation details). You can request features like signature verification, track delivery, ask for a reply with additional data, wait for that data, and then take action accordingly.

This article will guide you through the core principles, unique features, and practical use cases of EventBus, helping you build dependency-free, decoupled, and scalable systems.


Decouple Your Codebase

One big mistake most developers make is failing to define assembly definitions and namespaces. The first thing you should do in a fresh project is think about decoupling. Don’t just create folders to organize your code—add assembly definitions right from the start. If you don’t, you’ll inevitably end up with a tangled web of coupled code, knowingly or unknowingly.

How? Let’s start by defining the assembly structure. For ease of explanation, we will use Unity as an example, but the concept is pretty much the same regardless of the language.

1. Interface Assembly

All interfaces should reside here. Every other assembly will have a direct dependency on this assembly, but the Interface Assembly itself should have zero dependencies—not even on the system defaults, in my opinion. This keeps your interfaces lightweight, clean, and isolated.

2. Common Assembly

This assembly contains all the common, reusable elements, such as:

  • Base managers
  • Base MonoBehaviours
  • Enums (though I recommend avoiding them; I'll write a separate article on why)
  • Scriptable singletons

This is also where the singleton ScriptableObject for the EventBus will reside, alongside other singleton ScriptableObjects like GameLogger. I promise, these will be the only singleton ScriptableObjects we’ll ever use.

The Common Assembly will have the Interface Assembly and the Event Assembly as its dependencies, along with any other system assemblies needed, but none of your assembly .

3. Event Assembly

This assembly is dedicated to events. It contains:

  • A base event class (e.g., EventArgsBase)
  • Specific event classes that inherit from it (e.g., CreateAccountEvent : EventArgsBase)

We’ll dive deeper into different event concepts and provide code snippets later. The Event Assembly will only depend on the Interface Assembly.

4. Other Assemblies

These include:

  • UI
  • Networking
  • CoreGame
  • Any dedicated services, like in my project for LLM Games, where I have assemblies for LLMServices and Blockchain.

All these assemblies will depend on the Event Assembly, Common Assembly, and Interface Assembly, as well as any specific DLLs or assembly references they require. However, be cautious of circular dependencies. Assemblies that you create and intend to modify should only depend on the Event Assembly, Common Assembly, and Interface Assembly. For example, never add something like Networking as a dependency in UI!

Key Benefits of This Setup

With this architecture, there’s no way to access any class in the Core Game Assembly (or other assemblies) without going through the EventBus and the interface implementation of concrete classes in the Interface Assembly. This enforces strict boundaries between components, reduces coupling, and makes your codebase highly scalable, testable, and maintainable.


Assembly Diagram


Event Bus

The Event Bus serves as the core event-handling mechanism in our architecture. While it could be implemented as a static class, I prefer using a singleton ScriptableObject for this example, particularly when working within Unity. This approach ensures better integration with Unity’s workflow and ecosystem.

For those using Odin Inspector, it offers a wealth of features and several handy tools, such as the GlobalConfig for managing singletons effectively. However, if you don’t have Odin Inspector, you can implement your own version of a singleton ScriptableObject. While detailing that process is beyond the scope of this article, you can find numerous resources to guide you.

Below is an example of how my base implementation looks: https://pastebin.com/UP5QbbVL

Next, create the interface and concrete base event class. By now, you probably know where to place these! https://pastebin.com/MfwB0edT

Next lets Understand few Key component of Event Bus itself

Full Code: https://pastebin.com/fLzsEgtr

Subscribe

Registers a function (subscriber) to be called when a specific type of event occurs. It avoids duplicate subscribers unless the force flag is set to true. We do this using EventRegistrar we will go thru that later

How it works:

Locking with asyncSubscriberLock:

 lock (asyncSubscriberLock)        

Check if the event type already has a list:

if (!Subscribers.TryGetValue(eventType, out List subscriberList)) { subscriberList = new List(); Subscribers[eventType] = subscriberList; }        

Avoid duplicates:

 if (force || !subscriberList.Any(s => s.Target == subscriber.Target && s.Method == subscriber.Method)) { subscriberList.Add(subscriber); }        

Process queued events:

ProcessQueuedEventsAsync().Forget();        

Key Takeaways:

  • Thread Safety: Ensures only one thread modifies the subscriber list at a time using the lock.
  • Dynamic List Creation: Automatically creates a list for new event types.
  • Duplicate Check: Ensures the same function isn't added twice unless explicitly forced.
  • Queued Events: Notifies new subscribers of any waiting events.



SubscribeAsync

Registers an Asynchronous Function (Subscriber) for a Specific Event

How it Works: This process is similar to Subscribe, but it targets asynchronous workflows. It allows handling operations that require waiting for a result or returning data.

  • Async Subscribers Dictionary: Instead of the standard Subscribers dictionary, AsyncSubscribers is used to store asynchronous functions. This enables the handling of functions that return UniTask and are designed to run asynchronously.
  • Processing Queued Events: When an async subscriber is registered, ProcessQueuedEventsAsync<T>().Forget() ensures that any pending events of the specified type are processed immediately.

Key Takeaways:

  1. Designed specifically for asynchronous workflows where functions take time to complete.
  2. Ensures that queued events are processed seamlessly with asynchronous subscribers.


Unsubscribe


UnsubscribeAsync

Unsubscribe and UnsubscribeAsync

What They Do: Both methods are responsible for removing a previously registered subscriber from their respective lists:

  • Unsubscribe: Removes the subscriber from the Subscribers list.
  • UnsubscribeAsync: Removes the subscriber from the AsyncSubscribers list. This ensures that the subscriber is no longer called for the specific event.

How They Work:

  1. Locate the Subscriber List: Both methods first check if there is an existing list of subscribers for the event type in their respective dictionaries.
  2. Remove the Subscriber: If the list exists, the method removes the function by identifying its Target and Method, ensuring an exact match.

Key Takeaways:

  • Both methods are thread-safe, operating within the locked structure of their respective dictionaries.
  • UnsubscribeAsync is specifically designed to handle asynchronous subscribers, while Unsubscribe manages synchronous ones.


Publish

The Publish<T> Method

Serves as the entry point for synchronous event publishing. Its primary responsibilities include:

  1. Check for Re-Publishable Events: If the event (eventArgs) is not marked as re-publishable and has already been processed (stored in processedEvents), the method exits early to prevent duplicate processing.
  2. Call Internal Publishing: It invokes PublishInternal to manage the actual publication and distribution of the event to its subscribers.
  3. Dispose of Event Arguments: Once the event is successfully processed, it disposes of the event arguments and removes them from the processedEvents dictionary to free resources and maintain cleanliness.

Synchronous Workflow:

  • Ideal for events requiring immediate processing.
  • Ensures robustness by catching and logging any exceptions that occur during the processing of the event.

PublishAsync

The PublishAsync<T> method is the entry point for asynchronous event publishing. Its responsibilities are similar to Publish but designed for async workflows:

  1. Check for Re-Publishable Events:Similar to Publish, it skips events that are not re-publishable and already processed.
  2. Call Internal Publishing:It awaits the PublishInternal method to process the event asynchronously.
  3. Dispose of Event Args:Once processing is complete, it disposes of the event arguments and removes them from processedEvents.

Asynchronous Workflow:

  • Ideal for non-blocking operations.
  • Ensures smooth execution when events involve async subscribers or longer processing.

PublishInternal

The PublishInternal<T> method is responsible for the core logic of publishing events. It works for both synchronous and asynchronous workflows:

  1. Mark Event as Processed:Adds the event to the processedEvents dictionary to ensure it won't be re-processed unless explicitly forced.
  2. Handle Event Processing:Calls HandlePublishActionAsync to process all registered subscribers (both sync and async).
  3. Queue Event (if needed):If no subscribers handle the event (eventHandled is false), it attempts to queue the event for future processing by calling QueueEvent.

Core Workflow:

  • Acts as the main processing layer that integrates both handling and queuing mechanisms.
  • Ensures retries for unhandled events.

HandlePublishActionAsync

The HandlePublishActionAsync<T> method is responsible for invoking all registered subscribers for a specific event type:

  1. Find Subscribers:Looks up synchronous and asynchronous subscribers (Subscribers and AsyncSubscribers) for the given event type.
  2. Invoke Synchronous Subscribers:Iterates over all sync subscribers and invokes their callbacks.Logs any exceptions that occur during subscriber execution.
  3. Invoke Async Subscribers:Iterates over async subscribers (Func<T, UniTask>) and invokes their callbacks.If awaitAsyncSubscribers is true, it awaits the execution; otherwise, it uses .Forget() to run them in the background.
  4. Return Event Handling Status:Returns true if any subscribers handled the event, otherwise false.

Core Workflow:

  • Ensures all subscribers (sync and async) are invoked correctly and logs errors without breaking execution.

QueueEvent

The QueueEvent<T> method handles queuing events that were published but not immediately processed, often due to scenarios like script execution order issues or dynamic instantiation (e.g., when network prefabs are instantiated in a multiplayer game). Most events are processed immediately unless such cases arise.

Key Responsibilities:

  1. Find or Create a Queue: Checks if a queue exists for the event type in QueuedEvents. If not, it initializes a new queue.
  2. Enqueue the Event: Adds the event to the queue, ensuring it is stored for future processing.
  3. Return Status: Returns true if the event was successfully queued. If queuing fails, it logs an error and returns false.

Core Workflow:

  • Ensures unprocessed events are preserved for later processing.
  • Allows events to be handled by subscribers added after the event's initial publication, ensuring no event is lost.

ProcessQueuedEventsAsync

How ProcessQueuedEventsAsync Works

  1. Find the Queue ->Process Events in Batches -> Try to Process Each Event:
  2. Handle Failed Events->Retry Events ->Drop Excessive Failures:
  3. Clean Up and Dispose the event

This ensures queued events are processed whenever possible while handling errors gracefully.

Here's how these methods work together:

  1. Publish or PublishAsync is called ->PublishInternal is triggered ->HandlePublishActionAsync -> processes the event
  2. If the event is not handled, QueueEvent is called:
  3. Subscribe is called -> Added to Dictionary -> ProcessQueuedEventsAsync

Clear

Now, at the end of the code, you’ll notice an intriguing block. This block is specifically included for the EventBus Manager View. In the next section, we’ll dive into creating an Odin Editor Manager window to visually display all event relationships. This will include:

  • Showing where each event is used and in which part of the code.
  • Analyzing the codebase to check for unused events, events that are subscribed but not published, or published events with missing subscribers.

To achieve this, we’ll use parallel threading to scan the entire codebase and perform cross-references. While this might sound ambitious, trust me—on a game like LLM Games, which handles multiple card games such as Poker and Three Card Brag, integrates Large Language Models (LLMs), APIs, local LLMs, Web3-based decentralized transactions, and much more (to be announced soon), this codebase—boasting over 100 events and 300+ classes—completes the process in just a few seconds!

And that’s a wrap for Part One! Thank you for taking the time to read through this detailed breakdown. Your support and interest truly encourage me to continue writing articles like this. I’ll see you in the next part soon, where we’ll dive even deeper into the fascinating world of EventBus and its applications.

As I work on building my game, writing these articles, and hunting for contract jobs to support my journey, your engagement means the world to me. Follow me for updates, share your thoughts, and let’s keep the learning and inspiration alive!

Happy Holidays and Merry Christmas! Stay tuned for more exciting insights, and I’ll catch you in the next part.

Meanwhile Check out Sora Video !!



Saad Bin Tarique

Game Designer & Developer | Mobile Gaming, Unity, AI | Spearheaded Development of 5K+ Concurrent User Systems, Reduced Game Start Time by 30%, Delivered 10+ End-to-End Projects

2 个月

This is quite intriguing. I have been using a lightweight implementation of the similar system to facilitate communications between assemblies. Would definitely try this one, as this seems more robust.

要查看或添加评论,请登录

Sujan M.的更多文章

社区洞察

其他会员也浏览了