Frontend as the New Legacy: How We Missed the Event-Driven Paradigm Shift

Introduction: Architectural Déjà Vu Have you ever noticed how the digital world moves in spirals? In 2018, I was waving around Dockerfile and Helm charts, implementing microservices in C# with RabbitMQ — all for the sacred goal of low coupling. Three years later, having switched to Angular, I was horrified to realize: frontend components were communicating through Input/Output chains, as if it were 2005 and we were writing WinForms. It's like assembling a spaceship but controlling it via telegraph. In the backend, we proudly declare event-driven architecture, while on the frontend, components whisper through props like teenagers at a school dance. The irony? The more complex our systems became, the more they resembled the very monoliths we fled from in the backend world. My epiphany came when I had to modify a dialog box. To add a "Cancel" button, I needed to: Update the parent component Rewrite the mediator service Fix three child modules Twelve files for one button! In the microservices world, this would be like editing five repositories to change a label color. I asked myself: "Why hasn’t the frontend learned from distributed systems?" We’ve mastered WebAssembly, adopted GraphQL, modularized CSS — yet component communication remains stuck in the jQuery era. As if architects forgot that React, Angular, and Vue aren’t just about rendering, but interaction between independent agents. But what if I told you the solution has existed for decades? That the pattern powering RabbitMQ, Kafka, and AWS SQS could save the frontend? That it requires no heavy libraries — just 15 KB of code and a paradigm shift in how we view components. Let’s dive into the rabbit hole of event-driven design. After this article, your components might stop whispering and start conversing like mature, independent modules. Part 1: Historical Context — 40 Years of Pub/Sub Evolution In 1983, when programmers manually set motherboard jumpers, Xerox PARC’s Adele Goldberg was coding Smalltalk-80. Her lab birthed an idea that would outlive PCs, the web, and mobile: "Objects should communicate through messages, not method calls." Evolution Across Three Eras 1. Tribal Era (1980–2000) Smalltalk objects exchanged messages like primitive tribes — directly, without intermediaries. The problem? To send a " message," you needed to know the exact location of the recipient "tribe." "Tribe A sends a message to Tribe B" B primitive: 'The fire is out!' Imperial Era (2000–2010) CORBA and Enterprise Service Bus (ESB) turned messaging into bureaucracy. You had to: Know WSDL contracts Register endpoints Build XML schemas On a 2010 project, we spent three weeks integrating SAP with .NET via ESB. When we asked the architect, "Why not simplify it?" he replied, "This is enterprise — that’s how it’s done here." Globalization Era (2010–Present) Kafka, RabbitMQ, and cloud queues turned Pub/Sub into the lingua franca of microservices. The rules simplified: Message format = the only contract Publishers don’t know subscribers Brokers guarantee delivery Philosophical Shift: From Commands to Contracts Pub/Sub is a digital incarnation of Rousseau’s social contract. When a module publishes a PriceChangedEvent, it essentially declares: "I don’t know who needs this" "But if you want it — listen" "I promise the format: {itemId: string, newPrice: number}" This mirrors TCP/IP for humans: just as data packets don’t care if you’re a browser or email client, messages don’t care if you’re Angular or React. Why Has Pub/Sub Survived 40 Years of Tech Revolutions? Antifragility: Systems learn to live with errors (recall the Dead Letter Queues principle) Language Agnosticism: Messages don’t care what you code in — they’re like Esperanto for microservices Evolutionary Design: You can start with a simple bus and scale to distributed streaming Once at a meetup, I heard: "Kafka is Smalltalk for big data." There might be some truth — both approaches teach systems to communicate politely without unnecessary questions. In the next part, we’ll take this 40-year experience and apply it to Angular components — making them stop poking each other via Input/Output and start speaking the language of independent messages. Frontend: Stuck in the Past? While backend migrated from SOAP to events in the 2010s, frontend was busy inventing… @Output(). Angular components are stuck with relics of the imperial era: Rigid call hierarchies Services as ESB-like monsters Events passing through 5 layers — like bureaucratic mail Once, to add analytics for a button in a child component, we had to: Add @Output() analyticsEvent to component D Pass it through B → C → A Subscribe in the root component A ton of work for a single line: analytics.track('click'). It’s like delivering a letter to your neighbor via three post offices. Lessons the Frontend Missed 1. Events ≠ Call Chains Backend figured out lon

