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