COMP 305: Object-Oriented Software Design

University of San Diego, Fall 2025

Event-Driven Architecture

Event-Driven Architecture (EDA) is a design pattern where components communicate by emitting and listening for events rather than directly calling each other. This creates loose coupling between components, as event emitters don't need to know who (if anyone) is listening to their events.

Core Concepts

1. Events

An event represents something that has happened. Events carry information about what occurred but don't dictate what should happen in response.

// Native DOM events
button.addEventListener('click', (event) => {
    console.log('Button clicked');
    console.log('Mouse position:', event.clientX, event.clientY);
});

input.addEventListener('input', (event) => {
    console.log('Input changed:', event.target.value);
});

// Custom events
const customEvent = new CustomEvent('user-login', {
    detail: {
        username: 'alice',
        timestamp: Date.now()
    },
    bubbles: true,  // Event bubbles up through DOM
    cancelable: true // Can be cancelled
});

element.dispatchEvent(customEvent);

2. Event Emitters (Publishers)

Components that emit events when something significant happens. They don't know or care who is listening.

class ShoppingCart extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.items = [];
    }

    addItem(item) {
        this.items.push(item);

        // Emit event - don't know or care who listens
        this.dispatchEvent(new CustomEvent('item-added', {
            detail: {
                item,
                totalItems: this.items.length,
                totalPrice: this.calculateTotal()
            },
            bubbles: true
        }));

        this.render();
    }

    removeItem(itemId) {
        const item = this.items.find(i => i.id === itemId);
        this.items = this.items.filter(i => i.id !== itemId);

        this.dispatchEvent(new CustomEvent('item-removed', {
            detail: {
                item,
                totalItems: this.items.length,
                totalPrice: this.calculateTotal()
            },
            bubbles: true
        }));

        this.render();
    }

    checkout() {
        const order = {
            items: [...this.items],
            total: this.calculateTotal(),
            timestamp: Date.now()
        };

        this.dispatchEvent(new CustomEvent('checkout', {
            detail: order,
            bubbles: true
        }));
    }

    calculateTotal() {
        return this.items.reduce((sum, item) => sum + item.price, 0);
    }

    render() {
        // Render cart UI
    }
}

customElements.define('shopping-cart', ShoppingCart);

3. Event Listeners (Subscribers)

Components that listen for events and react accordingly. Multiple listeners can respond to the same event in different ways.

// Multiple listeners for the same event
const cart = document.querySelector('shopping-cart');

// Listener 1: Update cart badge
cart.addEventListener('item-added', (event) => {
    const badge = document.querySelector('.cart-badge');
    badge.textContent = event.detail.totalItems;
});

// Listener 2: Show notification
cart.addEventListener('item-added', (event) => {
    showNotification(`Added ${event.detail.item.name} to cart`);
});

// Listener 3: Track analytics
cart.addEventListener('item-added', (event) => {
    analytics.track('cart_item_added', {
        productId: event.detail.item.id,
        price: event.detail.item.price
    });
});

// Listener 4: Update recommendation engine
cart.addEventListener('item-added', (event) => {
    recommendationEngine.updateBasedOnItem(event.detail.item);
});

Event Bubbling and Delegation

Events bubble up through the DOM tree, allowing parent elements to listen for events from their children. This enables event delegation - handling many elements with a single listener.

// Without delegation - inefficient
document.querySelectorAll('.delete-btn').forEach(btn => {
    btn.addEventListener('click', handleDelete);
});

// With delegation - efficient
document.querySelector('.product-list').addEventListener('click', (event) => {
    // Check if clicked element matches our target
    if (event.target.matches('.delete-btn')) {
        const productId = event.target.dataset.productId;
        handleDelete(productId);
    }

    if (event.target.matches('.edit-btn')) {
        const productId = event.target.dataset.productId;
        handleEdit(productId);
    }

    if (event.target.matches('.add-to-cart-btn')) {
        const productId = event.target.dataset.productId;
        handleAddToCart(productId);
    }
});

// Benefits:
// 1. Works for dynamically added elements
// 2. Single listener instead of hundreds
// 3. Better memory usage
// 4. Easier to manage

Event Bus Pattern

An Event Bus is a centralized event system that allows components to communicate without direct references to each other. Components publish events to the bus and subscribe to events from the bus.

// Simple Event Bus implementation
class EventBus {
    constructor() {
        this.listeners = new Map();
    }

