Recipient Targeting System - Hexagonal Architecture Implementation β
Status: Phase 1 Complete! πβ
Date Completed: October 25, 2025
β What's Been Accomplishedβ
1. Database Layer β β
- Migration file created and approved
- Migration successfully run
- 4 new tables created:
communication_recipient_groupcommunication_group_memberbusiness_communication_recipient_rulecommunication_recipient_log
- 2 new enum types created:
recipient_member_typerecipient_targeting_type
2. Domain Layer (Ports) β β
Following hexagonal architecture principles:
Created Domain Interfaces:
-
apps/backend/src/recipient-groups/domain/recipient-groups-repository.domain.tsIRecipientGroupsRepositoryinterface- Defines contract for group operations
-
apps/backend/src/recipient-rules/domain/recipient-rules-repository.domain.tsIRecipientRulesRepositoryinterface- Defines contract for rule operations
Purpose: Domain layer defines the business rules and contracts (ports) without any implementation details.
3. Infrastructure Layer (Adapters) β β
Created Repository Implementations:
-
apps/backend/src/recipient-groups/infrastructure/recipient-groups.repository.ts- Implements
IRecipientGroupsRepository - Handles database operations via Kysely
- Methods: create, find, update, delete groups and members
- Implements
-
apps/backend/src/recipient-rules/infrastructure/recipient-rules.repository.ts- Implements
IRecipientRulesRepository - Handles rule queries and mutations
- Key method:
findActiveRules()for recipient resolution
- Implements
Purpose: Infrastructure layer provides concrete implementations of domain interfaces using external dependencies (database).
4. Application Layer (Use Cases) β β
Created Core Service:
-
apps/backend/src/communications/application/services/recipient-resolver.service.ts- RecipientResolverService - Core business logic
- Depends only on domain interfaces (ports), not infrastructure
- Main method:
resolveRecipients()- resolves rules to actual recipients - Handles: role-based, group-based, and ad-hoc targeting
- Implements deduplication logic
- Includes fallback for backward compatibility
Purpose: Application layer orchestrates business logic using domain contracts.
5. Module Configuration (DI) β β
Created Modules with Proper Dependency Injection:
-
apps/backend/src/recipient-groups/recipient-groups.module.ts- Binds
IRecipientGroupsRepositoryβRecipientGroupsRepository - Exports both interface and implementation
- Binds
-
apps/backend/src/recipient-rules/recipient-rules.module.ts- Binds
IRecipientRulesRepositoryβRecipientRulesRepository - Exports both interface and implementation
- Binds
Updated Existing Module:
-
apps/backend/src/communications/communications.module.ts- Imports: RecipientGroupsModule, RecipientRulesModule
- Provides: RecipientResolverService
- Exports: RecipientResolverService (for use in other modules)
6. Type Definitions β β
Created Type System:
-
packages/backend/database/src/types/recipient-targeting.types.ts- Selectable, Insertable, Updateable types for all tables
- Enum types: RecipientMemberType, RecipientTargetingType
ResolvedRecipientinterface (core DTO)RecipientRuleFiltersinterfaceGroupMemberWithUserinterface (for joins)
7. Build & Compilation β β
- All TypeScript compilation errors fixed
- Backend builds successfully
- Hexagonal architecture properly implemented
- Dependency injection working correctly
ποΈ Hexagonal Architecture Implementedβ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β DOMAIN LAYER (Ports) β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β IRecipientGroupsRepository (Interface) β β
β β IRecipientRulesRepository (Interface) β β
β β β Defines business contracts β β
β β β No dependencies β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β²
β depends on
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β APPLICATION LAYER (Use Cases) β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β RecipientResolverService β β
β β β Orchestrates business logic β β
β β β Depends on domain interfaces (ports) β β
β β β resolveRecipients(), previewRecipients() β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β²
β implemented by
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β INFRASTRUCTURE LAYER (Adapters) β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β RecipientGroupsRepository (implements port) β β
β β RecipientRulesRepository (implements port) β β
β β β Concrete implementations β β
β β β Uses Kysely for database access β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Benefits:
- β Business logic independent of infrastructure
- β Easy to test (mock interfaces)
- β Easy to swap implementations
- β Clear separation of concerns
- β Dependency inversion principle
π¦ File Structure Createdβ
apps/backend/src/
βββ recipient-groups/
β βββ domain/
β β βββ recipient-groups-repository.domain.ts β PORT
β βββ infrastructure/
β β βββ recipient-groups.repository.ts β ADAPTER
β βββ recipient-groups.module.ts β DI CONFIG
β
βββ recipient-rules/
β βββ domain/
β β βββ recipient-rules-repository.domain.ts β PORT
β βββ infrastructure/
β β βββ recipient-rules.repository.ts β ADAPTER
β βββ recipient-rules.module.ts β DI CONFIG
β
βββ communications/
βββ application/
βββ services/
βββ recipient-resolver.service.ts β USE CASE
packages/backend/database/src/
βββ types/
βββ recipient-targeting.types.ts β TYPES
π§ͺ What You Can Test Nowβ
Test 1: Create a Test Group (SQL)β
-- Insert a test group
INSERT INTO communication_recipient_group
(business_id, name, description, is_active, created_by, created_at)
VALUES
(
'YOUR_BUSINESS_ID',
'Test Recipient Group',
'Testing the recipient targeting system',
true,
'YOUR_USER_ID',
NOW()
)
RETURNING *;
Test 2: Add Members to Groupβ
-- Add a business user
INSERT INTO communication_group_member
(group_id, member_type, business_user_id, is_active, added_by, added_at)
VALUES
('GROUP_ID_FROM_PREVIOUS_STEP', 'business_user', 'BUSINESS_USER_ID', true, 'YOUR_USER_ID', NOW())
RETURNING *;
-- Add an external email
INSERT INTO communication_group_member
(group_id, member_type, email_address, display_name, is_active, added_by, added_at)
VALUES
('GROUP_ID_FROM_PREVIOUS_STEP', 'email', 'test@example.com', 'Test Contact', true, 'YOUR_USER_ID', NOW())
RETURNING *;
Test 3: Create Test Rulesβ
-- Rule: Send to inventory_manager role
INSERT INTO business_communication_recipient_rule
(business_id, communication_type, channel, targeting_type, role_name, is_active, priority, created_by, created_at)
VALUES
('YOUR_BUSINESS_ID', 'low_stock_alert', 'email', 'role', 'inventory_manager', true, 1, 'YOUR_USER_ID', NOW())
RETURNING *;
-- Rule: Send to test group
INSERT INTO business_communication_recipient_rule
(business_id, communication_type, channel, targeting_type, group_id, is_active, priority, created_by, created_at)
VALUES
('YOUR_BUSINESS_ID', 'low_stock_alert', 'email', 'group', 'GROUP_ID', true, 2, 'YOUR_USER_ID', NOW())
RETURNING *;
π― What's Next (Phase 2)β
Step 1: Update Low Stock Alert Handlerβ
File to modify: apps/backend/src/communications/application/events/on-create-low-stock-alert.handler.ts
Changes needed:
- Inject
RecipientResolverServicein constructor - Replace hardcoded recipient logic with
recipientResolverService.resolveRecipients() - Update send loop to use resolved recipients
Template code:
constructor(
// ... existing dependencies
private readonly recipientResolverService: RecipientResolverService,
) {}
async handleOnCreateLowStockAlert(event: OnCreateLowStockAlertEvent) {
// OLD CODE (remove):
// const businessUsers = await this.businessUsersService.findByBusinessId(...);
// NEW CODE (add):
const recipients = await this.recipientResolverService.resolveRecipients(
businessId,
'low_stock_alert',
'email'
);
// Update send loop to use recipients instead of businessUsers
for (const recipient of recipients) {
await this.communicationsService.send({
recipientType: recipient.type,
recipientId: recipient.userId,
recipientContact: recipient.contact,
recipientName: recipient.name,
// ... rest of your config
});
}
}
Step 2: Test End-to-Endβ
- Create test data (groups + rules) via SQL
- Trigger a low stock alert
- Verify recipients are resolved correctly
- Check logs for recipient selection details
Step 3: Create APIs (Optional for now)β
Create REST APIs for managing groups and rules:
- Group CRUD endpoints
- Member management endpoints
- Rule CRUD endpoints
- Preview endpoint (see who will receive before saving)
Step 4: Create Admin UI (Later)β
Build frontend interfaces for:
- Group management page
- Rule configuration page
- Recipient preview feature
π Progress Summaryβ
| Phase | Status | Completion |
|---|---|---|
| Phase 1: Foundation | β Complete | 100% |
| ββ Database Migration | β | |
| ββ Domain Layer (Ports) | β | |
| ββ Infrastructure Layer | β | |
| ββ Application Layer | β | |
| ββ Module Configuration | β | |
| ββ Build & Compilation | β | |
| Phase 2: Integration | π Next | 0% |
| ββ Update Low Stock Handler | π | |
| ββ End-to-end Testing | π | |
| ββ Logging & Debugging | π | |
| Phase 3: APIs | π Future | 0% |
| Phase 4: Admin UI | π Future | 0% |
π Key Architectural Decisionsβ
1. Hexagonal Architectureβ
Why: Separates business logic from infrastructure concerns
- Domain defines contracts (ports)
- Infrastructure provides implementations (adapters)
- Application orchestrates using ports
2. Dependency Injectionβ
Why: Loose coupling, testability
- Modules bind interfaces to implementations
- Services depend on interfaces, not concrete classes
3. Polymorphic Data Modelβ
Why: Flexibility for future extensions
- Groups can contain different member types
- Rules support multiple targeting types
- Easy to add new types without schema changes
4. Deduplication Logicβ
Why: Same person via multiple rules should receive once
- Prevents spam
- Better user experience
5. Fallback Behaviorβ
Why: Backward compatibility
- If no rules configured β send to all business users
- Existing handlers continue to work
π Notes & Considerationsβ
Type Casting in Kyselyβ
We use as any for enum types in Kysely queries because:
- Kysely's type system is strict about enums
- Our database enums need explicit casting
- Alternative: Use
sqltagged template for enums (more verbose)
Example:
.where("communicationType", "=", filters.communicationType as any)
Null vs Undefinedβ
Database columns can be null, but TypeScript prefers undefined for optional properties.
- Joined user fields:
userName?: string | null - This allows proper handling of LEFT JOINs
π Ready to Proceedβ
You are now ready to:
- β Test the database manually (create groups/rules via SQL)
- β Update the low stock alert handler to use RecipientResolverService
- β Test end-to-end flow with real low stock alerts
- β Build APIs when you're ready
- β Build admin UI when you're ready
All foundational work is complete following hexagonal architecture principles! π
π Supportβ
Documentation:
QUICK-START-IMPLEMENTATION.md- Step-by-step guideRECIPIENT-TARGETING-PROPOSAL.md- Full proposalrecipient-targeting-system-design.md- Architecture detailsrecipient-targeting-erd.md- Database diagrams
Need Help?
- Review the implementation checklist
- Check the architecture design doc
- Test manually with SQL queries first
Congratulations! Phase 1 is complete and builds successfully! π