The Problem with Microservice Event Chains
Learn why microservice event chains can become a nightmare, and discover better patterns for service communication.
We've all been there: a simple user action triggers a cascade of events across microservices. It seems elegant at first, but soon becomes a debugging nightmare. Here's why, and how to fix it.
The Common Pattern
// Service A: User Service
class UserService {
async createUser(data: CreateUserDTO): Promise<User> {
const user = await this.userRepo.create(data);
// Publish event
await this.eventBus.publish('user.created', {
userId: user.id,
email: user.email
});
return user;
}
}
// Service B: Email Service
class EmailListener {
@EventSubscriber('user.created')
async handleUserCreated(event: UserCreatedEvent) {
await this.sendWelcomeEmail(event.email);
// Publish another event
await this.eventBus.publish('welcome.email.sent', {
userId: event.userId
});
}
}
// Service C: Analytics Service
class AnalyticsListener {
@EventSubscriber('welcome.email.sent')
async handleEmailSent(event: EmailSentEvent) {
await this.trackUserJourney(event.userId, 'welcome_email');
// And another one...
await this.eventBus.publish('analytics.updated', {
userId: event.userId,
event: 'welcome_flow_complete'
});
}
}
The Problems
1. Invisible Dependencies
// ❌ BAD: Hidden service coupling
class UserController {
async createUser(req: Request) {
const user = await this.userService.createUser(req.body);
return { id: user.id };
// What else happens? 🤔
// - Email service sends welcome email
// - Analytics service tracks events
// - Notification service sends alerts
// - ...and who knows what else?
}
}
2. Debugging Nightmares
// ❌ BAD: Distributed tracing becomes complex
async function debugUserCreation(userId: string) {
const user = await userService.findUser(userId);
const emailLogs = await emailService.findLogs(userId);
const analyticsEvents = await analyticsService.findEvents(userId);
// Good luck correlating all of this!
console.log('User:', user);
console.log('Email Status:', emailLogs);
console.log('Analytics:', analyticsEvents);
}
Better Approaches
1. Orchestration Over Events
// ✅ BETTER: Explicit orchestration
class UserRegistrationOrchestrator {
async registerUser(data: RegisterUserDTO): Promise<void> {
// Start transaction
const traceId = generateTraceId();
try {
// Create user
const user = await this.userService.createUser(data);
// Send welcome email
await this.emailService.sendWelcomeEmail(user.email);
// Track analytics
await this.analyticsService.trackRegistration(user.id);
// Commit transaction
await this.commit(traceId);
} catch (error) {
// Rollback if needed
await this.rollback(traceId);
throw error;
}
}
}
2. Event Aggregation
// ✅ BETTER: Aggregate related events
interface UserRegistrationEvent {
type: 'user.registered';
payload: {
userId: string;
email: string;
timestamp: string;
metadata: {
source: string;
campaign?: string;
};
};
}
class UserRegistrationHandler {
async handle(event: UserRegistrationEvent): Promise<void> {
// Parallel execution of independent tasks
await Promise.all([
this.sendWelcomeEmail(event),
this.trackAnalytics(event),
this.setupUserDefaults(event)
]);
}
}
3. Saga Pattern
// ✅ BETTER: Explicit saga steps
class UserRegistrationSaga {
private readonly steps = [
{
execute: (data: RegisterUserDTO) =>
this.userService.createUser(data),
compensate: (userId: string) =>
this.userService.deleteUser(userId)
},
{
execute: (user: User) =>
this.emailService.sendWelcomeEmail(user.email),
compensate: (user: User) =>
this.emailService.sendCancellation(user.email)
},
{
execute: (user: User) =>
this.analyticsService.trackRegistration(user.id),
compensate: (user: User) =>
this.analyticsService.removeTracking(user.id)
}
];
async execute(data: RegisterUserDTO): Promise<void> {
const context = new SagaContext();
for (const step of this.steps) {
try {
const result = await step.execute(data);
context.addResult(result);
} catch (error) {
await this.rollback(context);
throw error;
}
}
}
private async rollback(context: SagaContext): Promise<void> {
for (const step of context.getExecutedSteps().reverse()) {
await step.compensate(context.getData());
}
}
}
Best Practices
1. Event Schema Versioning
// ✅ BETTER: Versioned event schemas
interface UserEventV1 {
version: 1;
type: 'user.created';
payload: {
userId: string;
email: string;
};
}
interface UserEventV2 {
version: 2;
type: 'user.created';
payload: {
userId: string;
email: string;
metadata: {
source: string;
timestamp: string;
};
};
}
class EventHandler {
handle(event: UserEventV1 | UserEventV2) {
if (event.version === 1) {
return this.handleV1(event);
}
return this.handleV2(event);
}
}
2. Correlation and Tracing
// ✅ BETTER: Explicit correlation
interface TracedEvent<T> {
traceId: string;
parentId?: string;
timestamp: string;
data: T;
}
class EventPublisher {
async publish<T>(
event: T,
context: TraceContext
): Promise<void> {
const tracedEvent: TracedEvent<T> = {
traceId: context.traceId,
parentId: context.parentId,
timestamp: new Date().toISOString(),
data: event
};
await this.eventBus.publish(tracedEvent);
}
}
Alternative Patterns
1. Command Pattern
// Direct commands instead of events
interface RegisterUserCommand {
type: 'register.user';
data: {
email: string;
password: string;
metadata: Record<string, any>;
};
}
class CommandBus {
async dispatch<T>(command: Command<T>): Promise<void> {
const handler = this.resolveHandler(command.type);
await handler.execute(command.data);
}
}
2. Process Manager
class RegistrationProcessManager {
private state: ProcessState = 'initial';
async handle(event: DomainEvent): Promise<void> {
switch (this.state) {
case 'initial':
if (event.type === 'user.created') {
await this.sendWelcomeEmail(event);
this.state = 'email_sent';
}
break;
case 'email_sent':
if (event.type === 'email.delivered') {
await this.setupUserDefaults(event);
this.state = 'completed';
}
break;
}
}
}
Conclusion
Instead of event chains:
- Use explicit orchestration when possible
- Implement proper saga patterns for complex flows
- Maintain clear event schemas and versioning
- Ensure proper tracing and debugging capabilities
- Consider alternative patterns like commands or process managers
Remember: Just because you can trigger events doesn't mean you should.