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

Bash
npm install @justscale/event

Basic 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:

src/events.tsTypeScript
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:

src/services/user-service.tsTypeScript
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

The 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:

src/controllers/notification.tsTypeScript
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:

audit-controller.tsTypeScript
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:

event-controller.tsTypeScript
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:

user-events.tsTypeScript
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:

user-repository.tsTypeScript
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:

subscription-controller.tsTypeScript
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:

event-context.tsTypeScript
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:

emit-options.tsTypeScript
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:

error-handling.tsTypeScript
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

Event handlers run asynchronously by default. If a handler throws, it won't affect other handlers or the emitter unless you use await: true.

Testing

Mock event buses in tests:

event.test.tsTypeScript
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