Event Bus Architecture¶
Overview¶
Meister Bill implements a type-safe event bus pattern for decoupled component communication across both frontend (web) and backend (API). This architecture enables flexible integration with multiple notification channels (toasts, Discord, WebSockets, etc.) and automatic audit logging without tight coupling between components.
New Generic Event System (v2.0) ✨¶
As of Issue #188, Meister Bill has transitioned to a generic event system that provides a unified approach to event handling across all entities:
- Event Types:
create,update,delete,notify - Entities:
user,customer,invoice,product,payment,settings,offer,project,address,document - Structure: All events follow a consistent structure with
type,entity,entityId,actor,details, and optionalmetadata - Audit Trail: Events automatically generate audit logs via the
AuditSubscriber
Benefits of the Generic System¶
- Consistency: Same event structure across all entities
- Scalability: Easy to add new entities without new event types
- Decoupled Auditing: Audit logic is now a subscriber, not embedded in controllers
- Type Safety: Full TypeScript support with Zod validation
- Flexibility: Metadata field for additional context (IP, user agent, session ID, etc.)
- Backend Support: Event bus now works on both frontend and backend
Generic Event Structure¶
{
id?: string; // Optional UUID, auto-generated
type: 'create' | 'update' | 'delete' | 'notify';
entity: 'user' | 'customer' | 'invoice' | ...;
entityId: string; // UUID of affected entity
actor: string; // UUID of user who triggered the event
details: Record<string, any>; // Event-specific details
timestamp: Date; // Auto-generated
metadata?: { // Optional metadata
ip?: string;
userAgent?: string;
sessionId?: string;
}
}
Architecture Diagram¶
┌─────────────────┐
│ Component │
│ (Invoice List) │
└────────┬────────┘
│ emit(EVENT_PAYMENT_BOOKED, data)
▼
┌─────────────────┐
│ Event Bus │
│ (useEventBus) │
└────────┬────────┘
│
├──────────────┬──────────────┬──────────────┐
▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Toast │ │ Discord │ │ WebSocket │ │ Custom │
│ Subscriber │ │ Subscriber │ │ Subscriber │ │ Subscriber │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
Key Benefits¶
- Decoupling: Components don't need to know about notification mechanisms
- Extensibility: Add new subscribers without modifying existing code
- Type Safety: Full TypeScript support with Zod schema validation
- Testability: Easy to mock event bus for unit tests
- Centralized Logic: All notification logic in one place
- Multiple Channels: One event can trigger multiple notifications
Core Components¶
1. Event Schema (packages/schemas/src/EventSchema.ts)¶
Defines all application events with full type safety using Zod:
// Event type constants
export const EVENT_PAYMENT_BOOKED = "payment.booked";
export const EVENT_INVOICE_CREATED = "invoice.created";
// ... more events
// Typed event schemas
export const PaymentBookedEventSchema = BaseEventSchema.extend({
type: z.literal(EVENT_PAYMENT_BOOKED),
data: z.object({
invoiceId: z.string().uuid(),
amount: z.number(),
currency: z.string(),
invoiceNumber: z.string().optional(),
}),
});
// Union of all events for type discrimination
export const AppEventSchema = z.discriminatedUnion("type", [
PaymentBookedEventSchema,
InvoiceCreatedEventSchema,
// ... more events
]);
2. Event Bus (apps/web/composables/useEventBus.ts)¶
Singleton event emitter using mitt library:
export const useEventBus = () => {
const emit = <T extends AppEvent["type"]>(
type: T,
event: Omit<Extract<AppEvent, { type: T }>, "type">
) => {
// Emits type-safe events
};
const on = <T extends AppEvent["type"]>(
type: T,
handler: (event: Extract<AppEvent, { type: T }>) => void
) => {
// Subscribe to events with type safety
};
return { emit, on, off, all };
};
3. Subscribers Plugin (apps/web/plugins/05.EventBusSubscribers.ts)¶
Initializes all subscribers on app startup:
- Toast Subscriber (always active): Shows UI notifications
- Discord Subscriber (optional): Posts to Discord webhook
- WebSocket Subscriber (optional): Broadcasts to WebSocket clients
Available Events¶
Payment Events¶
payment.booked- Payment successfully booked- Data:
invoiceId,amount,currency,invoiceNumber payment.failed- Payment booking failed- Data:
invoiceId,amount,error
Invoice Events¶
invoice.created- New invoice created- Data:
invoiceId,invoiceNumber,customerName invoice.updated- Invoice updated- Data:
invoiceId,invoiceNumber invoice.deleted- Invoice deleted- Data:
invoiceId,invoiceNumber invoice.cloned- Invoice cloned- Data:
originalInvoiceId,newInvoiceId,newInvoiceNumber
Customer Events¶
customer.created- New customer created- Data:
customerId,customerName customer.updated- Customer updated- Data:
customerId,customerName customer.deleted- Customer deleted- Data:
customerId,customerName
Authentication Events¶
auth.login.success- User logged in- Data:
email auth.login.failed- Login failed- Data:
email,error auth.logout- User logged out- Data:
email(optional) auth.register.success- User registered- Data:
email auth.register.failed- Registration failed- Data:
email,error
Usage Examples¶
Emitting Events¶
// In any component or composable
import { useEventBus } from "~/composables/useEventBus";
import { EVENT_PAYMENT_BOOKED } from "@meisterbill/schemas";
const eventBus = useEventBus();
// Emit an event
eventBus.emit(EVENT_PAYMENT_BOOKED, {
data: {
invoiceId: invoice.id,
amount: 100.00,
currency: "EUR",
invoiceNumber: "INV-2025-001"
}
});
Creating a Custom Subscriber¶
Create a new file in apps/web/subscribers/:
// apps/web/subscribers/slackSubscriber.ts
import { EVENT_PAYMENT_BOOKED } from "@meisterbill/schemas";
export const slackSubscriber = () => {
const eventBus = useEventBus();
const config = useRuntimeConfig();
const webhookUrl = config.public.slackWebhookUrl;
if (!webhookUrl) {
console.debug("[SlackSubscriber] Skipped - No webhook configured");
return;
}
eventBus.on(EVENT_PAYMENT_BOOKED, async (event) => {
await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `💰 Payment received: ${event.data.amount} ${event.data.currency}`,
}),
});
});
};
Then enable it in apps/web/plugins/05.EventBusSubscribers.ts:
import { slackSubscriber } from "~/subscribers/slackSubscriber";
export default defineNuxtPlugin(() => {
toastSubscriber();
slackSubscriber(); // Add your subscriber
});
Subscribing to Events in Components¶
// In a Vue component
const eventBus = useEventBus();
onMounted(() => {
eventBus.on(EVENT_PAYMENT_BOOKED, (event) => {
console.log(`Payment received: ${event.data.amount}`);
// Update UI, refresh data, etc.
});
});
// Don't forget to clean up
onUnmounted(() => {
eventBus.off(EVENT_PAYMENT_BOOKED, handler);
});
Adding New Event Types¶
- Define the event in schema (
packages/schemas/src/EventSchema.ts):
export const EVENT_PRODUCT_CREATED = "product.created";
export const ProductCreatedEventSchema = BaseEventSchema.extend({
type: z.literal(EVENT_PRODUCT_CREATED),
data: z.object({
productId: z.string().uuid(),
productName: z.string(),
price: z.number(),
}),
});
// Add to union
export const AppEventSchema = z.discriminatedUnion("type", [
// ... existing events
ProductCreatedEventSchema,
]);
export type ProductCreatedEvent = z.infer<typeof ProductCreatedEventSchema>;
- Rebuild schemas package:
pnpm --filter @meisterbill/schemas build
- Emit the event in your component:
eventBus.emit(EVENT_PRODUCT_CREATED, {
data: {
productId: product.id,
productName: product.name,
price: product.price,
}
});
- Add subscriber handling (optional):
// In apps/web/plugins/05.EventBusSubscribers.ts
eventBus.on(EVENT_PRODUCT_CREATED, (event) => {
addToast({
title: t("message.product_created_successfully"),
description: event.data.productName,
color: "success",
});
});
Environment Configuration¶
Discord Subscriber¶
Set in nuxt.config.ts or .env:
NUXT_PUBLIC_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
WebSocket Subscriber¶
Set in nuxt.config.ts or .env:
NUXT_PUBLIC_WEBSOCKET_URL=wss://your-websocket-server.com
Best Practices¶
- Always use event constants: Import from
@meisterbill/schemas - Keep event data minimal: Only include necessary information
- Don't block on event handlers: Keep handlers fast and async
- Use descriptive event names: Follow pattern
resource.action - Version events if needed: Add
v2suffix for breaking changes - Log events in debug mode: Use
console.debugfor visibility - Test event emissions: Mock the event bus in unit tests
- Document event data: Keep this doc updated with new events
Testing¶
Mocking the Event Bus¶
// In a test file
import { describe, it, expect, vi } from "vitest";
describe("Invoice List", () => {
it("emits payment booked event", async () => {
const mockEmit = vi.fn();
vi.mock("~/composables/useEventBus", () => ({
useEventBus: () => ({
emit: mockEmit,
on: vi.fn(),
off: vi.fn(),
}),
}));
// ... test code
expect(mockEmit).toHaveBeenCalledWith(EVENT_PAYMENT_BOOKED, {
data: expect.objectContaining({
invoiceId: expect.any(String),
amount: 100,
}),
});
});
});
Migration Guide¶
Before (Direct Toast Calls)¶
const { add: addToast } = useToast();
try {
await bookPayment(data);
addToast({
title: "Payment booked",
color: "success",
});
} catch (error) {
addToast({
title: "Payment failed",
color: "error",
});
}
After (Event Bus)¶
const eventBus = useEventBus();
try {
await bookPayment(data);
eventBus.emit(EVENT_PAYMENT_BOOKED, {
data: { invoiceId, amount, currency, invoiceNumber },
});
} catch (error) {
eventBus.emit(EVENT_PAYMENT_FAILED, {
data: { invoiceId, amount, error: error.message },
});
}
Troubleshooting¶
Events not being received¶
- Check if the subscriber is initialized in the plugin
- Verify event type constant is correct
- Check console for debug logs
- Ensure schemas package is built
Type errors¶
- Rebuild schemas package:
pnpm --filter @meisterbill/schemas build - Check TypeScript version compatibility
- Verify event data matches schema
Performance issues¶
- Reduce number of subscribers
- Make handlers async and non-blocking
- Debounce high-frequency events
- Consider event batching for bulk operations
Backend Event Bus (NEW)¶
The backend now has its own event bus for server-side events:
Backend Architecture¶
┌─────────────────┐
│ Controller │
│ (InvoiceCtrl) │
└────────┬────────┘
│ emitEvent('create', 'invoice', ...)
▼
┌─────────────────┐
│ Backend │
│ Event Bus │
│ (EventBus.ts) │
└────────┬────────┘
│
├──────────────┬──────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Audit │ │ Email │ │ Future │
│ Subscriber │ │ Subscriber │ │ Subscribers │
└──────────────┘ └──────────────┘ └──────────────┘
Usage in Controllers¶
import { emitEvent } from "../services/EventBus";
// In your controller
emitEvent(
"create",
"invoice",
invoice.id,
userId,
{
document_number: invoice.document_number,
...invoice,
}
);
Audit Subscriber¶
The AuditSubscriber automatically logs all create, update, and delete events to the database:
- Initialized at app startup
- Calculates diffs for update events
- Stores events in
audittable - Non-blocking (errors don't break the app)
Adding Backend Subscribers¶
Create a new subscriber in apps/api/src/subscribers/:
// apps/api/src/subscribers/EmailNotificationSubscriber.ts
import { eventBus } from "../services/EventBus";
export const initializeEmailNotificationSubscriber = (): void => {
eventBus.onTypeAndEntity("create", "invoice", async (event) => {
// Send email notification
await sendInvoiceCreatedEmail(event.entityId);
});
console.debug("[EmailNotificationSubscriber] Initialized");
};
Then initialize it in apps/api/src/index.ts:
import { initializeAuditSubscriber } from "./subscribers/AuditSubscriber";
import { initializeEmailNotificationSubscriber } from "./subscribers/EmailNotificationSubscriber";
(async () => {
await SessionHandler.createSessionDatabaseIfNotExists();
initializeAuditSubscriber();
initializeEmailNotificationSubscriber();
})();
Database Migration¶
To use the new generic event system, run the migration:
-- Location: database/migrations/001_audit_table_refactor.sql
-- Adds: event_type, entity, entity_id, metadata columns
-- Keeps: old columns for backward compatibility
Future Enhancements¶
- [x] Event persistence to database (via AuditSubscriber)
- [x] Server-side event bus for API
- [ ] Event replay functionality
- [ ] Event filtering by user/tenant
- [ ] Event analytics and monitoring
- [ ] Event-driven state management
- [ ] Event versioning system
- [ ] GraphQL subscription support
- [ ] Real-time event streaming between backend and frontend