Building a Type-Safe Event Bus in MQL5: Decoupling EA Components Without Global Variables
Introduction
When an Expert Advisor grows beyond a single trading-logic block, developers often reach for the most readily available coordination mechanism: global variables. A risk manager needs to know the current drawdown, so a double named g_current_drawdown appears at file scope. A signal engine fires, and the order manager is informed through a bool g_signal_active flag. Within weeks, a moderately complex EA accumulates dozens of these state bridges, each one representing a direct, invisible dependency between components that were designed to be independent.
The structural consequence of this pattern is not just messiness; it is behavioral coupling. When the risk manager reads g_current_drawdown, it assumes the right module wrote it at the right time within the correct tick cycle. If the execution engine is refactored to update that value asynchronously, or if a new component also writes to it, the risk manager's behavior changes silently. There is no contract enforced at the language level. The global variable is a shared mutable state channel with no access control, no type guarantee beyond the primitive, and no record of who produced the value or when.
Consider a realistic three-component architecture: a signal generator, an order manager, and a drawdown monitor. In the naïve global-variable design, their relationships look like this:

Figure 1: Architectural coupling of a three-component Expert Advisor, where independent modules inadvertently depend on one another through unmanaged read and write operations within a centralized global variables pool.
Every component reads and writes to a shared pool. Adding a fourth component means auditing all existing globals to understand which ones it must respect and which ones it may corrupt. Removing a component means tracing all references manually before deletion. Unit testing any single component in isolation is effectively impossible because its behavior depends on the global state being pre-configured correctly.
The publish-subscribe pattern, implemented here as a type-safe event bus, eliminates this shared state channel entirely. Components do not read each other's variables. They publish events describing what has happened and subscribe to the events they need to respond to. The bus mediates all communication. No component holds a pointer to any other component. The dependency graph collapses from an N-to-N mesh into a star topology, with the bus at the center.

