Skip to main content

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