Event Bus Architecture¶
Meister Bill uses a type-safe event bus pattern for decoupled component communication across frontend and backend.
Overview¶
βββββββββββββββββββ
β Component β
ββββββββββ¬βββββββββ
β emit(EVENT_PAYMENT_BOOKED)
βΌ
βββββββββββββββββββ
β Event Bus β
ββββββββββ¬βββββββββ
ββββββββββββ¬βββββββββββ¬βββββββββββ
βΌ βΌ βΌ βΌ
ββββββββ ββββββββ ββββββββ ββββββββ
βToast β βDiscordβ βWebSocketβ βAuditβ
ββββββββ ββββββββ ββββββββ ββββββββ
Generic Event System (v2.0)¶
Since Issue #188, all events use a consistent structure:
| Field | Type | Description |
|---|---|---|
type |
create\|update\|delete\|notify |
Event action |
entity |
user\|customer\|invoice\|product\|... |
Affected entity |
entityId |
string |
UUID of affected entity |
actor |
string |
UUID of user who triggered |
details |
object |
Event-specific data |
metadata |
object? |
IP, user agent, session ID |
Usage¶
Frontend: Emitting Events¶
import { useEventBus } from "~/composables/useEventBus";
import { EVENT_PAYMENT_BOOKED } from "@meisterbill/schemas";
const eventBus = useEventBus();
eventBus.emit(EVENT_PAYMENT_BOOKED, {
data: {
invoiceId: invoice.id,
amount: 100.00,
currency: "EUR",
invoiceNumber: "INV-2026-001"
}
});
Frontend: Creating Subscribers¶
// apps/web/subscribers/slackSubscriber.ts
export const slackSubscriber = () => {
const eventBus = useEventBus();
const config = useRuntimeConfig();
if (!config.public.slackWebhookUrl) return;
eventBus.on(EVENT_PAYMENT_BOOKED, async (event) => {
await fetch(config.public.slackWebhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `π° Payment: ${event.data.amount} ${event.data.currency}`
})
});
});
};
Enable in apps/web/plugins/05.EventBusSubscribers.ts.
Backend: Emitting Events¶
import { emitEvent } from "../services/EventBus";
emitEvent("create", "invoice", invoice.id, userId, {
document_number: invoice.document_number,
amount: invoice.total
});
The AuditSubscriber automatically logs all events to the database.
Available Events¶
| Event | Data |
|---|---|
payment.booked |
invoiceId, amount, currency, invoiceNumber |
payment.failed |
invoiceId, amount, error |
invoice.created |
invoiceId, invoiceNumber, customerName |
invoice.updated |
invoiceId, invoiceNumber |
invoice.deleted |
invoiceId, invoiceNumber |
customer.created |
customerId, customerName |
auth.login.success |
email |
auth.register.failed |
email, error |
Configuration¶
| Subscriber | Environment Variable |
|---|---|
| Discord | NUXT_PUBLIC_DISCORD_WEBHOOK_URL |
| WebSocket | NUXT_PUBLIC_WEBSOCKET_URL |
Testing¶
// Mock event bus in tests
const mockEmit = vi.fn();
vi.mock("~/composables/useEventBus", () => ({
useEventBus: () => ({ emit: mockEmit, on: vi.fn(), off: vi.fn() })
}));
expect(mockEmit).toHaveBeenCalledWith(EVENT_PAYMENT_BOOKED, {
data: expect.objectContaining({ amount: 100 })
});
Best Practices¶
- Use constants: Import event names from
@meisterbill/schemas - Keep data minimal: Only include necessary information
- Async handlers: Don't block on event handlers
- Naming: Use
resource.actionpattern - Debug: Use
console.debugfor visibility
Migration Example¶
Before:
const { add: addToast } = useToast();
try {
await bookPayment(data);
addToast({ title: "Success", color: "success" });
} catch (error) {
addToast({ title: "Failed", color: "error" });
}
After:
const eventBus = useEventBus();
try {
await bookPayment(data);
eventBus.emit(EVENT_PAYMENT_BOOKED, { data: { ... } });
} catch (error) {
eventBus.emit(EVENT_PAYMENT_FAILED, { data: { error: error.message } });
}
Files¶
schemas/src/EventSchema.ts- Event definitionsapps/web/composables/useEventBus.ts- Frontend event busapps/api/src/services/EventBus.ts- Backend event busapps/web/plugins/05.EventBusSubscribers.ts- Subscriber initialization
See Testing Guide for testing strategies.