Service Architecture - ThriveSend B2B2G
Service Architecture - ThriveSend B2B2G
Section titled “Service Architecture - ThriveSend B2B2G”Last Updated: October 30, 2025
Overview
Section titled “Overview”ThriveSend B2B2G uses a service layer pattern to encapsulate business logic, separate from API routes and UI components. This guide explains the service architecture with real code examples from the production codebase.
Service Layer Pattern
Section titled “Service Layer Pattern”API Routes (src/app/api/) ↓ callsService Layer (src/lib/services/) ↓ usesData Access (Prisma ORM) ↓ queriesDatabase (PostgreSQL)Benefits:
- ✅ Reusable business logic across API routes and server components
- ✅ Easier to test (mock database calls)
- ✅ Centralized validation and error handling
- ✅ Clear separation of concerns
- ✅ Consistent POPIA audit logging
Service Directory Structure
Section titled “Service Directory Structure”src/lib/services/├── client-management.ts # Client CRUD and workflows├── campaign-management.ts # Campaign operations├── analytics.ts # Analytics and reporting├── compliance-monitoring.ts # POPIA compliance├── email-service.ts # Email sending (Resend)├── invitation-security.ts # Invitation system├── database.ts # Database utilities├── approval-workflow.ts # Content approval├── breach-detection.ts # Security monitoring└── reports/ # Report generation ├── analytics-reports.ts └── compliance-reports.ts25 service modules in total, each focused on a specific domain.
Service Class Pattern
Section titled “Service Class Pattern”Example: ClientManagementService
Section titled “Example: ClientManagementService”Real code from src/lib/services/client-management.ts:
import { prisma } from '@/lib/db'import { databaseService } from '@/lib/services/database'import { Client, ClientType, ClientStatus, SecurityClearanceLevel, Prisma} from '@prisma/client'
// =============================================================================// TYPE DEFINITIONS// =============================================================================
export interface CreateClientRequest { organizationId: string name: string slug: string type: ClientType
// Government-specific fields governmentLevel?: GovernmentLevel municipalityCode?: string department?: string securityClearance?: SecurityClearanceLevel
// Contact information contactEmail: string contactPhone?: string address?: Record<string, unknown>
// Compliance popiaConsent: boolean consentDate?: Date dataRetentionPeriod?: number
// Custom fields settings?: Record<string, unknown> notes?: string}
export interface ClientResponse { id: string name: string slug: string type: ClientType status: ClientStatus
// Government specific governmentLevel?: GovernmentLevel securityClearance?: SecurityClearanceLevel
// Compliance popiaConsent: boolean complianceStatus: 'COMPLIANT' | 'NON_COMPLIANT' | 'PENDING'
// Metadata createdAt: Date updatedAt: Date
// Related data counts campaignCount: number activeCampaignCount: number}
// =============================================================================// SERVICE CLASS// =============================================================================
export class ClientManagementService {
/** * Create a new client with sector-specific validation */ async createClient( data: CreateClientRequest, userId: string, ipAddress?: string, userAgent?: string ): Promise<ClientResponse> { try { // Step 1: Validate sector-specific requirements this.validateSectorRequirements(data)
// Step 2: Check for duplicate clients await this.validateNoDuplicateClient( data.organizationId, data.name, data.contactEmail )
// Step 3: Determine security clearance for government clients const securityClearance = this.determineSecurityClearance(data)
// Step 4: Generate unique slug const uniqueSlug = await this.generateUniqueSlug( data.slug, data.organizationId )
// Step 5: Create client in database const client = await prisma.client.create({ data: { organizationId: data.organizationId, name: data.name, slug: uniqueSlug, type: data.type, status: 'ACTIVE',
// Government fields governmentLevel: data.governmentLevel, municipalityCode: data.municipalityCode, department: data.department, securityClearance,
// Contact contactEmail: data.contactEmail, contactPhone: data.contactPhone, address: data.address as Prisma.JsonObject,
// Compliance popiaConsent: data.popiaConsent, consentDate: data.consentDate || new Date(), dataRetentionPeriod: data.dataRetentionPeriod || 365,
// Settings settings: data.settings as Prisma.JsonObject, notes: data.notes, }, })
// Step 6: POPIA Audit Logging await this.logClientActivity( 'CLIENT_CREATED', client.id, userId, { clientName: client.name, clientType: client.type, securityClearance, }, ipAddress, userAgent )
// Step 7: Transform to response format return this.transformClientToResponse(client)
} catch (error) { console.error('[ClientManagementService] Create client error:', error) throw error } }
/** * Get client by ID with authorization check */ async getClientById( clientId: string, organizationId: string, userId: string ): Promise<ClientResponse | null> { try { const client = await prisma.client.findFirst({ where: { id: clientId, organizationId, // Multi-tenant isolation }, include: { _count: { select: { campaigns: true, campaigns: { where: { status: 'ACTIVE' } } } } } })
if (!client) { return null }
// POPIA Audit: Log data access await this.logClientActivity( 'CLIENT_VIEWED', client.id, userId, { clientName: client.name } )
return this.transformClientToResponse(client)
} catch (error) { console.error('[ClientManagementService] Get client error:', error) throw error } }
/** * List clients with filtering and pagination */ async listClients( filters: ClientFilters ): Promise<ClientListResponse> { try { // Build where clause const whereClause: Prisma.ClientWhereInput = { organizationId: filters.organizationId, }
if (filters.type && filters.type.length > 0) { whereClause.type = { in: filters.type } }
if (filters.status && filters.status.length > 0) { whereClause.status = { in: filters.status } }
if (filters.searchTerm) { whereClause.OR = [ { name: { contains: filters.searchTerm, mode: 'insensitive' } }, { contactEmail: { contains: filters.searchTerm, mode: 'insensitive' } }, ] }
// Calculate pagination const page = filters.page || 1 const limit = filters.limit || 10 const skip = (page - 1) * limit
// Execute queries in parallel const [clients, total] = await Promise.all([ prisma.client.findMany({ where: whereClause, include: { _count: { select: { campaigns: true, } } }, skip, take: limit, orderBy: { [filters.sortBy || 'createdAt']: filters.sortOrder || 'DESC' } }), prisma.client.count({ where: whereClause }) ])
// Calculate statistics const statistics = await this.calculateClientStatistics( filters.organizationId )
return { clients: clients.map(c => this.transformClientToResponse(c)), pagination: { total, page, limit, totalPages: Math.ceil(total / limit), hasNext: page * limit < total, hasPrev: page > 1, }, statistics, }
} catch (error) { console.error('[ClientManagementService] List clients error:', error) throw error } }
// --------------------------------------------------------------------------- // PRIVATE HELPER METHODS // ---------------------------------------------------------------------------
/** * Validate sector-specific requirements */ private validateSectorRequirements(data: CreateClientRequest): void { // Government clients require specific fields if (data.type === 'MUNICIPALITY' || data.type === 'PROVINCIAL_GOVT' || data.type === 'NATIONAL_GOVT') {
if (!data.governmentLevel) { throw new Error('Government level is required for government clients') }
if (data.type === 'MUNICIPALITY' && !data.municipalityCode) { throw new Error('Municipality code is required for municipal clients') } } }
/** * Check for duplicate clients */ private async validateNoDuplicateClient( organizationId: string, name: string, email: string ): Promise<void> { const existing = await prisma.client.findFirst({ where: { organizationId, OR: [ { name: { equals: name, mode: 'insensitive' } }, { contactEmail: { equals: email, mode: 'insensitive' } }, ] } })
if (existing) { throw new Error('Client with this name or email already exists') } }
/** * Determine security clearance based on client type */ private determineSecurityClearance( data: CreateClientRequest ): SecurityClearanceLevel { // Use provided clearance if valid if (data.securityClearance) { return data.securityClearance }
// Auto-assign based on government level if (data.governmentLevel === 'NATIONAL') { return 'CONFIDENTIAL' } else if (data.governmentLevel === 'PROVINCIAL') { return 'ENHANCED' } else { return 'BASIC' } }
/** * Generate unique slug for client */ private async generateUniqueSlug( slug: string, organizationId: string ): Promise<string> { let uniqueSlug = slug let counter = 1
while (await this.slugExists(uniqueSlug, organizationId)) { uniqueSlug = `${slug}-${counter}` counter++ }
return uniqueSlug }
private async slugExists( slug: string, organizationId: string ): Promise<boolean> { const existing = await prisma.client.findFirst({ where: { slug, organizationId } }) return !!existing }
/** * Log client activity for POPIA compliance */ private async logClientActivity( action: string, clientId: string, userId: string, details: Record<string, unknown>, ipAddress?: string, userAgent?: string ): Promise<void> { await prisma.auditEvent.create({ data: { userId, action, resourceType: 'CLIENT', resourceId: clientId, details: details as Prisma.JsonObject, ipAddress, userAgent, timestamp: new Date(), } }) }
/** * Transform database client to response format */ private transformClientToResponse(client: any): ClientResponse { return { id: client.id, name: client.name, slug: client.slug, type: client.type, status: client.status, governmentLevel: client.governmentLevel, securityClearance: client.securityClearance, popiaConsent: client.popiaConsent, complianceStatus: this.determineComplianceStatus(client), createdAt: client.createdAt, updatedAt: client.updatedAt, campaignCount: client._count?.campaigns || 0, activeCampaignCount: client._count?.activeCampaigns || 0, } }
private determineComplianceStatus( client: any ): 'COMPLIANT' | 'NON_COMPLIANT' | 'PENDING' { if (!client.popiaConsent) { return 'NON_COMPLIANT' }
// Check if consent is recent (within data retention period) const consentAge = Date.now() - new Date(client.consentDate).getTime() const retentionMs = client.dataRetentionPeriod * 24 * 60 * 60 * 1000
if (consentAge > retentionMs) { return 'PENDING' // Consent needs renewal }
return 'COMPLIANT' }
private async calculateClientStatistics( organizationId: string ): Promise<any> { const [total, government, business, active] = await Promise.all([ prisma.client.count({ where: { organizationId } }), prisma.client.count({ where: { organizationId, type: { in: ['MUNICIPALITY', 'PROVINCIAL_GOVT', 'NATIONAL_GOVT'] } } }), prisma.client.count({ where: { organizationId, type: { in: ['LARGE_CORPORATION', 'MEDIUM_BUSINESS', 'SMALL_BUSINESS'] } } }), prisma.client.count({ where: { organizationId, status: 'ACTIVE' } }), ])
return { totalClients: total, governmentClients: government, businessClients: business, activeClients: active, complianceStats: { compliant: 0, // Calculate separately nonCompliant: 0, pending: 0, } } }}
// Export singleton instanceexport const clientManagementService = new ClientManagementService()Screenshot Placeholder:
*ClientManagementService class showing methods and TypeScript types*Service Pattern Best Practices
Section titled “Service Pattern Best Practices”1. Type Safety with TypeScript
Section titled “1. Type Safety with TypeScript”Define clear interfaces for requests and responses:
// Input typeexport interface CreateClientRequest { organizationId: string name: string type: ClientType // ... other fields}
// Output typeexport interface ClientResponse { id: string name: string status: ClientStatus // ... other fields}2. Validation Before Database Operations
Section titled “2. Validation Before Database Operations”async createClient(data: CreateClientRequest): Promise<ClientResponse> { // Validate first this.validateSectorRequirements(data) await this.validateNoDuplicateClient(data.name, data.email)
// Then create const client = await prisma.client.create({ data })
return client}3. POPIA Audit Logging
Section titled “3. POPIA Audit Logging”Log all data operations:
// After creating/reading/updating/deletingawait this.logActivity( 'CLIENT_CREATED', // Action client.id, // Resource ID userId, // Who performed it { clientName: client.name }, // Additional details ipAddress, // Where from userAgent // How)4. Multi-Tenant Isolation
Section titled “4. Multi-Tenant Isolation”Always include organization ID in queries:
// ✅ Good: Organization-scoped queryconst client = await prisma.client.findFirst({ where: { id: clientId, organizationId: organizationId // Ensures multi-tenant isolation }})
// ❌ Bad: Missing organization check (security risk!)const client = await prisma.client.findFirst({ where: { id: clientId }})5. Error Handling
Section titled “5. Error Handling”try { // Service logic const result = await this.performOperation() return result} catch (error) { // Log error (without PII) console.error('[ServiceName] Operation error:', error)
// Re-throw or return friendly error throw new Error('Failed to perform operation')}6. Export Singleton Instance
Section titled “6. Export Singleton Instance”// At the end of service fileexport const clientManagementService = new ClientManagementService()
// Usage in API routesimport { clientManagementService } from '@/lib/services/client-management'
const client = await clientManagementService.createClient(data, userId)Service Communication Pattern
Section titled “Service Communication Pattern”Services can call other services:
export class CampaignManagementService {
async createCampaign(data: CreateCampaignRequest): Promise<Campaign> { // Validate client exists using client service const client = await clientManagementService.getClientById( data.clientId, data.organizationId, data.userId )
if (!client) { throw new Error('Client not found') }
// Check security clearance if (client.securityClearance === 'CONFIDENTIAL') { // Apply government-specific validation }
// Create campaign const campaign = await prisma.campaign.create({ data })
return campaign }}Using Services in API Routes
Section titled “Using Services in API Routes”Example: API route using service
import { auth } from '@clerk/nextjs/server'import { NextRequest, NextResponse } from 'next/server'import { clientManagementService } from '@/lib/services/client-management'
export async function POST(request: NextRequest) { try { // Step 1: Authentication const { userId, orgId } = await auth() if (!userId || !orgId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
// Step 2: Parse request body const body = await request.json()
// Step 3: Call service (business logic handled there) const client = await clientManagementService.createClient( { organizationId: orgId, ...body }, userId, request.headers.get('x-forwarded-for') || undefined, request.headers.get('user-agent') || undefined )
// Step 4: Return response return NextResponse.json({ success: true, data: client }, { status: 201 })
} catch (error) { console.error('[API] Create client error:', error) return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 }) }}Benefits of this pattern:
- API route stays thin (only handles HTTP concerns)
- Business logic in service (reusable, testable)
- Service handles validation, POPIA logging, database operations
- Easy to test service independently
Testing Services
Section titled “Testing Services”Unit Test Example
Section titled “Unit Test Example”import { clientManagementService } from '@/lib/services/client-management'import { prisma } from '@/lib/db'
// Mock Prismajest.mock('@/lib/db', () => ({ prisma: { client: { create: jest.fn(), findFirst: jest.fn(), }, auditEvent: { create: jest.fn(), } }}))
describe('ClientManagementService', () => { describe('createClient', () => { it('should create a client with correct data', async () => { const mockClient = { id: 'client-123', name: 'Test Client', type: 'LARGE_CORPORATION', status: 'ACTIVE', }
;(prisma.client.create as jest.Mock).mockResolvedValue(mockClient)
const result = await clientManagementService.createClient( { organizationId: 'org-123', name: 'Test Client', slug: 'test-client', type: 'LARGE_CORPORATION', contactEmail: 'test@example.com', popiaConsent: true, }, 'user-123' )
expect(result.id).toBe('client-123') expect(prisma.client.create).toHaveBeenCalledTimes(1) expect(prisma.auditEvent.create).toHaveBeenCalledTimes(1) })
it('should throw error for duplicate client', async () => { ;(prisma.client.findFirst as jest.Mock).mockResolvedValue({ id: 'existing-client' })
await expect( clientManagementService.createClient( { organizationId: 'org-123', name: 'Duplicate', slug: 'duplicate', type: 'LARGE_CORPORATION', contactEmail: 'duplicate@example.com', popiaConsent: true, }, 'user-123' ) ).rejects.toThrow('Client with this name or email already exists') }) })})Service Architecture Principles
Section titled “Service Architecture Principles”1. Single Responsibility
Section titled “1. Single Responsibility”Each service handles one domain (clients, campaigns, analytics, etc.)
2. Dependency Injection
Section titled “2. Dependency Injection”Services receive dependencies (database client, other services) rather than creating them
3. Stateless
Section titled “3. Stateless”Services don’t maintain state between calls (stateless operations)
4. Reusability
Section titled “4. Reusability”Service methods can be called from API routes, server components, or other services
5. Testability
Section titled “5. Testability”Services are easy to unit test by mocking dependencies (Prisma, other services)
Next Steps
Section titled “Next Steps”- Database Guide - Prisma patterns used in services
Last Updated: October 30, 2025