Event-Driven Communication Pattern
Overview
This document explains the event-driven architecture pattern for sending communications automatically when business events occur in the FlowPOS system.
Architecture Pattern
Loose Coupling via Event Emitter
┌─────────────────┐ ┌──────────────────┐ ┌────────────────────────┐
│ Domain Module │ │ Event Emitter │ │ Communications Module │
│ (e.g., Invites) │ ──────▶ │ (NestJS) │ ──────▶ │ (Event Handler) │
└─────────────────┘ └──────────────────┘ └────────────────────────┘
│ │ │
│ │ │
1. Create Invite 2. Emit Event 3. Send Email
2. Emit Event 3. Route to listeners 4. Queue for sending
Benefits:
- ✅ Decoupled - Modules don't directly depend on each other
- ✅ Single Responsibility - Each module handles its domain
- ✅ Testable - Can test invite creation without email system
- ✅ Extensible - Easy to add more listeners (SMS, push notifications)
- ✅ Async - Email sending doesn't block invite creation
- ✅ Resilient - Email failures don't affect invite creation
Implementation Example: Invite Emails
Step 1: Domain Module Emits Event
The InvitesService creates the invite and emits an event:
// apps/backend/src/invites/application/invites.service.ts
async createInvite(invite: InsertableInvite): Promise<SelectableInvite> {
const newInvite = await this.database.transaction().execute(async (trx) => {
return this.invitesRepository.create(invite, trx);
});
// Emit event after successful creation
this.eventEmitter.emit(
OnCreateInviteEvent.eventName,
new OnCreateInviteEvent(newInvite),
);
return newInvite;
}
Step 2: Event Class
Define the event structure:
// apps/backend/src/invites/application/events/on-create-invite.event.ts
export class OnCreateInviteEvent {
public static eventName = "invite.create";
invite: SelectableInvite;
constructor(invite: SelectableInvite) {
this.invite = invite;
}
}
Step 3: Communications Module Listens
The communications module has an event handler that listens for the event:
// apps/backend/src/communications/application/events/on-create-invite.handler.ts
@Injectable()
export class OnCreateInviteHandler {
constructor(private readonly communicationsService: CommunicationsService) {}
@OnEvent(OnCreateInviteEvent.eventName, { async: true })
async handleOnCreateInvite(event: OnCreateInviteEvent): Promise<void> {
const { invite } = event;
// Send email via communication system
await this.communicationsService.send({
businessId: invite.businessId,
createdBy: invite.createdBy || "system",
channel: "email",
type: "general_notification",
recipientType: "invite",
recipientId: invite.id,
recipientContact: invite.email,
subject: "You're invited to join now!!!",
content: this.buildInviteEmailContent(invite),
entityType: "invite",
entityId: invite.id,
priority: "high",
});
}
}
Step 4: Register Handler in Module
Register the handler so NestJS knows about it:
// apps/backend/src/communications/communications.module.ts
@Module({
// ...
providers: [
CommunicationsService,
// ...
// Event Handlers
OnCreateInviteHandler, // ← Add your handlers here
],
})
export class CommunicationsModule {}
Flow Diagram
User creates invite via API
│
▼
┌────────────────────────┐
│ InvitesController │
│ POST /invites │
└────────┬───────────────┘
│
▼
┌────────────────────────┐
│ InvitesService │
│ createInvite() │
│ - Save to database │
│ - Emit event ──────────┼──────────┐
└────────────────────────┘ │
│ │
│ return invite │
▼ │
200 OK to client │
│
┌──────────────▼─────────────────┐
│ Event Emitter │
│ "invite.create" │
└──────────────┬─────────────────┘
│
▼
┌──────────────────────────────────┐
│ OnCreateInviteHandler │
│ @OnEvent("invite.create") │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ CommunicationsService │
│ send() │
│ - Render template │
│ - Create communication record │
│ - Enqueue for sending ─────────┐ │
└────────────────────────────────┘ │
│
▼
┌──────────────────────┐
│ CommunicationQueue │
│ Queued for sending │
└──────────┬───────────┘
│
(30 seconds later)
│
▼
┌──────────────────────┐
│ Queue Processor │
│ @Cron(EVERY_30_SEC) │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ EmailAdapter │
│ Sendgrid API │
└──────────┬───────────┘
│
▼
📧 Email sent!
Best Practices
✅ DO:
- Keep handlers focused - One handler per event type
- Use async: true - Don't block the event emitter
- Log events - Track what's happening
- Handle errors gracefully - Don't throw, log and continue
- Use descriptive event names -
"invoice.created","payment.received" - Keep event payloads minimal - Only include IDs, fetch full data in handler
- Place handlers in the listening module - Not in the emitting module
❌ DON'T:
- Don't throw errors in handlers - This will break the event emitter
- Don't make handlers dependent on return values - They're fire-and-forget
- Don't use events for synchronous workflows - Use direct service calls instead
- Don't emit too many events - Only for significant domain events
- Don't put business logic in event classes - Keep them as DTOs
Example Use Cases
Phase 4 Implementations (from checklist)
4.1 Send Invoice Email
@OnEvent(OnCreateSaleEvent.eventName, { async: true })
async handleOnCreateSale(event: OnCreateSaleEvent): Promise<void> {
// Send invoice email when sale is created
}
4.2 Send Payment Confirmation
@OnEvent(OnPaymentReceivedEvent.eventName, { async: true })
async handleOnPaymentReceived(event: OnPaymentReceivedEvent): Promise<void> {
// Send payment confirmation email
}
4.3 Send Collection Reminder
@Cron(CronExpression.EVERY_DAY_AT_9AM)
async sendCollectionReminders(): Promise<void> {
// Check for overdue invoices and send reminders
}
4.4 Send Low Stock Alerts
@OnEvent(OnInventoryBelowThresholdEvent.eventName, { async: true })
async handleLowStock(event: OnInventoryBelowThresholdEvent): Promise<void> {
// Send low stock alert to inventory managers
}
Testing
Unit Test the Handler
describe('OnCreateInviteHandler', () => {
let handler: OnCreateInviteHandler;
let communicationsService: jest.Mocked<CommunicationsService>;
beforeEach(() => {
communicationsService = {
send: jest.fn(),
} as any;
handler = new OnCreateInviteHandler(communicationsService);
});
it('should send invitation email when invite is created', async () => {
const invite = {
id: 'invite-123',
email: 'user@example.com',
businessId: 'business-123',
// ...
};
const event = new OnCreateInviteEvent(invite);
await handler.handleOnCreateInvite(event);
expect(communicationsService.send).toHaveBeenCalledWith(
expect.objectContaining({
recipientContact: 'user@example.com',
channel: 'email',
})
);
});
});
Integration Test
it('should send email when invite is created via API', async () => {
// Create invite via API
const response = await request(app.getHttpServer())
.post('/invites')
.send({ email: 'test@example.com', ... });
// Wait for event processing
await new Promise(resolve => setTimeout(resolve, 100));
// Check that communication was created
const communications = await communicationsService.findAll({
filter: { entityType: 'invite', entityId: response.body.id }
});
expect(communications).toHaveLength(1);
expect(communications[0].recipientContact).toBe('test@example.com');
});
Monitoring & Debugging
Logging
All handlers log their activities:
[INFO] 📧 Sending invitation email for invite abc-123 to user@example.com
[INFO] ✅ Invitation email queued successfully for user@example.com
Finding Event Handlers
Search for event listeners:
grep -r "@OnEvent" apps/backend/src/
Checking Event Names
grep -r "eventName =" apps/backend/src/
Common Issues
| Issue | Solution |
|---|---|
| Handler not triggering | Check that handler is in module's providers array |
| Event emitted but not received | Check event name matches exactly |
| Handler fails silently | Add try/catch with logging |
| Circular dependency | Use forwardRef in both modules |
Migration Guide
Converting Direct Calls to Events
Before (Tight coupling):
// InvitesService
async createInvite(invite: InsertableInvite) {
const newInvite = await this.repository.create(invite);
// Direct call to another service
await this.emailService.sendInviteEmail(newInvite); // ❌ Tight coupling
return newInvite;
}
After (Loose coupling):
// InvitesService
async createInvite(invite: InsertableInvite) {
const newInvite = await this.repository.create(invite);
// Emit event instead
this.eventEmitter.emit(
OnCreateInviteEvent.eventName,
new OnCreateInviteEvent(newInvite)
); // ✅ Loose coupling
return newInvite;
}
// Separate handler in CommunicationsModule
@OnEvent(OnCreateInviteEvent.eventName)
async handleInviteCreated(event: OnCreateInviteEvent) {
await this.communicationsService.send({...});
}
Next Steps
- ✅ Invitations - Already implemented
- ⏳ Sales/Invoices - Implement
OnCreateSaleHandler(Phase 4.1) - ⏳ Payments - Implement
OnPaymentReceivedHandler(Phase 4.3) - ⏳ Collections - Implement scheduled job (Phase 4.8)
- ⏳ Low Stock - Implement
OnLowStockHandler(Phase 4.9)
Created: October 24, 2025 Author: FlowPOS Development Team Version: 1.0