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:
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:
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:
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.
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
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:
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.
领英推荐
Key Takeaways:
Unsubscribe and UnsubscribeAsync
What They Do: Both methods are responsible for removing a previously registered subscriber from their respective lists:
How They Work:
Key Takeaways:
The Publish<T> Method
Serves as the entry point for synchronous event publishing. Its primary responsibilities include:
Synchronous Workflow:
The PublishAsync<T> method is the entry point for asynchronous event publishing. Its responsibilities are similar to Publish but designed for async workflows:
Asynchronous Workflow:
The PublishInternal<T> method is responsible for the core logic of publishing events. It works for both synchronous and asynchronous workflows:
Core Workflow:
The HandlePublishActionAsync<T> method is responsible for invoking all registered subscribers for a specific event type:
Core Workflow:
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:
Core Workflow:
How ProcessQueuedEventsAsync Works
This ensures queued events are processed whenever possible while handling errors gracefully.
Here's how these methods work together:
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:
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 !!
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.