May 10, 2025 - 13:22
 0
Frontend as the New Legacy: How We Missed the Event-Driven Paradigm Shift

Introduction: Architectural Déjà Vu

Have you ever noticed how the digital world moves in spirals? In 2018, I was waving around Dockerfile and Helm charts, implementing microservices in C# with RabbitMQ — all for the sacred goal of low coupling. Three years later, having switched to Angular, I was horrified to realize: frontend components were communicating through Input/Output chains, as if it were 2005 and we were writing WinForms.

It's like assembling a spaceship but controlling it via telegraph. In the backend, we proudly declare event-driven architecture, while on the frontend, components whisper through props like teenagers at a school dance. The irony? The more complex our systems became, the more they resembled the very monoliths we fled from in the backend world.

My epiphany came when I had to modify a dialog box. To add a "Cancel" button, I needed to:

  1. Update the parent component
  2. Rewrite the mediator service
  3. Fix three child modules

Twelve files for one button! In the microservices world, this would be like editing five repositories to change a label color. I asked myself: "Why hasn’t the frontend learned from distributed systems?"

We’ve mastered WebAssembly, adopted GraphQL, modularized CSS — yet component communication remains stuck in the jQuery era. As if architects forgot that React, Angular, and Vue aren’t just about rendering, but interaction between independent agents.

But what if I told you the solution has existed for decades? That the pattern powering RabbitMQ, Kafka, and AWS SQS could save the frontend? That it requires no heavy libraries — just 15 KB of code and a paradigm shift in how we view components.

Let’s dive into the rabbit hole of event-driven design. After this article, your components might stop whispering and start conversing like mature, independent modules.

Part 1: Historical Context — 40 Years of Pub/Sub Evolution

In 1983, when programmers manually set motherboard jumpers, Xerox PARC’s Adele Goldberg was coding Smalltalk-80. Her lab birthed an idea that would outlive PCs, the web, and mobile: "Objects should communicate through messages, not method calls."

Evolution Across Three Eras

1. Tribal Era (1980–2000)
Smalltalk objects exchanged messages like primitive tribes — directly, without intermediaries. The problem? To send a " message," you needed to know the exact location of the recipient "tribe."

"Tribe A sends a message to Tribe B"
B primitive: 'The fire is out!'

Imperial Era (2000–2010)
CORBA and Enterprise Service Bus (ESB) turned messaging into bureaucracy. You had to:

  • Know WSDL contracts
  • Register endpoints
  • Build XML schemas

On a 2010 project, we spent three weeks integrating SAP with .NET via ESB. When we asked the architect, "Why not simplify it?" he replied, "This is enterprise — that’s how it’s done here."

Globalization Era (2010–Present)
Kafka, RabbitMQ, and cloud queues turned Pub/Sub into the lingua franca of microservices. The rules simplified:

  • Message format = the only contract
  • Publishers don’t know subscribers
  • Brokers guarantee delivery

Philosophical Shift: From Commands to Contracts

Pub/Sub is a digital incarnation of Rousseau’s social contract. When a module publishes a PriceChangedEvent, it essentially declares:

  • "I don’t know who needs this"
  • "But if you want it — listen"
  • "I promise the format: {itemId: string, newPrice: number}"

This mirrors TCP/IP for humans: just as data packets don’t care if you’re a browser or email client, messages don’t care if you’re Angular or React.

Why Has Pub/Sub Survived 40 Years of Tech Revolutions?

  1. Antifragility: Systems learn to live with errors (recall the Dead Letter Queues principle)
  2. Language Agnosticism: Messages don’t care what you code in — they’re like Esperanto for microservices
  3. Evolutionary Design: You can start with a simple bus and scale to distributed streaming

Once at a meetup, I heard: "Kafka is Smalltalk for big data." There might be some truth — both approaches teach systems to communicate politely without unnecessary questions.

In the next part, we’ll take this 40-year experience and apply it to Angular components — making them stop poking each other via Input/Output and start speaking the language of independent messages.

Frontend: Stuck in the Past?

While backend migrated from SOAP to events in the 2010s, frontend was busy inventing… @Output(). Angular components are stuck with relics of the imperial era:

  • Rigid call hierarchies
  • Services as ESB-like monsters
  • Events passing through 5 layers — like bureaucratic mail