Figure 2: Decoupled star topology architecture where EA components interact strictly through publish/subscribe channels mediated by a central event bus.
Architectural Overview
The implementation consists of four primary elements: an abstract listener interface (IEventListener), a typed event structure hierarchy, the bus itself (CEventBus), and a subscription slot wrapper struct (SListenerSlot). This wrapper addresses an MQL5 compiler limitation with multidimensional pointer arrays. A fifth element, an event type enumeration, provides the compile-time taxonomy that makes the routing type-safe.
The design deliberately avoids heavy framework dependencies. MQL5 provides templates, interfaces (via abstract classes), dynamic arrays, and pointer management. These primitives are sufficient to build a correct and efficient bus without external libraries.
Event Type Taxonomy
All events in the system are identified by a value from a single enumeration. This enumeration serves as the routing key the bus uses to match published events to registered subscribers.
enum ENUM_EA_EVENT { EA_EVENT_SIGNAL_LONG = 0, // Signal: Market Long Condition Detected EA_EVENT_SIGNAL_SHORT = 1, // Signal: Market Short Condition Detected EA_EVENT_SIGNAL_FLAT = 2, // Signal: No Active Directional Bias EA_EVENT_ORDER_FILL = 3, // Execution: Order Fill Confirmed EA_EVENT_ORDER_REJECT = 4, // Execution: Order Rejected by Broker EA_EVENT_DRAWDOWN_WARN = 5, // Risk: Drawdown Warning Threshold Crossed EA_EVENT_DRAWDOWN_HALT = 6, // Risk: Hard Drawdown Limit Reached — Trading Halted EA_EVENT_SESSION_OPEN = 7, // Session: Market Session Begin EA_EVENT_SESSION_CLOSE = 8 // Session: Market Session End };
Using an enum rather than string identifiers provides two concrete advantages. First, the compiler enforces valid event types at the call site, making it impossible to publish or subscribe to a misspelled or undefined event. Second, integer enum values support direct array indexing, which reduces subscriber lookup from a string comparison loop to an O(1) array access, a meaningful difference when the bus processes hundreds of events per second during volatile sessions.
Event Payload Structure
Every event carries a standardized payload structure. The design goal here is a fixed-size, stack-allocatable record that can be passed by const reference throughout the dispatch chain without heap allocation.
struct SEventPayload { ENUM_EA_EVENT event_type; // Routing key identifying the event class datetime timestamp; // Server time at point of publication long order_ticket; // Ticket number for fill/reject events (0 if unused) double value_primary; // Primary numeric payload (price, drawdown %, PnL) double value_secondary;// Secondary numeric payload (SL level, TP level, lot size) string message; // Human-readable diagnostic string for logging };
The message field is the only heap-allocated member of the structure because MQL5 strings are reference-counted heap objects. For high-frequency dispatch scenarios where message allocation becomes measurable overhead, the field can be left as an empty string literal without affecting routing behavior. The numeric fields cover the majority of financial event payloads: a fill event uses order_ticket for the position identifier, value_primary for the fill price, and value_secondary for the executed volume. A drawdown event uses value_primary for the current drawdown percentage and leaves the ticket field at zero.
The table below maps each event type to its expected payload field usage:
| Event Type | order_ticket | value_primary | value_secondary | message |
|---|---|---|---|---|
| EA_EVENT_SIGNAL_LONG | 0 | Entry reference price | ATR-derived SL offset | Signal source ID |
| EA_EVENT_SIGNAL_SHORT | 0 | Entry reference price | ATR-derived SL offset | Signal source ID |
| EA_EVENT_SIGNAL_FLAT | 0 | 0.0 | 0.0 | Reason for flat bias |
| EA_EVENT_ORDER_FILL | Position ticket | Fill price | Executed volume (lots) | Symbol name |
| EA_EVENT_ORDER_REJECT | Attempted ticket | Requested price | Requested volume | Broker error description |
| EA_EVENT_DRAWDOWN_WARN | 0 | Current drawdown % | Warning threshold % | Session equity |
| EA_EVENT_DRAWDOWN_HALT | 0 | Current drawdown % | Hard limit % | Trading halt reason |
| EA_EVENT_SESSION_OPEN | 0 | 0.0 | 0.0 | Session name identifier |
| EA_EVENT_SESSION_CLOSE | 0 | 0.0 | 0.0 | Session name identifier |
The Listener Interface
MQL5 does not support true interface types in the Java or C# sense, but abstract classes with pure virtual methods serve the same structural role. Any component that wishes to receive events from the bus must inherit from IEventListener and implement its single dispatch method.
//--- IEventListener class IEventListener { public: virtual void OnEvent(const SEventPayload &payload) = 0; virtual ~IEventListener() {} };
The OnEvent method accepts its payload by const reference. This is not a stylistic choice; it is a performance and safety contract. Passing the 80-byte SEventPayload structure by value would copy it onto each listener's stack frame on every dispatch call. With ten registered listeners and one hundred events per second, that is one thousand unnecessary structure copies per second. The const qualifier enforces that no listener implementation may modify the shared payload, preventing one listener from corrupting the event data seen by listeners dispatched later in the same publish cycle.
The pure virtual destructor ensures that the bus can safely delete listener objects through base-class pointers during cleanup, without invoking undefined behavior from a non-virtual destructor in a polymorphic hierarchy.
The CEventBus Class
The bus is the sole mediator between publishers and subscribers. Its internal data model is a fixed-length array of SListenerSlot wrapper objects, indexed directly by the event type enum value. MQL5 does not support two-dimensional dynamic arrays of object pointers, so an intermediary struct is required. Each SListenerSlot stores a dynamic IEventListener* array and an active-subscriber count. The bus declares SListenerSlot m_slots[EA_EVENT_COUNT = 9], which is a fixed-length array of plain structs and is fully valid MQL5. This structure enables O(1) subscriber lookup per event type. It requires a small, predictable allocation per subscription slot.

Figure 3: Memory layout of CEventBus showing the fixed-length m_slots array of size 9, containing structural SListenerSlot wrapper boundaries to isolate internal tracking counts from dynamic pointer array collections via O(1) enum indexing.
Subscription Slot Wrapper
MQL5 does not permit jagged or two-dimensional dynamic arrays of object pointers. A declaration of the form IEventListener *m_subscribers[EA_EVENT_COUNT][] is illegal syntax in the MQL5 compiler and produces array access errors at every call site that attempts to resize or index the inner dimension. The SListenerSlot struct resolves this by wrapping the per-event dynamic array into a named aggregate that the compiler can handle correctly:
struct SListenerSlot { IEventListener *listeners[]; // Dynamic array of subscriber pointers for one event type int count; // Active subscriber count in this slot SListenerSlot() : count(0) { ArrayResize(listeners, 0); } };
The bus then declares SListenerSlot m_slots[EA_EVENT_COUNT], which is a fixed-length array of plain structs. All ArrayResize calls and index operations target m_slots[slot].listeners and m_slots[slot].count rather than a two-dimensional array. The SListenerSlot default constructor initializes the count to zero and sets the inner array to zero capacity, so every slot is in a valid empty state the moment the bus is constructed without requiring an explicit initialization loop over the enum range.
Subscription Management
Subscriber registration stores a raw pointer to the listener object. The bus does not own these objects; it does not manage their lifecycle. The component that creates a listener is responsible for ensuring the listener remains valid for the duration of its registration. This is a deliberate design choice: ownership inversion (where the bus owns all listeners) would require the bus to manage heterogeneous object lifetimes, which conflicts with the typical EA structure where components are stack-allocated members of the top-level EA class.
The bus must validate registered pointers before dispatch. MQL5's CheckPointer() function returns POINTER_INVALID for dangling or deleted heap objects, and POINTER_DYNAMIC for valid heap allocations. This validation occurs during every dispatch cycle, not only during registration, because a listener may be deleted between registration and the next publish call.
Publish Mechanics
When Publish() is called, the bus retrieves the subscriber array for the event's type, iterates it, validates each pointer, and calls OnEvent() synchronously. The dispatch is synchronous by default, meaning the publisher's execution thread blocks until all listeners have returned from their OnEvent() implementations. This model is correct for single-threaded MQL5 EAs, where all execution occurs on the EA's thread within a tick handler. If a listener performs expensive computation (database writes, HTTP requests via WebRequest), that cost is paid synchronously during the tick, which must be considered when designing listener implementations.
The class interface surface is small by design:
| Method | Signature | Access | Architectural Purpose |
|---|---|---|---|
| Subscribe | bool Subscribe(ENUM_EA_EVENT type, IEventListener *listener) | public | Registers a listener for a specific event type |
| Unsubscribe | bool Unsubscribe(ENUM_EA_EVENT type, IEventListener *listener) | public | Removes a specific listener registration by pointer identity |
| Publish | void Publish(const SEventPayload &payload) | public | Dispatches an event to all registered subscribers for its type |
| SubscriberCount | int SubscriberCount(ENUM_EA_EVENT type) | public | Returns the number of active subscribers for a type (diagnostic) |
| Clear | void Clear() | public | Removes all subscriptions without deleting listener objects |
| m_slots | SListenerSlot m_slots[EA_EVENT_COUNT] | private | Subscription table indexed by event type |
Overhead Analysis and Practical Constraints
The event bus introduces measurable overhead relative to direct function calls. Understanding this overhead at the architectural level is necessary for deciding when the pattern is appropriate and when it is not.
Dispatch latency: Each Publish() call performs one array index dereference, a bounds check, a loop over subscribers, one CheckPointer() call per subscriber, and one virtual function call per subscriber. On modern hardware, the virtual dispatch itself contributes roughly 2 to 5 nanoseconds per call due to the vtable indirection. For an EA with five subscribers per event and one hundred published events per tick, the total added overhead is under a microsecond, which is negligible relative to network latency for order transmission. For ultra-low-latency scalping strategies where tick handler execution time is itself a performance variable, this overhead should be benchmarked explicitly.
Memory footprint: The subscription table allocates an IEventListener* pointer array per event type. With nine event types and a maximum of ten subscribers each, the maximum table size is 90 pointer slots, consuming 720 bytes on a 64-bit platform. The SEventPayload structure occupies approximately 80 bytes on the stack during dispatch. Neither figure is significant in the context of a long-running EA.
Pointer safety window: The CheckPointer() guard protects against dispatching to deleted objects, but it does not protect against objects that have been destructed without deallocation (i.e., local objects on a scope that has been unwound). If a listener is a stack-allocated local variable in a function that has returned, CheckPointer() will not detect the invalidation. All listeners that register with the bus must have lifetimes that span the bus's operational lifetime. A safer pattern is to allocate listeners on the heap and store them in the EA's main class. Delete them in OnDeinit() after clearing the bus.
No queuing, no thread safety: MQL5's execution model is single-threaded per EA. The bus implementation below does not include a message queue, deferred dispatch, or any concurrency primitive. If a future MQL5 runtime were to introduce multi-threaded execution contexts, the bus would require a mutex around m_subscribers access and a lock-free ring buffer for the publish queue. No such provision is made here because the current MQL5 runtime specification does not warrant it.
Recursive publish risk: If a listener's OnEvent() implementation calls Publish() on the same bus, a recursive dispatch cycle begins. The bus does not detect or prevent this. An EA_EVENT_ORDER_FILL listener that publishes EA_EVENT_DRAWDOWN_WARN in response is safe only if no EA_EVENT_DRAWDOWN_WARN listener publishes a fill event. Publish cycles that form a loop can exhaust the MQL5 call stack and terminate the EA. Mitigation relies on architecture, not runtime guards: handlers should publish only for external state changes, not as reactions to other bus events.
Integrating the Bus: A Three-Component EA
The following design scenario connects three independent EA components through the bus without any direct cross-references between them:
- CSignalEngine monitors price action and publishes EA_EVENT_SIGNAL_LONG, EA_EVENT_SIGNAL_SHORT, and EA_EVENT_SIGNAL_FLAT events. It holds no pointer to the order manager or risk monitor.
- COrderManager subscribes to signal events and publishes EA_EVENT_ORDER_FILL and EA_EVENT_ORDER_REJECT events after communicating with the broker. It holds no pointer to the signal engine or risk monitor.
- CDrawdownMonitor subscribes to fill events to track position exposure and publishes EA_EVENT_DRAWDOWN_WARN and EA_EVENT_DRAWDOWN_HALT events when risk thresholds are crossed. It holds no pointer to either the signal engine or the order manager.
The EA's top-level OnInit() function constructs all three components, constructs the bus, and wires subscriptions. OnTick() drives the signal engine, which internally calls Publish() on the bus reference it holds. The entire tick-to-execution chain flows through the bus without any direct module coupling.

Figure 4: Sequence execution flow of a single event-driven market tick, tracing how signals and execution events route through the central bus without coupling components.
Conclusion
The CEventBus architecture replaces an implicit, fragile network of global variables with an explicit, compiler-enforced communication contract. Components declare what they emit and what they consume at registration time, making the dependency structure of a complex EA visible, auditable, and refactorable without cross-module side effects.
The core trade-offs of this approach are well-defined. Synchronous dispatch maintains deterministic execution order within a tick but means listener performance directly affects tick handler latency. Pointer-based subscription management requires disciplined lifetime governance from the caller; the bus is an intentional non-owner of its subscribers. The O(1) subscription table lookup scales cleanly with subscriber count per event type, though the fixed enum-indexed table does constrain the event taxonomy to the compile-time enumeration, making runtime event type registration impossible without architectural changes.
For the majority of institutional-grade EA designs, these constraints are engineering boundaries, not limitations. The pattern provides the decoupling necessary to build, test, and evolve large component sets independently, and the overhead it introduces is well below the noise floor of any latency measurement that includes broker network round-trips.
Programs used in the article
| # | Name | Type | Description |
|---|---|---|---|
| 1 | EventBusSystem.mqh | Include File | Consolidated single-file include containing all six original modules in dependency order: ENUM_EA_EVENT taxonomy, SEventPayload structure, IEventListener abstract base, CEventBus routing engine, CSignalEngine, COrderManager, and CDrawdownMonitor |
| 2 | EventBusDemo.mq5 | Demo EA | Demonstration EA referencing "EventBusSystem.mqh." Wires all three components through CEventBus and handles OnInit, OnDeinit, OnTick, and OnTradeTransaction |
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
Engineering a Self-Healing Expert Advisor in MQL5 (Part 2): Restart-Safe Virtual Trade Protection
Extremal Optimization (EO)
Implementing a Fluent Interface Builder Pattern for MQL5 Order Construction
Neural Networks in Trading: Actor—Director—Critic
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use