Monoliths That Scale: Architecting with Command and Event Buses

Monoliths That Scale: Architecting with Command and Event Buses

Read Time10 minutes
E
Erik DvorcakPosted on May 20, 2025
#architecture#event-driven#monolith#command bus#scalability#angular

Monoliths That Scale: Architecting with Command and Event Buses

Most engineering teams start with a monolith. It's fast to build, easy to deploy, and straightforward to reason about—until it isn't. As features grow and teams expand, the traditional monolith becomes increasingly difficult to maintain, test, and evolve.

But what if you could design a monolith that scales with your organization, using architectural patterns typically reserved for distributed systems?

Architecture Pattern: Build maintainable monoliths that enable team autonomy without microservice complexity

Introduction

The path to microservices is often paved with monolithic regrets. Teams rush to decompose applications when faced with coupling problems, only to trade one set of challenges for another: distributed transactions, network latency, and deployment complexity.

The decoupled monolith offers an alternative approach—one that brings the modularity benefits of microservices while maintaining the operational simplicity of a single application. Central to this architecture are two critical patterns: the Command Bus and the Event Bus.

This article explores how these patterns create loosely coupled modules within a monolithic codebase, enabling your team to build maintainable applications that can evolve with changing requirements—and potentially migrate to microservices later if truly needed.

Traditional Monolith Challenges

💡

Why Monoliths Become Problematic

Structural Problems

  • • Direct service calls creating tight coupling between modules
  • • Feature boundaries blurred by shared code and direct dependencies
  • • Circular dependencies making the codebase difficult to navigate
  • • Single responsibility violated as components take on multiple roles

Development Challenges

  • • High cognitive load to understand effects of changes
  • • Features difficult to test in isolation
  • • Conflicting changes between teams working in shared areas
  • • High deployment risk as changes affect the entire application

These challenges intensify as applications grow. What begins as a manageable codebase gradually transforms into a complex web of interdependencies where changes become increasingly risky and time-consuming.

The Decoupled Monolith Architecture

A decoupled monolith is a single deployable unit where internal modules communicate through well-defined messages instead of direct references. The approach maintains the operational benefits of monoliths while enabling the structural benefits of more distributed architectures.

💡

Real-World Problem Example

Consider a common e-commerce scenario: placing an order in a traditional monolith directly calls methods on inventory, payment, notification, and analytics services, creating tight coupling. Adding new steps like fraud detection requires modifying existing code, and testing requires mocking multiple dependencies.

With a decoupled approach using command and event buses, an OrderController simply emits a ProcessOrder command and waits for a response. Independent handlers in separate modules listen for specific commands and events, enhancing modularity and making changes less risky.

Traditional vs. Decoupled Approach

order-controller-traditional.ts
1// Traditional tightly-coupled approach
2class OrderController {
3 constructor(
4 private inventoryService: InventoryService,
5 private paymentService: PaymentService,
6 private notificationService: NotificationService,
7 private analyticsService: AnalyticsService
8 ) {}
9
10 async placeOrder(order: Order): Promise<OrderResult> {
11 // Direct service calls create tight coupling
12 await this.inventoryService.reserveItems(order.items);
13 const paymentResult = await this.paymentService.processPayment(order.payment);
14
15 if (paymentResult.success) {
16 await this.notificationService.sendOrderConfirmation(order);
17 this.analyticsService.trackOrder(order);
18 return { success: true, orderId: paymentResult.transactionId };
19 } else {
20 await this.inventoryService.releaseItems(order.items);
21 return { success: false, error: paymentResult.error };
22 }
23 }
24}
order-controller-decoupled.ts
1// Decoupled approach with command and event buses
2class OrderController {
3 constructor(private commandBus: CommandBus) {}
4
5 async placeOrder(order: Order): Promise<OrderResult> {
6 // Simply emit command and wait for response
7 return this.commandBus.emitWithResponse<Order, OrderResult>(
8 'ProcessOrder',
9 order
10 ).toPromise();
11 }
12}
13
14// Independent, focused handlers in different modules
15@Injectable()
16class InventoryHandler {
17 constructor(
18 private inventoryService: InventoryService,
19 private commandBus: CommandBus,
20 private eventBus: EventBus
21 ) {
22 // Listen for the command
23 this.commandBus.on<Order>('ProcessOrder')
24 .subscribe(async (command) => {
25 try {
26 await this.inventoryService.reserveItems(command.data.items);
27 // Pass control to the next handler
28 this.commandBus.emit({
29 type: 'ProcessPayment',
30 data: command.data,
31 requestId: command.requestId
32 });
33 } catch (error) {
34 // Respond with failure if inventory check fails
35 if (command.requestId) {
36 this.commandBus.respond(command, {
37 success: false,
38 error: 'Inventory unavailable'
39 });
40 }
41 }
42 });
43
44 // Listen for payment failure to release inventory
45 this.eventBus.on<Order>('PaymentFailed')
46 .subscribe(async (order) => {
47 await this.inventoryService.releaseItems(order.items);
48 });
49 }
50}