Once, to add analytics for a button in a child component, we had to:

  1. Add @Output() analyticsEvent to component D
  2. Pass it through B → C → A
  3. Subscribe in the root component

A ton of work for a single line: analytics.track('click'). It’s like delivering a letter to your neighbor via three post offices.

Lessons the Frontend Missed

1. Events ≠ Call Chains

Backend figured out long ago: if microservice A calls B, and B calls C — that’s an anti-pattern. But in frontend, @Output() → service → @Input() is considered normal.

2. Broker ≠ Single Point of Failure

RabbitMQ handles millions of messages. A typical Angular service with Subjects crashes at 1000 events per second.

3. Format > Implementation

Backend uses OpenAPI/Swagger to define contracts. Frontend still works with any in events.

Dawn of Hope: Web Components

Ironically, the future of event-driven frontend began in 2011 with the concept of Web Components. Their Custom Events align closer to Pub/Sub philosophy than the Angular approach:

// Component A
dispatchEvent(new CustomEvent('price-changed', {
    detail: {itemId: '45', price: 20}
}));

// Component B
window.addEventListener('price-changed', (e) => {
    console.log(e.detail.price);
});

This echoes early Smalltalk principles but with HTML5 syntax. It's a pity frameworks didn't pursue this path further.

Historical Paradox: Frontend technologies refresh every 2 years, yet architectural patterns remain stuck in the 2000s. Perhaps it's time to stop reinventing wheels and borrow solutions from... 40-year-old Smalltalk.

In the next part, we'll explore how these principles apply to modern Angular applications – and why @Input/@Output can be more dangerous than they appear. Spoiler: it's like building a castle wall that's harder to scale from the inside than outside.

Part 2: Frontend Dilemma – When Components Start Gossiping

You know that moment when you open a colleague's code and see a component that knows too much? Like that neighbor who monitors everyone through security cameras. In the Angular world, this often starts with innocent @Input() and @Output(), but quickly evolves into a dependency web. Let's uncover why traditional approaches sometimes resemble a game of "broken telephone".

Problem 1: Input/Output as Chain Reactions

Imagine a ProductCard component that should show a modal on click. The classic approach:

// product-card.component.ts
@Output()
openModal = new EventEmitter<string>();

onClick()
{
    this.openModal.emit('product-details');
}

// parent.component.html
<product-card(openModal) = "handleModal($event)" > </product-card>
< modal [type] = "modalType" > </modal>

What's Wrong:

  1. ProductCard knows that a modal exists somewhere
  2. Parent component becomes a courier between unrelated parts
  3. Changing modal type requires editing multiple files

It's like if every office employee delivered documents personally instead of using a shared mailbox.

Problem 2: Mediator Services as New Monoliths

When Outputs multiply, we create ModalService:

// modal.service.ts
private modalSubject = new Subject<string>();
modal$ = this.modalSubject.asObservable();

open(type: string)
{
    this.modalSubject.next(type);
}

// product-card.component.ts
constructor(private modal: ModalService)
{
}

onClick()
{
    this.modal.open('product-details');
}

Seems better, but:

  • Service becomes a "god object" knowing all modals
  • Components are tightly coupled to service API
  • Testing requires mocking entire service for one button

In one project, our SharedService grew to 1200 lines - it managed modals, tooltips, notifications, and animations. We joked that it now runs the project instead of us, but the laughter was nervous.

Case: The Spy Modal

Several years ago we needed to add analytics for a feedback modal. The problem - it was opened from 17 places in the app. With the old pattern we had to:

  1. Add @Output() registerClick to 5 components
  2. Push events through 3 layers of parent components
  3. Update AnalyticsService to add tracking
  4. Write 23 tests to verify event propagation

This took 2 workdays. With postboy solution - 20 minutes:

// Opening modal with analytics
postboy.fire(new OpenModalEvent({
    type: 'signup',
    source: 'navbar' // Analytics context
}));

// Global subscription to all modal opens
postboy.sub(OpenModalEvent).subscribe(event => {
    analytics.track('modal-open', event.type, event.source);
});

No component edits - just added subscription in root module.

Why This is an Architectural Trap?

  1. Fragility: Changing one component triggers a wave of edits in others
  2. Testability: To test a button, you need to mock a chain of services
  3. Scalability: New features increase complexity exponentially

