# Services and Repositories - Business Logic Layer Complete guide to organizing business logic with services and data access with repositories. ## Table of Contents - [Service Layer Overview](#service-layer-overview) - [Dependency Injection Pattern](#dependency-injection-pattern) - [Singleton Pattern](#singleton-pattern) - [Repository Pattern](#repository-pattern) - [Service Design Principles](#service-design-principles) - [Caching Strategies](#caching-strategies) - [Testing Services](#testing-services) --- ## Service Layer Overview ### Purpose of Services **Services contain business logic** - the 'what' and 'why' of your application: ``` Controller asks: "Should I do this?" Service answers: "Yes/No, here's why, and here's what happens" Repository executes: "Here's the data you requested" ``` **Services are responsible for:** - ✅ Business rules enforcement - ✅ Orchestrating multiple repositories - ✅ Transaction management - ✅ Complex calculations - ✅ External service integration - ✅ Business validations **Services should NOT:** - ❌ Know about HTTP (Request/Response) - ❌ Direct Prisma access (use repositories) - ❌ Handle route-specific logic - ❌ Format HTTP responses --- ## Dependency Injection Pattern ### Why Dependency Injection? **Benefits:** - Easy to test (inject mocks) - Clear dependencies - Flexible configuration - Promotes loose coupling ### Excellent Example: NotificationService **File:** `/blog-api/src/services/NotificationService.ts` ```typescript // Define dependencies interface for clarity export interface NotificationServiceDependencies { prisma: PrismaClient; batchingService: BatchingService; emailComposer: EmailComposer; } // Service with dependency injection export class NotificationService { private prisma: PrismaClient; private batchingService: BatchingService; private emailComposer: EmailComposer; private preferencesCache: Map = new Map(); private CACHE_TTL = (notificationConfig.preferenceCacheTTLMinutes || 5) * 60 * 1000; // Dependencies injected via constructor constructor(dependencies: NotificationServiceDependencies) { this.prisma = dependencies.prisma; this.batchingService = dependencies.batchingService; this.emailComposer = dependencies.emailComposer; } /** * Create a notification and route it appropriately */ async createNotification(params: CreateNotificationParams) { const { recipientID, type, title, message, link, context = {}, channel = 'both', priority = NotificationPriority.NORMAL } = params; try { // Get template and render content const template = getNotificationTemplate(type); const rendered = renderNotificationContent(template, context); // Create in-app notification record const notificationId = await createNotificationRecord({ instanceId: parseInt(context.instanceId || '0', 10), template: type, recipientUserId: recipientID, channel: channel === 'email' ? 'email' : 'inApp', contextData: context, title: finalTitle, message: finalMessage, link: finalLink, }); // Route notification based on channel if (channel === 'email' || channel === 'both') { await this.routeNotification({ notificationId, userId: recipientID, type, priority, title: finalTitle, message: finalMessage, link: finalLink, context, }); } return notification; } catch (error) { ErrorLogger.log(error, { context: { '[NotificationService] createNotification': { type: params.type, recipientID: params.recipientID, }, }, }); throw error; } } /** * Route notification based on user preferences */ private async routeNotification(params: { notificationId: number; userId: string; type: string; priority: NotificationPriority; title: string; message: string; link?: string; context?: Record }) { // Get user preferences with caching const preferences = await this.getUserPreferences(params.userId); // Check if we should batch or send immediately if (this.shouldBatchEmail(preferences, params.type, params.priority)) { await this.batchingService.queueNotificationForBatch({ notificationId: params.notificationId, userId: params.userId, userPreference: preferences, priority: params.priority, }); } else { // Send immediately via EmailComposer await this.sendImmediateEmail({ userId: params.userId, title: params.title, message: params.message, link: params.link, context: params.context, type: params.type, }); } } /** * Determine if email should be batched */ shouldBatchEmail(preferences: UserPreference, notificationType: string, priority: NotificationPriority): boolean { // HIGH priority always immediate if (priority === NotificationPriority.HIGH) { return false; } // Check batch mode const batchMode = preferences.emailBatchMode || BatchMode.IMMEDIATE; return batchMode !== BatchMode.IMMEDIATE; } /** * Get user preferences with caching */ async getUserPreferences(userId: string): Promise { // Check cache first const cached = this.preferencesCache.get(userId); if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { return cached.preferences; } const preference = await this.prisma.userPreference.findUnique({ where: { userID: userId }, }); const finalPreferences = preference || DEFAULT_PREFERENCES; // Update cache this.preferencesCache.set(userId, { preferences: finalPreferences, timestamp: Date.now(), }); return finalPreferences; } } ``` **Usage in Controller:** ```typescript // Instantiate with dependencies const notificationService = new NotificationService({ prisma: PrismaService.main, batchingService: new BatchingService(PrismaService.main), emailComposer: new EmailComposer(), }); // Use in controller const notification = await notificationService.createNotification({ recipientID: 'user-123', type: 'AFRLWorkflowNotification', context: { workflowName: 'AFRL Monthly Report' }, }); ``` **Key Takeaways:** - Dependencies passed via constructor - Clear interface defines required dependencies - Easy to test (inject mocks) - Encapsulated caching logic - Business rules isolated from HTTP --- ## Singleton Pattern ### When to Use Singletons **Use for:** - Services with expensive initialization - Services with shared state (caching) - Services accessed from many places - Permission services - Configuration services ### Example: PermissionService (Singleton) **File:** `/blog-api/src/services/permissionService.ts` ```typescript import { PrismaClient } from '@prisma/client'; class PermissionService { private static instance: PermissionService; private prisma: PrismaClient; private permissionCache: Map = new Map(); private CACHE_TTL = 5 * 60 * 1000; // 5 minutes // Private constructor prevents direct instantiation private constructor() { this.prisma = PrismaService.main; } // Get singleton instance public static getInstance(): PermissionService { if (!PermissionService.instance) { PermissionService.instance = new PermissionService(); } return PermissionService.instance; } /** * Check if user can complete a workflow step */ async canCompleteStep(userId: string, stepInstanceId: number): Promise { const cacheKey = `${userId}:${stepInstanceId}`; // Check cache const cached = this.permissionCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { return cached.canAccess; } try { const post = await this.prisma.post.findUnique({ where: { id: postId }, include: { author: true, comments: { include: { user: true, }, }, }, }); if (!post) { return false; } // Check if user has permission const canEdit = post.authorId === userId || await this.isUserAdmin(userId); // Cache result this.permissionCache.set(cacheKey, { canAccess: isAssigned, timestamp: Date.now(), }); return isAssigned; } catch (error) { console.error('[PermissionService] Error checking step permission:', error); return false; } } /** * Clear cache for user */ clearUserCache(userId: string): void { for (const [key] of this.permissionCache) { if (key.startsWith(`${userId}:`)) { this.permissionCache.delete(key); } } } /** * Clear all cache */ clearCache(): void { this.permissionCache.clear(); } } // Export singleton instance export const permissionService = PermissionService.getInstance(); ``` **Usage:** ```typescript import { permissionService } from '../services/permissionService'; // Use anywhere in the codebase const canComplete = await permissionService.canCompleteStep(userId, stepId); if (!canComplete) { throw new ForbiddenError('You do not have permission to complete this step'); } ``` --- ## Repository Pattern ### Purpose of Repositories **Repositories abstract data access** - the 'how' of data operations: ``` Service: "Get me all active users sorted by name" Repository: "Here's the Prisma query that does that" ``` **Repositories are responsible for:** - ✅ All Prisma operations - ✅ Query construction - ✅ Query optimization (select, include) - ✅ Database error handling - ✅ Caching database results **Repositories should NOT:** - ❌ Contain business logic - ❌ Know about HTTP - ❌ Make decisions (that's service layer) ### Repository Template ```typescript // repositories/UserRepository.ts import { PrismaService } from '@project-lifecycle-portal/database'; import type { User, Prisma } from '@project-lifecycle-portal/database'; export class UserRepository { /** * Find user by ID with optimized query */ async findById(userId: string): Promise { try { return await PrismaService.main.user.findUnique({ where: { userID: userId }, select: { userID: true, email: true, name: true, isActive: true, roles: true, createdAt: true, updatedAt: true, }, }); } catch (error) { console.error('[UserRepository] Error finding user by ID:', error); throw new Error(`Failed to find user: ${userId}`); } } /** * Find all active users */ async findActive(options?: { orderBy?: Prisma.UserOrderByWithRelationInput }): Promise { try { return await PrismaService.main.user.findMany({ where: { isActive: true }, orderBy: options?.orderBy || { name: 'asc' }, select: { userID: true, email: true, name: true, roles: true, }, }); } catch (error) { console.error('[UserRepository] Error finding active users:', error); throw new Error('Failed to find active users'); } } /** * Find user by email */ async findByEmail(email: string): Promise { try { return await PrismaService.main.user.findUnique({ where: { email }, }); } catch (error) { console.error('[UserRepository] Error finding user by email:', error); throw new Error(`Failed to find user with email: ${email}`); } } /** * Create new user */ async create(data: Prisma.UserCreateInput): Promise { try { return await PrismaService.main.user.create({ data }); } catch (error) { console.error('[UserRepository] Error creating user:', error); throw new Error('Failed to create user'); } } /** * Update user */ async update(userId: string, data: Prisma.UserUpdateInput): Promise { try { return await PrismaService.main.user.update({ where: { userID: userId }, data, }); } catch (error) { console.error('[UserRepository] Error updating user:', error); throw new Error(`Failed to update user: ${userId}`); } } /** * Delete user (soft delete by setting isActive = false) */ async delete(userId: string): Promise { try { return await PrismaService.main.user.update({ where: { userID: userId }, data: { isActive: false }, }); } catch (error) { console.error('[UserRepository] Error deleting user:', error); throw new Error(`Failed to delete user: ${userId}`); } } /** * Check if email exists */ async emailExists(email: string): Promise { try { const count = await PrismaService.main.user.count({ where: { email }, }); return count > 0; } catch (error) { console.error('[UserRepository] Error checking email exists:', error); throw new Error('Failed to check if email exists'); } } } // Export singleton instance export const userRepository = new UserRepository(); ``` **Using Repository in Service:** ```typescript // services/userService.ts import { userRepository } from '../repositories/UserRepository'; import { ConflictError, NotFoundError } from '../utils/errors'; export class UserService { /** * Create new user with business rules */ async createUser(data: { email: string; name: string; roles: string[] }): Promise { // Business rule: Check if email already exists const emailExists = await userRepository.emailExists(data.email); if (emailExists) { throw new ConflictError('Email already exists'); } // Business rule: Validate roles const validRoles = ['admin', 'operations', 'user']; const invalidRoles = data.roles.filter((role) => !validRoles.includes(role)); if (invalidRoles.length > 0) { throw new ValidationError(`Invalid roles: ${invalidRoles.join(', ')}`); } // Create user via repository return await userRepository.create({ email: data.email, name: data.name, roles: data.roles, isActive: true, }); } /** * Get user by ID */ async getUser(userId: string): Promise { const user = await userRepository.findById(userId); if (!user) { throw new NotFoundError(`User not found: ${userId}`); } return user; } } ``` --- ## Service Design Principles ### 1. Single Responsibility Each service should have ONE clear purpose: ```typescript // ✅ GOOD - Single responsibility class UserService { async createUser() {} async updateUser() {} async deleteUser() {} } class EmailService { async sendEmail() {} async sendBulkEmails() {} } // ❌ BAD - Too many responsibilities class UserService { async createUser() {} async sendWelcomeEmail() {} // Should be EmailService async logUserActivity() {} // Should be AuditService async processPayment() {} // Should be PaymentService } ``` ### 2. Clear Method Names Method names should describe WHAT they do: ```typescript // ✅ GOOD - Clear intent async createNotification() async getUserPreferences() async shouldBatchEmail() async routeNotification() // ❌ BAD - Vague or misleading async process() async handle() async doIt() async execute() ``` ### 3. Return Types Always use explicit return types: ```typescript // ✅ GOOD - Explicit types async createUser(data: CreateUserDTO): Promise {} async findUsers(): Promise {} async deleteUser(id: string): Promise {} // ❌ BAD - Implicit any async createUser(data) {} // No types! ``` ### 4. Error Handling Services should throw meaningful errors: ```typescript // ✅ GOOD - Meaningful errors if (!user) { throw new NotFoundError(`User not found: ${userId}`); } if (emailExists) { throw new ConflictError('Email already exists'); } // ❌ BAD - Generic errors if (!user) { throw new Error('Error'); // What error? } ``` ### 5. Avoid God Services Don't create services that do everything: ```typescript // ❌ BAD - God service class WorkflowService { async startWorkflow() {} async completeStep() {} async assignRoles() {} async sendNotifications() {} // Should be NotificationService async validatePermissions() {} // Should be PermissionService async logAuditTrail() {} // Should be AuditService // ... 50 more methods } // ✅ GOOD - Focused services class WorkflowService { constructor( private notificationService: NotificationService, private permissionService: PermissionService, private auditService: AuditService ) {} async startWorkflow() { // Orchestrate other services await this.permissionService.checkPermission(); await this.workflowRepository.create(); await this.notificationService.notify(); await this.auditService.log(); } } ``` --- ## Caching Strategies ### 1. In-Memory Caching ```typescript class UserService { private cache: Map = new Map(); private CACHE_TTL = 5 * 60 * 1000; // 5 minutes async getUser(userId: string): Promise { // Check cache const cached = this.cache.get(userId); if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { return cached.user; } // Fetch from database const user = await userRepository.findById(userId); // Update cache if (user) { this.cache.set(userId, { user, timestamp: Date.now() }); } return user; } clearUserCache(userId: string): void { this.cache.delete(userId); } } ``` ### 2. Cache Invalidation ```typescript class UserService { async updateUser(userId: string, data: UpdateUserDTO): Promise { // Update in database const user = await userRepository.update(userId, data); // Invalidate cache this.clearUserCache(userId); return user; } } ``` --- ## Testing Services ### Unit Tests ```typescript // tests/userService.test.ts import { UserService } from '../services/userService'; import { userRepository } from '../repositories/UserRepository'; import { ConflictError } from '../utils/errors'; // Mock repository jest.mock('../repositories/UserRepository'); describe('UserService', () => { let userService: UserService; beforeEach(() => { userService = new UserService(); jest.clearAllMocks(); }); describe('createUser', () => { it('should create user when email does not exist', async () => { // Arrange const userData = { email: 'test@example.com', name: 'Test User', roles: ['user'], }; (userRepository.emailExists as jest.Mock).mockResolvedValue(false); (userRepository.create as jest.Mock).mockResolvedValue({ userID: '123', ...userData, }); // Act const user = await userService.createUser(userData); // Assert expect(user).toBeDefined(); expect(user.email).toBe(userData.email); expect(userRepository.emailExists).toHaveBeenCalledWith(userData.email); expect(userRepository.create).toHaveBeenCalled(); }); it('should throw ConflictError when email exists', async () => { // Arrange const userData = { email: 'existing@example.com', name: 'Test User', roles: ['user'], }; (userRepository.emailExists as jest.Mock).mockResolvedValue(true); // Act & Assert await expect(userService.createUser(userData)).rejects.toThrow(ConflictError); expect(userRepository.create).not.toHaveBeenCalled(); }); }); }); ``` --- **Related Files:** - SKILL.md - Main guide - [routing-and-controllers.md](routing-and-controllers.md) - Controllers that use services - [database-patterns.md](database-patterns.md) - Prisma and repository patterns - [complete-examples.md](complete-examples.md) - Full service/repository examples