Core Principles

  • Messaging over direct calls — Components communicate by publishing and consuming messages
  • Module autonomy — Each feature area owns its domain logic and implementation details
  • Explicit contracts — Well-defined message types formalize the API between modules
  • Single responsibility — Each handler performs one specific task in response to a message

This architecture delivers several key benefits over traditional monoliths:

Modularity

Clear boundaries between features without infrastructure complexity of microservices

Testability

Features can be isolated and tested independently through their message interfaces

Evolution

Modules can be refined or replaced without cascading changes through the application

The Command Bus Pattern

The Command Bus serves as a mediator that routes commands (requests to perform actions) to their appropriate handlers. It implements a form of the mediator pattern to decouple command senders from the handlers that process them.

💡

Command Pattern Fundamentals

Commands represent intents to perform an action that will change the system state. They:

  • • Are named in imperative form: CreateUser, UpdateSettings, ProcessPayment
  • • Typically map to a single handler that knows how to execute them
  • • May return results directly to the sender when synchronous responses are needed
  • • Should be immutable data structures containing all necessary information

The Command Bus itself doesn't prescribe how handlers should be implemented or how commands should be structured—it simply provides the routing mechanism between parts of your application.

Two common command interactions exist in this pattern: fire-and-forget commands and request-response commands.

The Event Bus Pattern

While the Command Bus handles direct actions, the Event Bus enables loose coupling through a publish-subscribe model. Events represent significant occurrences that have already happened within the system.

💡

Event Pattern Fundamentals

Events represent facts that have occurred in the system. They:

  • • Are named in past tense: UserCreated, OrderPlaced, PaymentProcessed
  • • May have multiple subscribers, or none at all
  • • Create an audit trail of system activities
  • • Allow modules to react to changes without direct coupling

The Event Bus provides a way for completely separate modules to communicate without knowledge of each other—the emitter doesn't need to know who (if anyone) is listening.

This pattern enables powerful workflows where one module can emit an event that multiple other modules react to independently, each performing their specialized tasks.

Implementation in Angular

Let's examine practical implementations of both bus patterns using Angular 19+ and RxJS. These implementations leverage RxJS Subjects for message distribution and TypeScript for type safety.

CommandBus Implementation