This resembles a city without a master plan: first buildings are erected haphazardly, then they spend years clearing crooked alleys.

Plus, up to a quarter of code review discussions revolve around "how to properly pass an event through component C".

Interim Summary: Angular components are like metropolis residents — they can be independent but need a "central post office" for communication. In the next part, we'll explore how to implement such an office: via custom solution, NgRx, or lightweight libraries. Spoiler: sometimes the best framework is a few dozen lines of code.

Part 3: Backend Lessons — What Frontend Can Steal from RabbitMQ

If components could talk, they'd ask the backend for advice. RabbitMQ, Kafka, and other brokers have been solving the same problems that plague frontend for decades. Let's "borrow" four principles to stop reinventing wheels.

Principle 1: Publishers Don't Know Subscribers

How it works in RabbitMQ:

A seller (publisher) places goods in a warehouse (broker). They don't care who takes the goods — a courier, client, or thief (just kidding).

Frontend Analogy:

A component publishes a "User Logged In" event without knowing:

  • Who will update the header
  • Who will send analytics
  • Who will show a welcome tooltip
// Before
this.authService.login().pipe(
    tap(() => {
        this.header.refresh();
        this.analytics.trackLogin();
        this.tourService.start();
    })
);

// How it should be
this.authService.login().subscribe(() => {
    eventBus.publish(new UserLoggedInEvent());
});

Principle 2: Messages as System Documentation

Backend Practice:

In RabbitMQ, message schemas (e.g., using Avro) serve as living API documentation.

Frontend Implementation:

Every event is a typed class:

class PasswordChangedEvent {
    constructor(
        public readonly userId: string,
        public readonly method: 'email' | 'sms'
    ) {
    }
}

Now any developer can see:

  • What data the event contains
  • Possible field values
  • Where it's used (via project search)

Principle 3: Queues as Chaos Buffers

Backend Pattern:

If a consumer service crashes, RabbitMQ preserves messages in queues until it recovers.

Frontend Adaptation:

For critical events (e.g., analytics), implement retry logic:

class AnalyticsService {
    private failedEvents: AnalyticEvent[] = [];

    constructor() {
        eventBus.subscribe(AnalyticEvent).subscribe(event => {
            try {
                this.sendToServer(event);
            } catch {
                this.failedEvents.push(event); // Save for retry
            }
        });
    }
}

Principle 4: Typing as Contract

Backend Example:

Kafka schemas are registered in Confluent Schema Registry. Incompatible versions get blocked.

Frontend Implementation:

Use TypeScript for error prevention:

// V1: Deprecated version
class ProductAddedEvent {
    constructor(public productId: string) {
    }
}

// V2: New version
class ProductAddedEventV2 {
    constructor(
        public productId: string,
        public categoryId: string
    ) {
    }
}

// Subscriber catches only its version
eventBus.subscribe(ProductAddedEventV2).subscribe(/* ... */);

What It Looks Like in an Ideal World

Imagine components as independent microservices:

  1. Module A publishes CartUpdatedEvent with type { cartId: string, items: CartItem[] }
  2. Module B subscribes and updates the cart badge
  3. Module C listens to the same event for shipping calculations
  4. Module D writes to LocalStorage

No one knows about others' existence. Changed the cart format? Just create CartUpdatedEventV2 — old subscribers keep working with V1.

Why Frontend Lags Behind?

Synchronous Mindset: "Click button → call method → get result" — this is a legacy approach.

Fear of Asynchronicity: Developers fear "floating" events, though it's normal in backend.

Contract Culture: Frontend teams rarely document event formats, turning them into magic strings.

Real-Life Story:

When we implemented typed events, a new developer connected the "order history" feature in a day by studying existing message classes. Previously, this required weeks of negotiations.

Conclusion:

Backend architects have polished event-driven approaches for decades. It's time for frontend to stop stewing in its own juice and start "stealing" proven solutions. In the next part, we'll implement these principles in practice — from custom buses to ready-made tools.

Part 4: Practical Implementation — From Custom Solutions to Libraries

Frontend developers often argue: "Should we build our own EventBus or use existing solutions?". The answer depends on scale. Let’s implement three variants and analyze when each fits.

