Use mutation events to run cross-resource side effects and integrate external systems — without coupling resources to each other.
Events exist to decouple. A resource owns its writes; anything that should happen as a consequence of a write lives in a handler that subscribes to the resource’s mutation events. The resource doing the write never imports the code that reacts to it — so features stay independent and easy for you, and your agents, to add or remove.Three patterns cover almost everything you’ll do with events.
When a change in one resource needs to update another, subscribe instead of reaching in. Say you keep a notesCount on each user so a profile page doesn’t have to aggregate. Rather than incrementing the counter inside every notes endpoint, the users resource owns the rule and listens to notes events:
The notes endpoints stay thin — they insert a note and return. The “keep the count fresh” rule lives with the resource that owns the count. Delete the feature later? Remove the handler file and re-run codegen. Nothing else changes.
Soft deletes flow through notes.update, since deleting a note sets deletedAt. Filtering deletedAt: null in the count keeps a deleted note out of the total. Wire a notes.delete handler too only if you hard-delete.
Want to keep a record of every change to a resource without threading audit logic through its endpoints? Let the audit resource subscribe. The users resource has no idea it’s being audited:
// resources/audit-logs/handlers/track-user-changes.tsimport db from '@/db';import { eventBus } from '@/event-bus';import logger from '@/logger';eventBus.on('users.update', async ({ docs, prevDocs }) => { try { for (const [i, user] of docs.entries()) { const before = prevDocs?.[i]; if (before && before.email !== user.email) { await db.auditLogs.insertOne({ userId: user.id, action: 'email_changed', from: before.email, to: user.email, }); } } } catch (err) { logger.error(`users.update audit handler error: ${err}`); }});
prevDocs gives you the before-and-after for free — no diff library, no change payload to assemble. This is the inverse of importing a historyService into the users endpoints: the dependency points into the audit resource, not out of users.
Events are the natural place to fan out to anything outside your database — analytics, sockets, email, webhooks. Ship ships two of these in the base template.
Analytics
users/handlers/sync-analytics.ts subscribes to users.insert and tracks a “New user created” event through analyticsService.
Realtime sockets
users/handlers/to-sockets.ts subscribes to users.update and pushes the updated row to that user’s browser over Socket.IO via ioEmitter.publishToUser.
The same shape covers webhooks — subscribe to a resource’s events and POST the payload out, turning your database mutations into real-time triggers for downstream systems:
Not every consequence belongs in a handler. A quick rule:
Use an event when the side effect belongs to a different resource, is best-effort, or fans out to an external system — analytics, sockets, audit logs, denormalised counts, webhooks.
Use a method or do it inline when the work is part of the same transaction or is required for the write to be correct (e.g. validating a foreign key). Events fire after the write; they’re reactions, not preconditions.
Reading a resource’s handlers/ folders tells you everything that happens as a consequence of its writes — one place, no hidden coupling. That’s the payoff: every meaningful change flows through the same typed channel, and the structure is the documentation.