Code Decoupling - Event Driven
Turn your GOD MODULE into a supervisor using event bus
Before Topic
I categorize this topic as a frontend one because there is primarily one environment(such as browser) where too many complex domains run. As a user interface maker, it is crucial for frontend code to maintain continuity in order to deliver a better user experience. Therefore, code coupling heavily was the regular customer.
If you are familiar with the difference between modularization and decoupling you can skip to here.
Modularization vs. Decoupling
This section’s example code was generated by AI, for reference only.
Suppose we have UI rendering logic, state management logic, data fetching logic and so on, and now they are in the same function.
async function main() {
let data: any = null;
let isLoading: boolean = true;
let error: Error | null = null;
try {
// Data fetcher
const response = await fetch('https://api.example.com/data');
data = await response.json();
} catch (err) {
error = err;
} finally {
isLoading = false;
}
// UI render logic
if (isLoading) {
// Loading indicator
console.log('Loading...');
} else if (error) {
// Error indicator
console.error('Error:', error);
} else {
// Render UI
console.log('Rendering UI with data:', data);
}
}
As long as you have a little bit experience of coding, you will find that just like a ball of yarn. That would be a disaster to maintain.
Then we can divide them into different modules at first.
// fetchData.ts
async function fetchData(): Promise<any> {
try {
const response = await fetch('https://api.example.com/data');
return await response.json();
} catch (err) {
throw new Error(`Failed to fetch data: ${err}`);
}
}
// State.ts
class State {
private data: any = null;
private isLoading: boolean = true;
private error: Error | null = null;
async loadData() {
try {
this.data = await fetchData();
this.error = null;
} catch (err) {
this.error = err as Error;
} finally {
this.isLoading = false;
}
}
getData() {
return this.data;
}
isLoading() {
return this.isLoading;
}
getError() {
return this.error;
}
}
// UIRenderer.ts
function renderUI(state: State) {
if (state.isLoading()) {
// Loading indicator
console.log('Loading...');
} else if (state.getError()) {
// Error indicator
console.error('Error:', state.getError());
} else {
// Render UI
console.log('Rendering UI with data:', state.getData());
}
}
// index.ts
async function main() {
const state = new State();
await state.loadData();
renderUI(state);
}
main();
That looks much better. But still, there is a problem. If you would like to add more features to the UI, or state would become more complex, the index or the entry point, gradually becomes like a God module.
It’s like…
// main.ts
import { fetchData } from './fetchData';
import { State } from './State';
import { renderUI } from './UIRenderer';
import { Logger } from './Logger';
async function main() {
// Initialize state
const state = new State();
// Log application start
Logger.log('Starting application...');
try {
// Load data
await state.loadData();
Logger.log('Data loaded successfully.');
// Render UI
renderUI({
isLoading: () => state.isLoading(),
getError: () => state.getError(),
getData: () => state.getData(),
});
} catch (err) {
// Log error
Logger.error(`Failed to load data: ${err}`);
}
// Additional features
// User authentication
const isAuthenticated = await authenticateUser();
if (!isAuthenticated) {
Logger.error('User authentication failed.');
return;
}
// Data caching
cacheData(state.getData());
// Error handling
if (state.getError()) {
Logger.error(`Error occurred: ${state.getError()}`);
return;
}
// More complex logic
// Process data
const processedData = processData(state.getData());
Logger.log('Data processed successfully.');
// Render processed data
renderUI({
isLoading: () => false,
getError: () => null,
getData: () => processedData,
});
// Log application end
Logger.log('Application finished.');
}
The problem is that the index.ts is just like a God, who knows everything and do everything! You may say that we could divide more modules but, imagine that module A initializes module B, and module B initializes module C, and module C calls back to module A or B…
Writing this far, only one thing I should say - modularization is not decoupling. Modularization actually help you read and understand your code better, but the code are still hard coupling. Once the project becomes huge and complex, it will be difficult to maintain.
Decoupling with Event Bus
Overall Idea of Decoupling
Just like frontend calling a backend API: you only care about the contract (endpoint & payload), not the database, framework, or server logic behind it.
Inside the frontend, we apply the same rule: a module issues an order for user:list and then forgets about it; whoever implements the renderer subscribes to that order request and delivers the UI—no imports, no direct calls, only contracts between modules.
Event Driven Framework
The main charactor of event driven framework is the Event Bus. The following code provides a simple implementation of event bus(here is EventEmitter).
/**
* Initialize in the main module and pass to other modules
*/
export class EventEmitter {
private events = new Map<string, Function[]>();
/**
* Listen to events(always)
*
* @param eventName the event's name
* @param callback the callback when detected the specific event emitted
*/
on(eventName: string, callback: Function): void {
if (!this.events.has(eventName)) {
this.events.set(eventName, []);
}
const callbacks = this.events.get(eventName);
// Check if the same callback exists(usually comes from the same caller), prevent duplicate
if (!callbacks.includes(callback)) {
callbacks.push(callback);
}
}
/**
* Listen to events(once)
*
* @param eventName the event's name
* @param callback the callback when detected the specific event emitted
*/
once(eventName: string, callback: Function): void {
// Check if the same callback exists(usually comes from the same caller), prevent duplicate
if (this.events.get(eventName)?.includes(callback)) return;
const onceCallback = (...args: any[]) => {
callback(...args);
this.off(eventName, onceCallback);
};
this.on(eventName, onceCallback);
}
/**
* Remove a event listener
*
* @param eventName the event's name
* @param callback the callback when detected the specific event emitted
*/
off(eventName: string, callback: Function): void {
if (!this.events.has(eventName)) return;
const callbacks = this.events.get(eventName)!;
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
// If no more callbacks, remove the event
if (callbacks.length === 0) {
this.events.delete(eventName);
}
}
/**
* Emit one event
*
* @param eventName the event's name
* @param args arguments to pass to the callback
*/
emit(eventName: string, ...args: any[]): void {
if (!this.events.has(eventName)) return;
const callbacks = this.events.get(eventName)!;
callbacks.forEach(callback => {
try {
callback(...args);
} catch (error) {
Debug.error('EVENT', `Error in event listener for "${eventName}":`, error);
}
});
}
}
Note that
Debugis a sample logger, here did not provide the specific implementation.
Then initialize this class in the main module and pass the instance to other sub-modules.
// index.ts
import { EventEmitter } from '/path/to/EventEmitter';
import { StateManager } from '/path/to/StateManager';
import { UIRenderer } from '/path/to/UIRenderer';
const eventBus = new EventEmitter();
const stateManager = new StateManager(eventBus);
const uiRenderer = new UIRenderer(eventBus);
eventBus.emit('appStart');
In other modules, you can use the event bus to communicate with each other.
// StateManager.ts
import { EventEmitter } from '/path/to/EventEmitter';
export class StateManager {
private eventBus: EventEmitter;
constructor(eventBus: EventEmitter) {
this.eventBus = eventBus;
this.eventBus.on('loadData', () => this.loadData());
}
private async loadData() {
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
this.eventBus.emit('dataLoaded', data);
} catch (error) {
this.eventBus.emit('error', error);
}
}
}
// UIRenderer.ts
import { EventEmitter } from '/path/to/EventEmitter';
export class UIRenderer {
private eventBus: EventEmitter;
constructor(eventBus: EventEmitter) {
this.eventBus = eventBus;
this.eventBus.on('dataLoaded', (data: any) => this.render(data));
this.eventBus.on('error', (error: any) => this.renderError(error));
this.eventBus.once('appStart', () => {
this.showLoading();
this.eventBus.emit('loadData');
});
}
private render(data: any) {
// Render data
}
private renderError(error: any) {
// Render error
}
private showLoading() {
// Show loading
}
}
Notice: EventEmitter would not use strictly single instance because you can create different instances to use in different individual parts passed to their own modules.
Improvements
Since the event bus does not provide async support, value return and error passing, we need to improve the EventEmitter.
/**
* Initialize in the main module and pass to other modules
*/
export class EventEmitter {
private events = new Map<string, Function[]>();
/**
* Listen to events(always)
*
* @param eventName the event's name
* @param callback the callback when detected the specific event emitted
*/
on(eventName: string, callback: Function): void {
if (!this.events.has(eventName)) {
this.events.set(eventName, []);
}
const callbacks = this.events.get(eventName);
// Check if the same callback exists(usually comes from the same caller), prevent duplicate
if (!callbacks.includes(callback)) {
callbacks.push(callback);
}
}
/**
* Listen to events(once)
*
* @param eventName the event's name
* @param callback the callback when detected the specific event emitted
*/
once(eventName: string, callback: Function): void {
// Check if the same callback exists(usually comes from the same caller), prevent duplicate
if (this.events.get(eventName)?.includes(callback)) return;
const onceCallback = (...args) => {
callback(...args);
this.off(eventName, onceCallback);
};
this.on(eventName, onceCallback);
}
/**
* Remove a event listener
*
* @param eventName the event's name
* @param callback the callback when detected the specific event emitted
*/
off(eventName: string, callback: Function): void {
if (!this.events.has(eventName)) return;
const callbacks = this.events.get(eventName)!;
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
// If no callbacks, remove this event
if (callbacks.length === 0) {
this.events.delete(eventName);
}
}
/**
* Emit one event
*
* @param eventName the event's name
* @param args arguments to pass to the callback
*/
async emit(eventName: string, ...args: any[]): Promise<any[]> {
if (!this.events.has(eventName)) return [];
const callbacks = this.events.get(eventName);
const results = [];
const errors = [];
for (const callback of callbacks) {
try {
const result = await callback(...args);
results.push(result);
} catch (error) {
Debug.error('EVENT', `Error in event listener for "${eventName}":`, error);
errors.push(error);
}
}
// Throw error if any error
if (errors.length > 0) {
throw new Error(`Errors occurred in ${errors.length} listener(s) for "${eventName}"`, { cause: errors });
}
return results;
}
/**
* Emit one event and return the first result
*
* @param eventName the event's name
* @param args arguments to pass to the callback
* @returns {*} The first result of the listeners, or undefined if no listeners
*/
async emitFirst(eventName: string, ...args: any[]): Promise<any> {
if (!this.events.has(eventName)) return undefined;
const callbacks = this.events.get(eventName);
for (const callback of callbacks) {
try {
const result = await callback(...args);
return result;
} catch (error) {
Debug.error('EVENT', `Error in event listener for "${eventName}":`, error);
throw error;
}
}
return undefined;
}
}
Note again
Debugis a sample logger, here did not provide the specific implementation.
The following is a sample usage:
// Async event handler
eventBus.on('fetchData', async (id: string) => {
const response = await fetch(`/api/data/${id}`);
return await response.json(); // return the data
});
// Await and get the first result
const data = await eventBus.emitFirst('fetchData', '123');
console.log('Received data:', data);
Off Topic
Emit in Vue is pretty different from this framework. Vue component’s emit is a parent–child shared utility - events stay inside the component tree; an Event-Bus emit is a factory-wide system - any module with the bus can tune in.
Personal experience and knowledge - Issues are welcome