Event Bus
Type-safe event-driven architecture with publish/subscribe patterns
The @justscale/event package provides a type-safe event bus implementation that enables clean event-driven architecture in your JustScale applications. Define events with Zod schemas and get full type safety for both publishers and subscribers.
Installation
npm install @justscale/eventBasic Concepts
The event bus pattern allows different parts of your application to communicate without tight coupling. Publishers emit events, and subscribers listen for specific event types, all with full TypeScript type safety.
Creating an Event Bus
Define your event schemas using Zod:
import { createEventBus } from '@justscale/event';
import { z } from 'zod';
export const AppEvents = createEventBus({
'user.created': z.object({
userId: z.string(),
email: z.string(),
createdAt: z.date(),
}),
'user.deleted': z.object({
userId: z.string(),
}),
'order.placed': z.object({
orderId: z.string(),
userId: z.string(),
total: z.number(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number(),
})),
}),
'order.cancelled': z.object({
orderId: z.string(),
reason: z.string(),
}),
});Emitting Events
Inject the event bus into services or controllers to emit events:
import { createService } from '@justscale/core';
import { AppEvents } from '../events';
const db = { users: { insert: async (u: any) => ({ id: '1', ...u }), delete: async (id: string) => {} } };
export const UserService = createService({
inject: { events: AppEvents },
factory: ({ events }) => ({
async createUser(email: string, password: string) {
// Create user in database
const user = await db.users.insert({ email, password });
// Emit typed event
await events.emit('user.created', {
userId: user.id,
email: user.email,
createdAt: new Date(),
});
return user;
},
async deleteUser(userId: string) {
await db.users.delete(userId);
// Type-safe event emission
await events.emit('user.deleted', { userId });
},
}),
});Info
emit method is fully type-safe. TypeScript will enforce that you provide the correct payload shape for each event type.Listening to Events
Use the .on() route builder to create event handlers in controllers:
import { createController } from '@justscale/core';
import { AppEvents } from '../events';
import { MailerService } from '../services/mailer';
export const NotificationController = createController({
inject: { mailer: MailerService },
routes: (services) => ({
// Handle user.created events
onUserCreated: AppEvents.on('user.created')
.handle(({ payload }) => {
// payload is typed as { userId: string; email: string; createdAt: Date }
services.mailer.sendWelcomeEmail(payload.email);
console.log('Welcome email sent to ' + payload.email);
}),
// Handle user.deleted events
onUserDeleted: AppEvents.on('user.deleted')
.handle(({ payload }) => {
// payload is typed as { userId: string }
console.log('User ' + payload.userId + ' deleted');
}),
}),
});Wildcard Patterns
Listen to multiple related events using wildcard patterns:
export const AuditController = createController({
routes: () => ({
// Listen to all user events
onAnyUserEvent: AppEvents.on('user.*')
.handle(({ eventName, payload }) => {
// eventName: 'user.created' | 'user.deleted'
// payload: union of all matching event payloads
console.log('User event: ' + eventName, payload);
// Store in audit log
auditLog.record(eventName, payload);
}),
// Listen to all events
onAnyEvent: AppEvents.on('*')
.handle(({ eventName, payload }) => {
// Receives all events from the bus
console.log('Event: ' + eventName);
}),
// Listen to multiple specific patterns
onOrderEvent: AppEvents.on('order.*')
.handle(({ eventName, payload }) => {
// Handles 'order.placed' and 'order.cancelled'
if (eventName === 'order.placed') {
// payload is narrowed to order.placed payload
}
}),
}),
});Middleware and Guards
Event handlers support middleware and guards just like HTTP routes:
import { createMiddleware } from '@justscale/core';
const LoggingMiddleware = createMiddleware(({ next }) => {
console.log('Event handler starting');
const result = next();
console.log('Event handler completed');
return result;
});
export const EventController = createController({
routes: () => ({
onUserCreated: AppEvents.on('user.created')
.use(LoggingMiddleware)
.handle(({ payload }) => {
// Handler logic with logging
}),
}),
});Model Events
For entity-based events, use createModelEvents to automatically generate CRUD event schemas:
import { createModelEvents } from '@justscale/event';
import { User } from './models/user';
export const UserEvents = createModelEvents({
model: User,
events: {
created: true,
updated: true,
deleted: true,
},
});
// Automatically creates:
// - 'User.created' event with { entity: User, id: string }
// - 'User.updated' event with { entity: User, id: string, changes: Partial<User> }
// - 'User.deleted' event with { id: string }Emit model events from repositories:
export const UserRepository = createRepository(UserModel, {
inject: { events: UserEvents },
factory: ({ events }) => ({
async save(user: User) {
const isNew = !user.id;
const saved = await db.users.save(user);
if (isNew) {
await events.emit('created', {
entity: saved,
id: saved.id,
});
} else {
await events.emit('updated', {
entity: saved,
id: saved.id,
changes: user,
});
}
return saved;
},
async delete(id: string) {
await db.users.delete(id);
await events.emit('deleted', { id });
},
}),
});Event Subscriptions
Use the Subscribe route builder for long-lived subscriptions:
import { Subscribe } from '@justscale/event';
import { AppEvents } from './events';
export const SubscriptionController = createController({
routes: () => ({
// Subscribe returns an async iterator
userEvents: Subscribe(AppEvents, 'user.*', async function* ({ eventName, payload }) {
// This generator receives all matching events
yield { type: eventName, data: payload };
}),
}),
});Event Context
Event handlers receive a rich context object:
AppEvents.on('user.created').handle(({
eventName, // 'user.created'
payload, // { userId: string, email: string, createdAt: Date }
metadata, // Optional metadata passed during emit
timestamp, // When the event was emitted
// Plus any injected dependencies
}) => {
// Handler logic
});Emit Options
Pass options when emitting events:
await events.emit('user.created', payload, {
// Add custom metadata
metadata: {
source: 'admin-panel',
requestId: req.id,
},
// Wait for all handlers to complete
await: true,
// Timeout for handlers
timeout: 5000,
});Error Handling
Handle errors in event handlers gracefully:
AppEvents.on('order.placed').handle(async ({ payload }) => {
try {
await processOrder(payload.orderId);
} catch (error) {
console.error('Failed to process order:', error);
// Emit compensation event
await events.emit('order.failed', {
orderId: payload.orderId,
error: error.message,
});
}
});Warning
await: true.Testing
Mock event buses in tests:
import { createTestSession } from '@justscale/testing';
import { AppEvents } from './events';
test('user creation emits event', async () => {
const session = createTestSession({
services: [UserService],
});
const events: any[] = [];
const mockEvents = {
emit: vi.fn(async (name, payload) => {
events.push({ name, payload });
}),
};
const userService = session.get(UserService, {
events: mockEvents,
});
await userService.createUser('test@example.com', 'password');
expect(mockEvents.emit).toHaveBeenCalledWith('user.created', {
userId: expect.any(String),
email: 'test@example.com',
createdAt: expect.any(Date),
});
});Use Cases
- Decoupled notifications - Send emails/SMS without coupling to business logic
- Audit logging - Track all system events in one place
- Cache invalidation - Clear caches when data changes
- Webhooks - Trigger external API calls on events
- Analytics - Track user actions and system behavior
- Saga patterns - Orchestrate complex workflows
Best Practices
- Keep events focused - One event should represent one thing happening
- Use past tense - Events represent things that have happened
- Include context - Add enough data for handlers to act
- Don't return values - Events are fire-and-forget (unless using await: true)
- Handle errors - Event handlers should be resilient
- Avoid cycles - Don't emit events in response to the same event