    // Subscribe to an event
    on(eventName, callback) {
        if (!this.listeners.has(eventName)) {
            this.listeners.set(eventName, []);
        }
        this.listeners.get(eventName).push(callback);

        // Return unsubscribe function
        return () => this.off(eventName, callback);
    }

    // Unsubscribe from an event
    off(eventName, callback) {
        if (!this.listeners.has(eventName)) return;

        const callbacks = this.listeners.get(eventName);
        const index = callbacks.indexOf(callback);
        if (index > -1) {
            callbacks.splice(index, 1);
        }
    }

    // Publish an event
    emit(eventName, data) {
        if (!this.listeners.has(eventName)) return;

        this.listeners.get(eventName).forEach(callback => {
            try {
                callback(data);
            } catch (error) {
                console.error(`Error in event listener for ${eventName}:`, error);
            }
        });
    }

    // Subscribe to an event once
    once(eventName, callback) {
        const unsubscribe = this.on(eventName, (data) => {
            callback(data);
            unsubscribe();
        });
        return unsubscribe;
    }
}

// Create global event bus
const eventBus = new EventBus();

// Export for use in other modules
export default eventBus;

Using the Event Bus

import eventBus from './event-bus.js';

// Component A - doesn't know about Component B or C
class ProductCatalog extends HTMLElement {
    addToCart(product) {
        // Just publish the event
        eventBus.emit('product:added-to-cart', {
            productId: product.id,
            name: product.name,
            price: product.price,
            timestamp: Date.now()
        });
    }
}

// Component B - listens for cart events
class CartSummary extends HTMLElement {
    connectedCallback() {
        // Subscribe to events
        this.unsubscribe = eventBus.on('product:added-to-cart', (data) => {
            this.itemCount++;
            this.totalPrice += data.price;
            this.render();
        });
    }

    disconnectedCallback() {
        // Clean up subscription when component is removed
        this.unsubscribe();
    }
}

// Component C - also listens, completely independent
class AnalyticsTracker extends HTMLElement {
    connectedCallback() {
        this.unsubscribe = eventBus.on('product:added-to-cart', (data) => {
            // Track the event
            this.sendToAnalytics('add_to_cart', data);
        });
    }

    disconnectedCallback() {
        this.unsubscribe();
    }
}

// Component D - also listens for notifications
class NotificationManager extends HTMLElement {
    connectedCallback() {
        this.unsubscribe = eventBus.on('product:added-to-cart', (data) => {
            this.showNotification(`Added ${data.name} to cart`);
        });
    }

    disconnectedCallback() {
        this.unsubscribe();
    }
}

Web Components with Custom Events

Web Components can emit custom events to communicate with their parent or with an event bus. This maintains encapsulation while enabling interaction.

class FormValidator extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
    }

    connectedCallback() {
        this.shadowRoot.innerHTML = `
            <style>
                .field { margin-bottom: 1rem; }
                .error { color: red; font-size: 0.875rem; }
                input.invalid { border-color: red; }
            </style>
            <div class="field">
                <input type="email" placeholder="Enter email" />
                <div class="error"></div>
            </div>
        `;

        const input = this.shadowRoot.querySelector('input');
        const errorDiv = this.shadowRoot.querySelector('.error');

        input.addEventListener('blur', () => {
            const value = input.value;
            const isValid = this.validateEmail(value);

            if (!isValid) {
                input.classList.add('invalid');
                errorDiv.textContent = 'Please enter a valid email';

                // Emit validation failed event
                this.dispatchEvent(new CustomEvent('validation-failed', {
                    detail: {
                        field: 'email',
                        value,
                        error: 'Invalid email format'
                    },
                    bubbles: true,
                    composed: true  // Cross shadow DOM boundary
                }));
            } else {
                input.classList.remove('invalid');
                errorDiv.textContent = '';

                // Emit validation success event
                this.dispatchEvent(new CustomEvent('validation-success', {
                    detail: {
                        field: 'email',
                        value
                    },
                    bubbles: true,
                    composed: true
                }));
            }
        });
    }

    validateEmail(email) {
        return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
    }
}

customElements.define('form-validator', FormValidator);

Using the validator with event listeners:

<form id="signup-form">
    <form-validator></form-validator>
    <button type="submit">Sign Up</button>
</form>

