Skip to content

Service Architecture - ThriveSend B2B2G

Last Updated: October 30, 2025


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.


API Routes (src/app/api/)
↓ calls
Service Layer (src/lib/services/)
↓ uses
Data Access (Prisma ORM)
↓ queries
Database (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

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.ts

25 service modules in total, each focused on a specific domain.


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 instance
export const clientManagementService = new ClientManagementService()

Screenshot Placeholder:

../../../assets/images/screenshots/dev-service-client-management.png
*ClientManagementService class showing methods and TypeScript types*

Define clear interfaces for requests and responses:

// Input type
export interface CreateClientRequest {
organizationId: string
name: string
type: ClientType
// ... other fields
}
// Output type
export interface ClientResponse {
id: string
name: string
status: ClientStatus
// ... other fields
}
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
}

Log all data operations:

// After creating/reading/updating/deleting
await this.logActivity(
'CLIENT_CREATED', // Action
client.id, // Resource ID
userId, // Who performed it
{ clientName: client.name }, // Additional details
ipAddress, // Where from
userAgent // How
)

Always include organization ID in queries:

// ✅ Good: Organization-scoped query
const 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 }
})
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')
}
// At the end of service file
export const clientManagementService = new ClientManagementService()
// Usage in API routes
import { clientManagementService } from '@/lib/services/client-management'
const client = await clientManagementService.createClient(data, userId)

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
}
}

Example: API route using service

src/app/api/clients/route.ts
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

import { clientManagementService } from '@/lib/services/client-management'
import { prisma } from '@/lib/db'
// Mock Prisma
jest.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')
})
})
})

Each service handles one domain (clients, campaigns, analytics, etc.)

Services receive dependencies (database client, other services) rather than creating them

Services don’t maintain state between calls (stateless operations)

Service methods can be called from API routes, server components, or other services

Services are easy to unit test by mocking dependencies (Prisma, other services)



Last Updated: October 30, 2025