command-bus.service.ts
1import { Injectable } from '@angular/core';
2import { Observable, Subject, ReplaySubject, filter, take, timeout, throwError } from 'rxjs';
3
4export interface CommandMessage<T = unknown> {
5 type: string;
6 data: T;
7 requestId?: string;
8}
9
10@Injectable({ providedIn: 'root' })
11export class CommandBus {
12 #commandStream = new Subject<CommandMessage>();
13 #pendingResponses = new Map<string, ReplaySubject<unknown>>();
14
15 emit<T = unknown>(command: CommandMessage<T>): void {
16 this.#commandStream.next(command);
17 }
18
19 emitWithResponse<T = unknown, R = unknown>(
20 type: string,
21 data: T,
22 responseTimeoutMs = 5000
23 ): Observable<R> {
24 const requestId = crypto.randomUUID();
25 const subject = new ReplaySubject<R>(1);
26 this.#pendingResponses.set(requestId, subject as ReplaySubject<unknown>);
27
28 subject.pipe(take(1)).subscribe({
29 complete: () => this.#pendingResponses.delete(requestId)
30 });
31
32 this.emit({ type, data, requestId });
33
34 return subject.asObservable().pipe(
35 timeout({
36 each: responseTimeoutMs,
37 with: () => throwError(() => new Error(`Response timed out for command: ${type}`))
38 })
39 );
40 }
41
42 on<T = unknown>(type: string): Observable<CommandMessage<T>> {
43 return this.#commandStream.pipe(
44 filter((msg) => msg.type === type)
45 ) as Observable<CommandMessage<T>>;
46 }
47
48 respond<R = unknown>(message: CommandMessage<unknown>, response: R): void {
49 const requestId = message.requestId;
50 if (!requestId) return;
51
52 const responder = this.#pendingResponses.get(requestId);
53 if (!responder) {
54 console.warn(`No pending response found for requestId: ${requestId}`);
55 return;
56 }
57
58 responder.next(response);
59 responder.complete();
60 }
61}

EventBus Implementation

event-bus.service.ts
1import { Injectable } from '@angular/core';
2import { Observable, Subject, filter, map } from 'rxjs';
3
4export interface EventMessage<T = unknown> {
5 type: string;
6 payload: T;
7}
8
9@Injectable({ providedIn: 'root' })
10export class EventBus {
11 #eventStream = new Subject<EventMessage>();
12
13 emit<T = unknown>(event: EventMessage<T>): void {
14 this.#eventStream.next(event);
15 }
16
17 on<T = unknown>(type: string): Observable<T> {
18 return this.#eventStream.pipe(
19 filter((event) => event.type === type),
20 map((event) => event.payload as T)
21 );
22 }
23}

