Event System
ExpressoTS v4 includes a powerful, type-safe event system for building decoupled, event-driven architectures. Unlike string-based event systems in other frameworks, ExpressoTS uses typed event classes for full TypeScript inference.
Overview
The event system provides:
- Type-Safe Events: Event classes instead of strings for full TypeScript inference
- Auto-Discovery: Event handlers discovered automatically via decorators
- Priority Execution: Multiple handlers with configurable execution order
- Conditional Handlers: Execute handlers based on conditions
- Event Replay: Built-in event recording and replay for debugging
- Flow Visualization: Track and visualize event propagation
Creating Events
Define events as TypeScript classes:
// events/user.events.ts
export class UserCreatedEvent {
constructor(
public readonly userId: string,
public readonly email: string,
public readonly timestamp: Date = new Date()
) {}
}
export class UserUpdatedEvent {
constructor(
public readonly userId: string,
public readonly changes: Record<string, any>
) {}
}
export class UserDeletedEvent {
constructor(public readonly userId: string) {}
}
Benefits of Class-Based Events
- Full TypeScript Inference: Auto-complete on event properties
- Compile-Time Safety: TypeScript catches errors at build time
- Self-Documenting: Event structure is clear from the class definition
- Validation: Add validation logic in constructors if needed
Creating Event Handlers
Event handlers are classes decorated with @OnEvent():
import { provide, OnEvent, IEventHandler } from "@expressots/core";
import { UserCreatedEvent } from "../events/user.events";
@provide(SendWelcomeEmailHandler)
@OnEvent(UserCreatedEvent)
export class SendWelcomeEmailHandler implements IEventHandler<UserCreatedEvent> {
constructor(@inject(EmailService) private emailService: EmailService) {}
async handle(event: UserCreatedEvent): Promise<void> {
// Full TypeScript inference on event payload!
await this.emailService.sendWelcome(event.email);
console.log(`Welcome email sent to ${event.email}`);
}
}
Key Concepts
- @provide(): Registers the handler in the DI container
- @OnEvent(): Associates the handler with an event type
- IEventHandler<T>: Interface ensuring type-safe event handling
Emitting Events
Inject the EventEmitter service to emit events:
import { provide, inject, EventEmitter } from "@expressots/core";
import { UserCreatedEvent } from "../events/user.events";
@provide(UserService)
export class UserService {
constructor(@inject(EventEmitter) private eventEmitter: EventEmitter) {}
async createUser(dto: CreateUserDto): Promise<User> {
const user = await this.userRepository.create(dto);
// Emit type-safe event
await this.eventEmitter.emit(
new UserCreatedEvent(user.id, user.email)
);
return user;
}
}
Type Safety
// ✅ Correct - TypeScript validates arguments
// ❌ Error - TypeScript catches type mismatch
// ^^^ Type 'number' not assignable to 'string'
Multiple Handlers with Priority
Register multiple handlers for the same event with execution priority:
@provide(SendWelcomeEmailHandler)
@OnEvent(UserCreatedEvent, { priority: 1 }) // Runs first
export class SendWelcomeEmailHandler implements IEventHandler<UserCreatedEvent> {
async handle(event: UserCreatedEvent) {
await this.emailService.sendWelcome(event.email);
}
}
@provide(CreateUserProfileHandler)
@OnEvent(UserCreatedEvent, { priority: 2 }) // Runs second
export class CreateUserProfileHandler implements IEventHandler<UserCreatedEvent> {
async handle(event: UserCreatedEvent) {
await this.profileService.create(event.userId);
}
}
@provide(LogAnalyticsHandler)
@OnEvent(UserCreatedEvent, { priority: 3 }) // Runs last
export class LogAnalyticsHandler implements IEventHandler<UserCreatedEvent> {
async handle(event: UserCreatedEvent) {
this.analytics.track("user_created", { userId: event.userId });
}
}
Priority Rules
- Lower numbers execute first
- Handlers without priority default to
0 - Handlers with the same priority execute in registration order
Conditional Handlers
Execute handlers only when specific conditions are met:
import { provide, OnEvent, When, IEventHandler } from "@expressots/core";
@provide(PremiumFeatureHandler)
@OnEvent(UserCreatedEvent)
@When((event: UserCreatedEvent) => event.isPremium)
export class PremiumFeatureHandler implements IEventHandler<UserCreatedEvent> {
async handle(event: UserCreatedEvent) {
// Only executed for premium users
await this.premiumService.activateFeatures(event.userId);
}
}
@provide(RegionalNotificationHandler)
@OnEvent(UserCreatedEvent)
@When((event: UserCreatedEvent) => event.region === "EU")
export class RegionalNotificationHandler implements IEventHandler<UserCreatedEvent> {
async handle(event: UserCreatedEvent) {
// Only for EU users
await this.gdprService.sendConsent(event.email);
}
}
Async Conditions
Conditions can be asynchronous:
@provide(SpecialOfferHandler)
@OnEvent(UserCreatedEvent)
@When(async (event: UserCreatedEvent) => {
const user = await userRepository.findById(event.userId);
return user.signupSource === "referral";
})
export class SpecialOfferHandler implements IEventHandler<UserCreatedEvent> {
async handle(event: UserCreatedEvent) {
await this.offerService.sendReferralBonus(event.userId);
}
}
Event Aggregation
Handle multiple event types with a single handler:
import { provide, OnEvents, IEventHandler } from "@expressots/core";
import { UserCreatedEvent, UserUpdatedEvent, UserDeletedEvent } from "../events/user.events";
@provide(UserAuditHandler)
@OnEvents([UserCreatedEvent, UserUpdatedEvent, UserDeletedEvent])
export class UserAuditHandler implements IEventHandler<UserCreatedEvent | UserUpdatedEvent | UserDeletedEvent> {
async handle(event: UserCreatedEvent | UserUpdatedEvent | UserDeletedEvent) {
// Handle any user-related event
const eventType = event.constructor.name;
await this.auditService.log({
type: eventType,
userId: event.userId,
timestamp: new Date(),
});
}
}
Async Event Handlers
Handle events asynchronously with error handling:
@provide(AsyncNotificationHandler)
@OnEvent(UserCreatedEvent, { async: true })
export class AsyncNotificationHandler implements IEventHandler<UserCreatedEvent> {
async handle(event: UserCreatedEvent) {
// Runs asynchronously - doesn't block the emitter
await this.notificationService.sendPushNotification(event.userId);
}
}
Retry Logic
Configure retry behavior for failed handlers:
@provide(RetryableHandler)
@OnEvent(UserCreatedEvent, {
async: true,
retry: {
maxRetries: 3,
backoff: "exponential",
initialDelay: 1000,
}
})
export class RetryableHandler implements IEventHandler<UserCreatedEvent> {
async handle(event: UserCreatedEvent) {
// Automatically retries on failure
await this.externalService.sync(event);
}
}
Event Recording & Replay
Record events for debugging and replay:
import { EventRecorder } from "@expressots/core";
@provide(DebugController)
@controller("/debug")
export class DebugController {
constructor(@inject(EventRecorder) private recorder: EventRecorder) {}
@Get("/events")
getRecordedEvents() {
return this.recorder.getEvents();
}
@Get("/events/replay")
replayEvents(@query("from") fromTimestamp: number) {
return this.recorder.replay({
fromTimestamp,
});
}
@Get("/events/export")
exportEvents(@query("format") format: "json" | "csv" | "timeline") {
return this.recorder.export(format);
}
}
Replay Options
// Replay last 5 minutes of events
recorder.replay({
fromTimestamp: Date.now() - 5 * 60 * 1000,
});
// Replay specific event types
recorder.replay({
eventTypes: [UserCreatedEvent, UserUpdatedEvent],
});
// Replay with filter
recorder.replay({
filter: (event) => event.userId === "specific-user-id",
});
Export Formats
// JSON format
const json = recorder.export("json");
// CSV format
const csv = recorder.export("csv");
// Visual timeline
const timeline = recorder.export("timeline");
// Outputs ASCII art visualization of events
Event Flow Visualization
Track event propagation through your application:
import { EventFlowTracker } from "@expressots/core";
@provide(DiagnosticsController)
@controller("/diagnostics")
export class DiagnosticsController {
constructor(@inject(EventFlowTracker) private flowTracker: EventFlowTracker) {}
@Get("/flow/:requestId")
getEventFlow(@param("requestId") requestId: string) {
return this.flowTracker.getFlow(requestId);
}
@Get("/flow/:requestId/visualize")
visualizeFlow(@param("requestId") requestId: string) {
return this.flowTracker.visualize(requestId);
// Returns ASCII art visualization:
// UserCreatedEvent
// ├── SendWelcomeEmailHandler (12ms)
// ├── CreateUserProfileHandler (8ms)
// └── LogAnalyticsHandler (2ms)
}
}
Setup
Enable the event system in your application:
import { AppExpress } from "@expressots/adapter-express";
import { EventEmitter, EventRecorder, EventFlowTracker } from "@expressots/core";
export class App extends AppExpress {
async configureServices(): Promise<void> {
// Register event system services
this.Provider.register(EventEmitter);
// Optional: Enable event recording (development only)
if (await this.isDevelopment()) {
this.Provider.register(EventRecorder);
this.Provider.register(EventFlowTracker);
}
}
}
Testing Events
Testing Event Emission
Test that events are emitted correctly:
import { EventEmitter } from "@expressots/core";
import { UserService } from "./user.service";
import { UserCreatedEvent } from "../events/user.events";
describe("UserService", () => {
let service: UserService;
let mockEventEmitter: jest.Mocked<EventEmitter>;
beforeEach(() => {
mockEventEmitter = {
emit: jest.fn(),
} as any;
service = new UserService(mockEventEmitter, mockUserRepository);
});
it("should emit UserCreatedEvent when user is created", async () => {
await service.createUser(dto);
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
expect.any(UserCreatedEvent)
);
const emittedEvent = mockEventEmitter.emit.mock.calls[0][0];
expect(emittedEvent.email).toBe(dto.email);
});
});
Testing Event Handlers
Test handlers in isolation:
import { SendWelcomeEmailHandler } from "./send-welcome-email.handler";
import { UserCreatedEvent } from "../events/user.events";
describe("SendWelcomeEmailHandler", () => {
let handler: SendWelcomeEmailHandler;
let mockEmailService: jest.Mocked<EmailService>;
beforeEach(() => {
mockEmailService = {
sendWelcome: jest.fn().mockResolvedValue(true),
} as any;
handler = new SendWelcomeEmailHandler(mockEmailService);
});
it("should send welcome email when user is created", async () => {
await handler.handle(event);
expect(mockEmailService.sendWelcome).toHaveBeenCalledWith(
);
});
it("should handle email service errors", async () => {
mockEmailService.sendWelcome.mockRejectedValue(
new Error("Email service down")
);
await expect(handler.handle(event)).rejects.toThrow(
"Email service down"
);
});
});
Testing Multiple Handlers
Test that all handlers execute in priority order:
import { bootstrap } from "@expressots/core";
import { App } from "./app";
describe("Event Handler Priority", () => {
let app: any;
let executionLog: string[] = [];
beforeAll(async () => {
// Spy on handlers to track execution order
jest.spyOn(SendWelcomeEmailHandler.prototype, "handle")
.mockImplementation(async (event) => {
executionLog.push("email");
});
jest.spyOn(CreateUserProfileHandler.prototype, "handle")
.mockImplementation(async (event) => {
executionLog.push("profile");
});
jest.spyOn(LogAnalyticsHandler.prototype, "handle")
.mockImplementation(async (event) => {
executionLog.push("analytics");
});
app = await bootstrap(App, { port: 0 });
});
afterAll(async () => {
await app.close();
});
it("should execute handlers in priority order", async () => {
executionLog = [];
const response = await fetch(`http://localhost:${app.port}/users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
expect(response.ok).toBe(true);
expect(executionLog).toEqual(["email", "profile", "analytics"]);
});
});
Testing Conditional Handlers
Test @When() conditions:
import { PremiumFeatureHandler } from "./premium-feature.handler";
import { UserCreatedEvent } from "../events/user.events";
describe("PremiumFeatureHandler", () => {
let handler: PremiumFeatureHandler;
let mockPremiumService: jest.Mocked<PremiumService>;
beforeEach(() => {
mockPremiumService = {
activateFeatures: jest.fn().mockResolvedValue(true),
} as any;
handler = new PremiumFeatureHandler(mockPremiumService);
});
it("should activate features for premium users", async () => {
await handler.handle(event);
expect(mockPremiumService.activateFeatures).toHaveBeenCalledWith("123");
});
it("should not activate features for non-premium users", async () => {
// Handler should not be called due to @When() condition
// This would be tested via E2E tests
});
});
E2E Event Testing
Test events in a real application:
import { bootstrap } from "@expressots/core";
import { App } from "./app";
describe("User Events E2E", () => {
let app: any;
beforeAll(async () => {
app = await bootstrap(App, { port: 0 });
});
afterAll(async () => {
await app.close();
});
it("should trigger all event handlers when user is created", async () => {
const response = await fetch(`http://localhost:${app.port}/users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Test User",
}),
});
expect(response.ok).toBe(true);
// Verify side effects
await new Promise(resolve => setTimeout(resolve, 100)); // Wait for async handlers
// Check that email was sent (via test email service)
const sentEmails = await testEmailService.getSentEmails();
expect(sentEmails).toContainEqual(
);
// Check that profile was created
const profile = await profileService.findByUserId("123");
expect(profile).toBeDefined();
// Check that analytics was logged
const events = await analyticsService.getEvents();
expect(events).toContainEqual(
expect.objectContaining({ type: "user_created" })
);
});
});
Best Practices
- Use Descriptive Event Names:
UserCreatedEventis better thanEvent1 - Keep Events Immutable: Use
readonlyproperties - Single Responsibility: Each handler should do one thing
- Use Priority Wisely: Reserve low numbers for critical handlers
- Handle Errors: Wrap handler logic in try/catch
- Test Events: Test event emission and handling separately
- Test Priority Order: Verify handlers execute in the expected sequence
- Test Conditions: Verify conditional handlers execute only when appropriate
Performance Tips
- Use Async Handlers: Enable
async: truefor non-critical handlers to avoid blocking - Minimize Handler Count: Too many handlers per event can slow down requests
- Cache Expensive Operations: Use caching in handlers that make expensive calls
- Use Priority Effectively: Put fast, critical handlers first
- Avoid Synchronous Operations: Never block the event loop in handlers
- Monitor Handler Performance: Track execution time of handlers in production
Comparison with Other Frameworks
| Feature | ExpressoTS | NestJS | Laravel |
|---|---|---|---|
| Type-Safe Events | ✅ Event classes | ❌ String-based | ❌ String-based |
| Auto-Discovery | ✅ Via @OnEvent() | ⚠️ Manual registration | ⚠️ Manual registration |
| Priority Execution | ✅ Built-in | ⚠️ Manual ordering | ⚠️ Manual ordering |
| Conditional Handlers | ✅ @When() decorator | ❌ Manual | ❌ Manual |
| Event Replay | ✅ Built-in | ❌ Not available | ❌ Not available |
| Flow Visualization | ✅ Built-in | ❌ Not available | ❌ Not available |
Support the Project
ExpressoTS is MIT-licensed open source. See the support guide to contribute.