Option 1: Custom RxJS Bus in 15 Minutes

A basic RxJS EventBus in 20 lines of code:

import {filter, map, Subject} from 'rxjs';

type EventPayload<T> = { type: string; data?: T };

class EventBus {
    private _events$ = new Subject<EventPayload<unknown>>();

    publish<T>(type: string, data?: T): void {
        this._events$.next({type, data});
    }

    subscribe<T>(type: string) {
        return this._events$.pipe(
            filter(event => event.type === type),
            map(event => event.data as T)
        );
    }
}

// Usage
const bus = new EventBus();
bus.subscribe<string>('GREETING').subscribe(msg => console.log(msg));
bus.publish('GREETING', 'Hello from 1983!');

Pros:

  • Full control
  • 0 dependencies
  • Suitable for small projects

Cons:

  • No data typing
  • Manual subscription management
  • No Angular lifecycle support

When to use:

  • Prototypes
  • Mini-apps (<15 components)
  • Teaching Pub/Sub principles

Option 2: NgRx as an Event Store

NgRx is "heavy artillery" with a full toolkit:

// actions/events.actions.ts
export const showModal = createAction(
    '[UI] Show Modal',
    props<{ type: string; context?: unknown }>()
);

// effects/events.effects.ts
showModal$ = createEffect(() =>
    this.actions$.pipe(
        ofType(showModal),
        tap(({type}) => console.log(`Modal ${type} opened`))
    ), {dispatch: false}
);

// component.ts
this.store.dispatch(showModal({type: 'confirm'}));

Pros:

  • DevTools with event history
  • Integration with app state
  • Support for complex scenarios (CQRS, sagas)

Cons:

  • Overkill for simple tasks
  • Learning curve
  • Bundle size +45 KB
  • Typing challenges

When to use:

  • Enterprise applications
  • When NgRx is already in use
  • Complex state-driven business logic

Option 3: Specialized Libraries

The postboy Example:

// Event definition
class ApiErrorEvent extends PostboyGenericMessage {
    constructor(public readonly error: Error) {
        super();
    }
}

// Publishing
postboy.fire(new ApiErrorEvent(err));

// Subscription
postboy.sub(ApiErrorEvent).subscribe(event => {
    alert(`Error: ${event.error.message}`);
});

When to use:

  • Medium to enterprise projects
  • Microfrontends
  • Gradual legacy code migration

How to Choose a Tool?

1. Decision Map:

  • < 15 components: RxJS Subject
  • 15+ components (to infinity): postboy or similar
  • 50+ components (to infinity): NgRx + additional brokers

2. 48-Hour Rule:
If you couldn't implement Pub/Sub in two days — your choice is too complex.

3. Scalability Test:
Try adding a reaction to an event from a completely new module. If it requires changes in 3+ places — your architecture isn't event-driven.

Case Study:
On a project with 70 components, we started with a custom solution and switched to postboy after six months. It was like changing an engine mid-flight, but the event-driven architecture allowed gradual migration.

Conclusion:
Pub/Sub isn't a silver bullet. It's like a hammer: you can drive nails or smash screens. Choose tools based on "nail size" and remember: the best architecture lets you sleep at night, not brag at meetups.

Part 5: When Events Become Harmful — Pub/Sub Pitfalls

Pub/Sub is like fire: warms when controlled, burns everything when unleashed. Let's explore three scenarios where event-driven models turn from medicine to poison.

1. Cyclic Dependencies: Infinite Loop

Problem:

Event A triggers B, B triggers C, and C triggers A again.

// Component A
postboy.subscribe(EventC).subscribe(() => {
    postboy.publish(new EventA()); // Looping
});

// Component B
postboy.subscribe(EventA).subscribe(() => {
    postboy.publish(new EventB());
});

// Component C
postboy.subscribe(EventB).subscribe(() => {
    postboy.publish(new EventC());
});

Why dangerous:

  • Infinite event loop → 100% CPU load
  • Impossible to debug via DevTools

Solution:

  • Add debounce (RxJS operator):
postboy.subscribe(EventA).pipe(
    debounceTime(100)
).subscribe(/* ... */);
  • Use inhibitor flags:
let isProcessing = false;

postboy.subscribe(EventA).subscribe(() => {
    if (!isProcessing) {
        isProcessing = true;
// Logic...
        isProcessing = false;
    }
});

