Saltar al contenido principal

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:

  1. Keep handlers focused - One handler per event type
  2. Use async: true - Don't block the event emitter
  3. Log events - Track what's happening
  4. Handle errors gracefully - Don't throw, log and continue
  5. Use descriptive event names - "invoice.created", "payment.received"
  6. Keep event payloads minimal - Only include IDs, fetch full data in handler
  7. Place handlers in the listening module - Not in the emitting module

DON'T:

  1. Don't throw errors in handlers - This will break the event emitter
  2. Don't make handlers dependent on return values - They're fire-and-forget
  3. Don't use events for synchronous workflows - Use direct service calls instead
  4. Don't emit too many events - Only for significant domain events
  5. 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

IssueSolution
Handler not triggeringCheck that handler is in module's providers array
Event emitted but not receivedCheck event name matches exactly
Handler fails silentlyAdd try/catch with logging
Circular dependencyUse 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

  1. Invitations - Already implemented
  2. Sales/Invoices - Implement OnCreateSaleHandler (Phase 4.1)
  3. Payments - Implement OnPaymentReceivedHandler (Phase 4.3)
  4. Collections - Implement scheduled job (Phase 4.8)
  5. Low Stock - Implement OnLowStockHandler (Phase 4.9)

Created: October 24, 2025 Author: FlowPOS Development Team Version: 1.0