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?
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
1// Traditional tightly-coupled approach2class OrderController {3 constructor(4 private inventoryService: InventoryService,5 private paymentService: PaymentService,6 private notificationService: NotificationService,7 private analyticsService: AnalyticsService8 ) {}9
10 async placeOrder(order: Order): Promise<OrderResult> {11 // Direct service calls create tight coupling12 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}
1// Decoupled approach with command and event buses2class OrderController {3 constructor(private commandBus: CommandBus) {}4
5 async placeOrder(order: Order): Promise<OrderResult> {6 // Simply emit command and wait for response7 return this.commandBus.emitWithResponse<Order, OrderResult>(8 'ProcessOrder',9 order10 ).toPromise();11 }12}13
14// Independent, focused handlers in different modules15@Injectable()16class InventoryHandler {17 constructor(18 private inventoryService: InventoryService,19 private commandBus: CommandBus,20 private eventBus: EventBus21 ) {22 // Listen for the command23 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 handler28 this.commandBus.emit({29 type: 'ProcessPayment',30 data: command.data,31 requestId: command.requestId32 });33 } catch (error) {34 // Respond with failure if inventory check fails35 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 inventory45 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
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 = 500023 ): 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
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.
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 command8commandBus.on<{ email: string }>('CreateUser').subscribe((command) => {9 // Handle user creation logic10 userService.create(command.data.email);11 12 // If this command expects a response, send it13 if (command.requestId) {14 commandBus.respond(command, { id: 'new-user-123' });15 }16});17
18// Emit with response expectation19commandBus.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 completion28eventBus.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 modules38// Module 1: Notification service39eventBus.on<{ userId: string; email: string }>('UserCreated')40 .subscribe((data) => {41 notificationService.sendWelcomeEmail(data.email);42 });43
44// Module 2: Analytics service45eventBus.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.
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 // Arrange23 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 // Act35 handler.handleCreateUser(createUserCommand);36 37 // Assert38 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.
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 // Arrange25 component.registrationForm.setValue({26 email: 'test@example.com',27 password: 'password123'28 });29 30 // Act31 component.submitForm();32 33 // Assert34 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.
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 implementations11 commandBus = new CommandBus();12 eventBus = new EventBus();13 14 // Use mock services15 mockInventoryService = new MockInventoryService();16 mockPaymentService = new MockPaymentService();17 18 // Create handlers with real buses but mock services19 orderHandler = new OrderCommandHandler(20 commandBus,21 eventBus,22 mockInventoryService23 );24 25 paymentHandler = new PaymentCommandHandler(26 commandBus,27 eventBus,28 mockPaymentService29 );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 // Arrange38 const orderData = {39 items: [{ id: 'item-1', quantity: 2 }],40 payment: { cardNumber: '4111111111111111', amount: 100 }41 };42 43 // Spy on events44 const orderCompletedSpy = jasmine.createSpy('orderCompletedSpy');45 eventBus.on('OrderCompleted').subscribe(orderCompletedSpy);46 47 // Act48 const result = await commandBus49 .emitWithResponse('ProcessOrder', orderData)50 .toPromise();51 52 // Assert53 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:
Feature | Traditional Monolith | Decoupled Monolith | Microservices |
---|---|---|---|
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
1// Command handler decorator2export function CommandHandler(commandType: string) {3 return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {4 const originalMethod = descriptor.value;5 6 // Store metadata about this handler7 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: propertyKey15 });16 17 return descriptor;18 };19}20
21// Base handler class that sets up subscriptions using metadata22export 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 usage37@Injectable({ providedIn: 'root' })38export class UserCommandHandlers extends BaseCommandHandlerService {39 constructor(40 commandBus: CommandBus,41 private userService: UserService,42 private eventBus: EventBus43 ) {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.email56 }57 });58 59 // If this is a command that expects a response, send it60 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 →Contents
Jump to a section of this article