Implementation Highlights

  • TypeScript generics provide type safety across message boundaries
  • RxJS filtering routes messages to interested handlers
  • Private class fields (#) encapsulate implementation details
  • Early returns promote clean, flat code over nested conditionals
  • Timeout handling prevents hanging promises if handlers fail to respond

Practical Usage Examples

Let's explore practical examples showing how these bus implementations enable clean, decoupled interactions between application modules.

usage-examples.ts
1// Emit a simple command (fire-and-forget)
2commandBus.emit({
3 type: 'CreateUser',
4 data: { email: 'erik@example.com' }
5});
6
7// Listen for a command
8commandBus.on<{ email: string }>('CreateUser').subscribe((command) => {
9 // Handle user creation logic
10 userService.create(command.data.email);
11
12 // If this command expects a response, send it
13 if (command.requestId) {
14 commandBus.respond(command, { id: 'new-user-123' });
15 }
16});
17
18// Emit with response expectation
19commandBus.emitWithResponse<{ email: string }, { id: string }>(
20 'CreateUser',
21 { email: 'erik@example.com' }
22).subscribe({
23 next: (res) => console.log('Created user with ID:', res.id),
24 error: (err) => console.error('Failed to create user:', err)
25});
26
27// Emit an event after command completion
28eventBus.emit({
29 type: 'UserCreated',
30 payload: {
31 userId: 'new-user-123',
32 email: 'erik@example.com',
33 timestamp: new Date().toISOString()
34 }
35});
36
37// Subscribe to events in different modules
38// Module 1: Notification service
39eventBus.on<{ userId: string; email: string }>('UserCreated')
40 .subscribe((data) => {
41 notificationService.sendWelcomeEmail(data.email);
42 });
43
44// Module 2: Analytics service
45eventBus.on<{ userId: string; timestamp: string }>('UserCreated')
46 .subscribe((data) => {
47 analyticsService.trackUserCreation(data.userId, data.timestamp);
48 });

These examples demonstrate how different parts of your application can interact without direct references to each other, using standardized message formats instead.

Testing Message-Driven Applications

One of the major benefits of a decoupled monolith is improved testability. The message-based architecture makes it easier to test components in isolation without complex mocking.

💡

Testing Approaches

There are three key testing approaches when working with command and event buses:

1. Command Handler Testing: Test handlers in isolation by verifying they respond correctly to commands and produce expected events. Mock dependencies like services and the event bus to focus on handler logic.

2. Component Testing: When testing UI components that emit commands, mock the command bus to verify the correct messages are sent with appropriate payloads. This isolates component logic from handler implementation.

3. Integration Testing: For testing workflows that span multiple handlers, use real bus implementations but mock services. This verifies the communication flow between handlers while controlling external dependencies.

Command Handler Testing

When testing command handlers, focus on verifying they respond correctly to commands and produce expected side effects. Mock service dependencies and the event bus to isolate handler logic.

user-command-handler.spec.ts
1describe('UserCommandHandler', () => {
2 let handler: UserCommandHandler;
3 let mockUserService: jasmine.SpyObj<UserService>;
4 let mockEventBus: jasmine.SpyObj<EventBus>;
5
6 beforeEach(() => {
7 mockUserService = jasmine.createSpyObj('UserService', ['createUser']);
8 mockEventBus = jasmine.createSpyObj('EventBus', ['emit']);
9
10 TestBed.configureTestingModule({
11 providers: [
12 UserCommandHandler,
13 { provide: UserService, useValue: mockUserService },
14 { provide: EventBus, useValue: mockEventBus }
15 ]
16 });
17
18 handler = TestBed.inject(UserCommandHandler);
19 });
20
21 it('should create user and emit UserCreated event when handling CreateUser command', () => {
22 // Arrange
23 const createUserCommand: CommandMessage<CreateUserData> = {
24 type: 'CreateUser',
25 data: { email: 'test@example.com' },
26 requestId: '123'
27 };
28
29 mockUserService.createUser.and.returnValue(Promise.resolve({
30 id: 'user-123',
31 email: 'test@example.com'
32 }));
33
34 // Act
35 handler.handleCreateUser(createUserCommand);
36
37 // Assert
38 expect(mockUserService.createUser).toHaveBeenCalledWith('test@example.com');
39 expect(mockEventBus.emit).toHaveBeenCalledWith({
40 type: 'UserCreated',
41 payload: jasmine.objectContaining({
42 userId: 'user-123',
43 email: 'test@example.com'
44 })
45 });
46 });
47});

Testing UI Components

For components that interact with buses, use test doubles to verify correct messages are sent. This approach isolates UI logic from the actual command handlers.

user-registration.component.spec.ts
1describe('UserRegistrationComponent', () => {
2 let component: UserRegistrationComponent;
3 let fixture: ComponentFixture<UserRegistrationComponent>;
4 let mockCommandBus: jasmine.SpyObj<CommandBus>;
5
6 beforeEach(async () => {
7 mockCommandBus = jasmine.createSpyObj('CommandBus', ['emitWithResponse']);
8 mockCommandBus.emitWithResponse.and.returnValue(of({ success: true }));
9
10 await TestBed.configureTestingModule({
11 imports: [ReactiveFormsModule],
12 declarations: [UserRegistrationComponent],
13 providers: [
14 { provide: CommandBus, useValue: mockCommandBus }
15 ]
16 }).compileComponents();
17
18 fixture = TestBed.createComponent(UserRegistrationComponent);
19 component = fixture.componentInstance;
20 fixture.detectChanges();
21 });
22
23 it('should emit CreateUser command when form is submitted', () => {
24 // Arrange
25 component.registrationForm.setValue({
26 email: 'test@example.com',
27 password: 'password123'
28 });
29
30 // Act
31 component.submitForm();
32
33 // Assert
34 expect(mockCommandBus.emitWithResponse).toHaveBeenCalledWith(
35 'CreateUser',
36 { email: 'test@example.com', password: 'password123' }
37 );
38 });
39});

Integration Testing

For end-to-end testing of message flows, use the actual bus implementations with mock services. This approach verifies communication between handlers while controlling external dependencies.

order-flow.integration.spec.ts
1describe('Order Processing Flow Integration', () => {
2 let commandBus: CommandBus;
3 let eventBus: EventBus;
4 let orderHandler: OrderCommandHandler;
5 let paymentHandler: PaymentCommandHandler;
6 let mockInventoryService: MockInventoryService;
7 let mockPaymentService: MockPaymentService;
8
9 beforeEach(() => {
10 // Use real bus implementations
11 commandBus = new CommandBus();
12 eventBus = new EventBus();
13
14 // Use mock services
15 mockInventoryService = new MockInventoryService();
16 mockPaymentService = new MockPaymentService();
17
18 // Create handlers with real buses but mock services
19 orderHandler = new OrderCommandHandler(
20 commandBus,
21 eventBus,
22 mockInventoryService
23 );
24
25 paymentHandler = new PaymentCommandHandler(
26 commandBus,
27 eventBus,
28 mockPaymentService
29 );
30
31 // Initialize handlers (which sets up subscriptions)
32 orderHandler.initialize();
33 paymentHandler.initialize();
34 });
35
36 it('should process order through the entire flow', async () => {
37 // Arrange
38 const orderData = {
39 items: [{ id: 'item-1', quantity: 2 }],
40 payment: { cardNumber: '4111111111111111', amount: 100 }
41 };
42
43 // Spy on events
44 const orderCompletedSpy = jasmine.createSpy('orderCompletedSpy');
45 eventBus.on('OrderCompleted').subscribe(orderCompletedSpy);
46
47 // Act
48 const result = await commandBus
49 .emitWithResponse('ProcessOrder', orderData)
50 .toPromise();
51
52 // Assert
53 expect(result.success).toBeTrue();
54 expect(mockInventoryService.reserveCalled).toBeTrue();
55 expect(mockPaymentService.processCalled).toBeTrue();
56 expect(orderCompletedSpy).toHaveBeenCalled();
57 });
58});

Architectural Pattern Comparison

Before selecting an architectural approach, it's valuable to compare the three main patterns discussed and their suitability for different scenarios:

FeatureTraditional MonolithDecoupled MonolithMicroservices
Deployment Complexity

Low - Single deployable unit

Low - Single deployable unit

High - Many services with interdependencies

Internal Modularity

Poor - Often becomes "big ball of mud"

Strong - Well-defined boundaries via messages

Strong - Complete separation between services

Team Autonomy

Low - Everyone works in same codebase

Medium - Teams own modules but share codebase

High - Teams can own entire services

Operations Complexity

Low - Single application to monitor

Low - Single application to monitor

High - Multiple services to monitor and debug

Testability

Mixed - Often requires large test fixtures

Good - Modules can be tested in isolation

Mixed - Unit tests easy, integration tests complex

Technology Flexibility

Low - Single technology stack

Medium - Common core but flexible implementations

High - Each service can use different stack

Transactional Consistency

Strong - Uses database transactions

Strong - Can use database transactions

Challenging - Requires distributed patterns

Scalability

Limited - Vertical scaling only

Limited - Better internal scaling but still monolithic

High - Services can scale independently

Implementation Cost

Low - Simple to get started

Medium - Requires disciplined design

High - Requires significant infrastructure

Architectural Trade-offs

✅ Advantages

  • Clean modularity with explicit boundaries between features
  • No infrastructure overhead of distributed messaging systems
  • Testable architecture where modules can be isolated
  • Refactoring safety through well-defined message contracts
  • Easier migration path to microservices if needed later

❌ Considerations

  • Added complexity for smaller applications that don't need it
  • Learning curve for teams unfamiliar with message-based architectures
  • Debugging challenges in message-based workflows
  • No horizontal scaling beyond single-process capabilities
  • Potential message explosion without disciplined design
💡

When To Use This Pattern

This architecture is particularly well-suited for:

  • • Medium to large applications with distinct feature areas
  • • Teams at risk of creating a "big ball of mud" architecture
  • • Projects that may need microservice-like modularity in the future
  • • Organizations that want team autonomy without distributed system complexity

Conversely, very small applications or those with minimal domain complexity may not benefit enough to justify the additional architectural patterns.

Advanced Patterns & Techniques

Once you've successfully implemented the basic command and event bus patterns, you can evolve your architecture with more advanced techniques:

CQRS (Command Query Responsibility Segregation)

Separate your reading operations from your writing operations with specialized models for each.

  • • Use the command bus for state changes
  • • Create a specialized query bus for data retrieval
  • • Optimize read and write models independently

Sagas & Process Managers

Coordinate long-running processes that span multiple commands and events.

  • • Orchestrate multi-step processes
  • • Maintain state between steps
  • • Handle compensating actions for failures
💡

Decorator-Based Handler Registration

A common enhancement to the basic pattern is to add a decorator-based registration system for command handlers. This approach uses TypeScript decorators to mark methods as handlers for specific command types, improving discoverability and reducing boilerplate code.

The implementation typically involves a CommandHandler decorator that stores metadata about which methods handle which commands, and a base handler class that automatically subscribes to the relevant commands during initialization.

This pattern is especially useful in larger applications where you might have dozens of command types and handlers spread across multiple modules.

Decorator-Based Command Handler System

command-handler-decorators.ts
1// Command handler decorator
2export function CommandHandler(commandType: string) {
3 return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
4 const originalMethod = descriptor.value;
5
6 // Store metadata about this handler
7 if (!Reflect.hasMetadata('handlers', target.constructor)) {
8 Reflect.defineMetadata('handlers', [], target.constructor);
9 }
10
11 const handlers = Reflect.getMetadata('handlers', target.constructor);
12 handlers.push({
13 commandType,
14 methodName: propertyKey
15 });
16
17 return descriptor;
18 };
19}
20
21// Base handler class that sets up subscriptions using metadata
22export abstract class BaseCommandHandlerService implements OnInit {
23 constructor(protected commandBus: CommandBus) {}
24
25 ngOnInit() {
26 const handlers = Reflect.getMetadata('handlers', this.constructor) || [];
27
28 handlers.forEach(handler => {
29 this.commandBus.on(handler.commandType).subscribe(command => {
30 (this as any)[handler.methodName](command);
31 });
32 });
33 }
34}
35
36// Example usage
37@Injectable({ providedIn: 'root' })
38export class UserCommandHandlers extends BaseCommandHandlerService {
39 constructor(
40 commandBus: CommandBus,
41 private userService: UserService,
42 private eventBus: EventBus
43 ) {
44 super(commandBus);
45 }
46
47 @CommandHandler('CreateUser')
48 async handleCreateUser(command: CommandMessage<CreateUserData>) {
49 const user = await this.userService.createUser(command.data.email);
50
51 this.eventBus.emit({
52 type: 'UserCreated',
53 payload: {
54 userId: user.id,
55 email: user.email
56 }
57 });
58
59 // If this is a command that expects a response, send it
60 if (command.requestId) {
61 this.commandBus.respond(command, { success: true, userId: user.id });
62 }
63 }
64}

Conclusion

Monoliths don't need to be rigid or unmaintainable. With command and event buses, you can structure your application in a scalable, clean, and maintainable way—gaining many benefits of microservices without the operational complexity.

This approach creates clear boundaries between modules, improves testability, and allows your system to evolve more gracefully over time. It also prepares your codebase for potential future extraction into separate services if that becomes necessary.

Consider adopting this pattern before reaching for distributed systems. You might find that a well-designed monolith meets your needs for much longer than expected, saving significant complexity and overhead.

Key Takeaways

  • Start simple, evolve gradually: Begin with a well-structured monolith and introduce message buses to decouple features as complexity grows

  • Design for testability: Message-based architectures are inherently more testable—take advantage of this for comprehensive test coverage

  • Be consistent with message design: Create clear conventions for command and event naming, structure, and responsibility boundaries

  • Consider performance implications: Message-based systems add some overhead—ensure your bus implementations are optimized for your application's needs

Want to Discuss Software Architecture?

Have questions about implementing this pattern or other architectural approaches for your projects? Let's connect.

Get in Touch →
Erik Dvorcak

About the Author

Erik Dvorcak is a Remote Software Engineer with 8+ years of experience specializing in building elegant SaaS products and startup solutions from concept to deployment.