<script is:inline>
    const form = document.getElementById('signup-form');
    const validator = form.querySelector('form-validator');
    const submitBtn = form.querySelector('button');

    let isEmailValid = false;

    // Listen for validation events
    validator.addEventListener('validation-success', (event) => {
        console.log('Valid email:', event.detail.value);
        isEmailValid = true;
        submitBtn.disabled = false;
    });

    validator.addEventListener('validation-failed', (event) => {
        console.log('Invalid email:', event.detail.error);
        isEmailValid = false;
        submitBtn.disabled = true;
    });

    form.addEventListener('submit', (event) => {
        if (!isEmailValid) {
            event.preventDefault();
            alert('Please fix validation errors');
        }
    });
</script>

Real-World Example: E-Commerce System

// Event Bus
const eventBus = new EventBus();

// Product Component - emits events
class ProductCard extends HTMLElement {
    connectedCallback() {
        this.shadowRoot.innerHTML = `
            <div class="product">
                <h3>${this.getAttribute('name')}</h3>
                <p>$${this.getAttribute('price')}</p>
                <button class="add-to-cart">Add to Cart</button>
            </div>
        `;

        this.shadowRoot.querySelector('.add-to-cart').addEventListener('click', () => {
            eventBus.emit('cart:item-added', {
                id: this.getAttribute('product-id'),
                name: this.getAttribute('name'),
                price: parseFloat(this.getAttribute('price'))
            });
        });
    }
}

// Cart Component - listens and emits
class ShoppingCartWidget extends HTMLElement {
    constructor() {
        super();
        this.items = [];
        this.attachShadow({ mode: 'open' });
    }

    connectedCallback() {
        this.render();

        // Listen for items being added
        this.unsubscribe = eventBus.on('cart:item-added', (item) => {
            this.items.push(item);
            this.render();

            // Emit updated cart state
            eventBus.emit('cart:updated', {
                items: this.items,
                total: this.calculateTotal(),
                count: this.items.length
            });
        });

        // Listen for checkout
        this.shadowRoot.addEventListener('click', (e) => {
            if (e.target.matches('.checkout-btn')) {
                eventBus.emit('cart:checkout-started', {
                    items: this.items,
                    total: this.calculateTotal()
                });
            }
        });
    }

    calculateTotal() {
        return this.items.reduce((sum, item) => sum + item.price, 0);
    }

    render() {
        this.shadowRoot.innerHTML = `
            <div class="cart">
                <h3>Cart (${this.items.length})</h3>
                <ul>
                    ${this.items.map(item => `
                        <li>${item.name} - $${item.price}</li>
                    `).join('')}
                </ul>
                <p>Total: $${this.calculateTotal()}</p>
                <button class="checkout-btn">Checkout</button>
            </div>
        `;
    }

    disconnectedCallback() {
        this.unsubscribe();
    }
}

// Analytics Component - listens only
class AnalyticsTracker extends HTMLElement {
    connectedCallback() {
        this.subscriptions = [
            eventBus.on('cart:item-added', (data) => {
                this.track('add_to_cart', data);
            }),

            eventBus.on('cart:checkout-started', (data) => {
                this.track('begin_checkout', {
                    value: data.total,
                    items: data.items.length
                });
            })
        ];
    }

    track(eventName, data) {
        console.log(`[Analytics] ${eventName}`, data);
        // Send to analytics service
    }

    disconnectedCallback() {
        this.subscriptions.forEach(unsubscribe => unsubscribe());
    }
}

// Notification Component - listens only
class NotificationToast extends HTMLElement {
    connectedCallback() {
        this.attachShadow({ mode: 'open' });

        eventBus.on('cart:item-added', (item) => {
            this.show(`Added ${item.name} to cart`);
        });

        eventBus.on('cart:checkout-started', () => {
            this.show('Proceeding to checkout...');
        });
    }

    show(message) {
        this.shadowRoot.innerHTML = `
            <style>
                .toast {
                    position: fixed;
                    bottom: 20px;
                    right: 20px;
                    background: #333;
                    color: white;
                    padding: 1rem;
                    border-radius: 4px;
                    animation: slideIn 0.3s ease;
                }
                @keyframes slideIn {
                    from { transform: translateX(100%); }
                    to { transform: translateX(0); }
                }
            </style>
            <div class="toast">${message}</div>
        `;

        setTimeout(() => {
            this.shadowRoot.innerHTML = '';
        }, 3000);
    }
}

customElements.define('product-card', ProductCard);
customElements.define('shopping-cart-widget', ShoppingCartWidget);
customElements.define('analytics-tracker', AnalyticsTracker);
customElements.define('notification-toast', NotificationToast);

Benefits

Challenges

Best Practices

Key Takeaways

See Also