2. Memory Leaks: Ghost Subscriptions

Problem:

Unsubscribed subscriptions in services accumulate during hot module reloading.


@Injectable()
export class AnalyticsService {
    constructor() {
// Subscription never unsubscribed!
        eventBus.subscribe(TrackingEvent).subscribe(/* ... */);
    }
}

Why dangerous:

  • Memory leaks → performance crashes
  • "Zombie handlers" react to events after component destruction

Angular Solution:

  • Use takeUntilDestroyed:
private destroyRef = inject(DestroyRef);

eventBus.subscribe(Event)
    .pipe(takeUntilDestroyed(this.destroyRef))
    .subscribe(/* ... */);
  • For services — manual management:
private sub?: Subscription;

ngOnInit()
{
    this.sub = eventBus.subscribe(Event).subscribe(/* ... */);
}

ngOnDestroy()
{
    this.sub?.unsubscribe();
}

3. Typing Blind Spots: Errors in the Darkness

The Problem:

Untyped events become runtime time bombs.

// Bad: Data without contract
eventBus.publish('user_updated', {id: 123, name: 'Alice'});

// Somewhere else in the codebase
eventBus.subscribe('user_updated').subscribe(data => {
    console.log((data as any).age); // undefined → runtime crash
});

Why Dangerous:

  • Errors surface only at runtime
  • Refactoring becomes Russian roulette

Solution:

  • Use message classes:
class UserModel {
    id: string;
    name: string
}

class UserUpdatedEvent extends PostboyGenericMessage {
    constructor(data: UserModel) {
        super();
    }
}

// Type-safe subscription
postboy.subscribe(UserUpdatedEvent).subscribe(data => {
    console.log(data.name); // string type guaranteed
});

When NOT to Use Pub/Sub

Safety Rule:

Before publishing an event, ask three questions:

  1. Are there subscribers besides me?
  2. Could this event trigger unexpected side effects?
  3. Can the problem be solved simpler via Input/Output?

Conclusion:

Pub/Sub requires discipline. It's nuclear energy: properly handled gives light, mishandled causes destruction.

Part 6: Epilogue — Cultivating Architecture

Software architecture isn't a blueprint carved in stone. It's a living garden where components, like plants, grow, intertwine, and occasionally require pruning. Pub/Sub isn't a magic staff, but a gardener's tool to manage chaos without suppressing it.

Three Rules of Evolution

1. Start Small

Don't attempt to implement event-driven architecture across the entire application at once. Begin with one module where dependencies already resemble a spiderweb. Replace the most problematic @Output() with an event — and watch how the architecture starts evolving on its own.

2. Refactor Only When Issues Arise

If components communicate through two initialization layers — leave them untouched. As Kent Beck said: "Don't solve problems you don't have". Pub/Sub is a remedy for complexity, not a preventive vitamin.

3. Choose Tools Matching Scale

A custom 20-line bus might be better than NgRx or postboy for a 15-component project. But when the system grows to 200+ modules — seek solutions with typing.

How to Start Tomorrow

1. Find a "Suspicious" Component

One that knows about five other modules. Replace one method call with an event.

2. Document Contracts

Create an events folder with message classes. Even if using string types — describe them in JSDoc.

3. Organize a "Silent Day"

Ban the team from using @Output() and mediator services for a week. You'll be surprised how quickly event-driven alternatives emerge.

Epilogue for Skeptics

"But events complicate debugging!" you'll say. Let me answer with a story: when we implemented event-driven architecture in our project, a new developer integrated a feature in one day that previously took a week. They simply found the right event in documentation and subscribed.

Yes, events mean responsibility. Yes, they require discipline. But they also grant freedom comparable to transitioning from monarchy to democracy. You stop being a god-architect and become a gardener who only sets growth rules.

Postboy — one tool in your shed. You might choose NgRx, a custom bus, or something else. The essence isn't the library, but the paradigm shift: *stop coupling components and start describing their interaction as a contract between equals
*
.

Final Tip. Next time you see a chain of three @Output()s — imagine it's a weed. Uproot it, plant an event, and watch the architecture blossom.

P.S. Postboy documentation — here. But if you prefer to write your own EventBus, I hope this treatise inspires your heroic feat. Happy coding!